diff --git a/docs/API-Reference/language/CodeInspection.md b/docs/API-Reference/language/CodeInspection.md index 3993007c05..e73b770a91 100644 --- a/docs/API-Reference/language/CodeInspection.md +++ b/docs/API-Reference/language/CodeInspection.md @@ -163,7 +163,7 @@ Each error object in the results should have the following structure: htmlMessage:string, type:?Type , fix: { // an optional fix, if present will show the fix button - replace: "text to replace the offset given below", + replaceText: "text to replace the offset given below", rangeOffset: { start: number, end: number @@ -194,7 +194,7 @@ Each error object in the results should have the following structure: | htmlMessage | string | The error message to be displayed as HTML. | | type | [Type](#Type) | The type of the error. Defaults to `Type.WARNING` if unspecified. | | fix | Object | An optional fix object. | -| fix.replace | string | The text to replace the error with. | +| fix.replaceText | string | The text to replace the error with. | | fix.rangeOffset | Object | The range within the text to replace. | | fix.rangeOffset.start | number | The start offset of the range. | | fix.rangeOffset.end | number | The end offset of the range. If no errors are found, return either `null`(treated as file is problem free) or an object with a zero-length `errors` array. Always use `message` to safely display the error as text. If you want to display HTML error message, then explicitly use `htmlMessage` to display it. Both `message` and `htmlMessage` can be used simultaneously. After scanning the file, if you need to omit the lint result, return or resolve with `{isIgnored: true}`. This prevents the file from being marked with a no errors tick mark in the status bar and excludes the linter from the problems panel. | diff --git a/docs/API-Reference/widgets/NotificationUI.md b/docs/API-Reference/widgets/NotificationUI.md index 2b16dc11c2..3222a1d9bb 100644 --- a/docs/API-Reference/widgets/NotificationUI.md +++ b/docs/API-Reference/widgets/NotificationUI.md @@ -52,6 +52,8 @@ The `createFromTemplate` API can be configured with numerous options. See API op * [.createToastFromTemplate(title, template, [options])](#module_widgets/NotificationUI..createToastFromTemplate) ⇒ Notification * [.showToastOn(containerOrSelector, template, [options])](#module_widgets/NotificationUI..showToastOn) ⇒ Notification * [.showHUD(iconClass, label, [options])](#module_widgets/NotificationUI..showHUD) ⇒ Notification + * [.hideRichTooltip()](#module_widgets/NotificationUI..hideRichTooltip) : function + * [.attachRichTooltip(elements, html, [options])](#module_widgets/NotificationUI..attachRichTooltip) ⇒ Object @@ -188,3 +190,31 @@ NotificationUI.showHUD("fa-solid fa-magnifying-glass-plus", "110%"); | label | string | Text to display below the icon (e.g. "110%"). | | [options] | Object | optional, supported options: * `autoCloseTimeS` - Time in seconds after which the HUD auto-closes. Default is 1. | + + +### widgets/NotificationUI.hideRichTooltip() : function +Hide the currently showing rich tooltip (if any). + +**Kind**: inner method of [widgets/NotificationUI](#module_widgets/NotificationUI) + + +### widgets/NotificationUI.attachRichTooltip(elements, html, [options]) ⇒ Object +Attaches a rich (HTML-capable) hover tooltip to the given element(s). The tooltip is +Phoenix-themed for both light and dark themes, positioned beside the element, clamped to the +viewport, and attached to `` so scrolling containers cannot clip it. + +```js +NotificationUI.attachRichTooltip($(".my-info-icon"), "Hello world"); +// or compute content per element on show: +NotificationUI.attachRichTooltip($(".my-info-icon"), el => $(el).attr("data-info")); +``` + +**Kind**: inner method of [widgets/NotificationUI](#module_widgets/NotificationUI) +**Returns**: Object - call `detach()` to unbind the handlers and hide the tooltip + +| Param | Type | Description | +| --- | --- | --- | +| elements | jQuery \| Element \| string | element(s) or selector to attach to | +| html | string \| function | TRUSTED html string (escape untrusted parts yourself), or a function returning it for the hovered element | +| [options] | Object | optional, supported options: * `showDelayMs` - hover delay before the tooltip appears. Default 250. | + diff --git a/src-node/lsp-client.js b/src-node/lsp-client.js index 02800d35d4..423167df03 100644 --- a/src-node/lsp-client.js +++ b/src-node/lsp-client.js @@ -142,12 +142,54 @@ function handleMessage(serverId, msg) { } else { resolve(msg.result); } + } else if (msg.method && msg.id !== undefined) { + // Server-initiated REQUEST: answer it right here so the server never awaits a reply + // forever (the browser side has no response path; an unanswered request can stall the + // server's own processing - e.g. vscode-json-language-server pulling configuration). + _respondToServerRequest(serverId, server, msg); } else if (msg.method) { - // Notification or server-initiated request - forward to the browser. + // Notification - forward to the browser (e.g. textDocument/publishDiagnostics). nodeConnector.triggerPeer('lspNotification', { serverId, ...msg }); } } +/** + * Answer a server-initiated request with a benign, spec-shaped reply. We advertise minimal client + * capabilities (no dynamic registration, workspace.configuration=false), so servers should rarely + * send these - this is the safety net that guarantees no server hangs awaiting a reply. + * @param {string} serverId - The server identifier (for logging) + * @param {Object} server - The server state object + * @param {Object} msg - The incoming JSON-RPC request (method + id) + */ +function _respondToServerRequest(serverId, server, msg) { + let response; + switch (msg.method) { + case 'workspace/configuration': + // Result must be an array matching params.items length; null entries mean "no config". + response = { + jsonrpc: '2.0', id: msg.id, + result: ((msg.params && msg.params.items) || []).map(() => null) + }; + break; + case 'client/registerCapability': + case 'client/unregisterCapability': + case 'window/workDoneProgress/create': + case 'window/showMessageRequest': + response = { jsonrpc: '2.0', id: msg.id, result: null }; + break; + default: + response = { + jsonrpc: '2.0', id: msg.id, + error: { code: -32601, message: `Method not handled by Phoenix LSP client: ${msg.method}` } + }; + } + try { + server.process.stdin.write(encode(response)); + } catch (e) { + console.error(`[lsp-client][${serverId}] failed to answer ${msg.method}:`, e.message); + } +} + /** * Ping endpoint to verify the LSP connector is alive. * @returns {Promise} Status and list of active servers diff --git a/src-node/npm-intel.js b/src-node/npm-intel.js new file mode 100644 index 0000000000..749c00fd84 --- /dev/null +++ b/src-node/npm-intel.js @@ -0,0 +1,52 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * npm-intel - node-side helper for npm security-advisory lookups. The registry's bulk advisory + * endpoint is POST-only without CORS headers, so the browser context cannot call it directly; + * the JSONSupport extension routes the request here on desktop builds. + * + * Lazy-loaded via NodeUtils._loadNodeExtensionModule("./npm-intel") on first use - keep this + * module free of heavyweight requires so it adds nothing to node boot. + */ + +const ADVISORY_BULK_URL = "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk"; + +/** + * POST the bulk advisory query to the npm registry. + * @param {Object} params + * @param {Object} params.body - map of package name -> array of exact versions + * @returns {Promise} the registry's response: package name -> advisory array + */ +async function fetchAdvisoriesBulk({ body }) { + const response = await fetch(ADVISORY_BULK_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body) + }); + if (!response.ok) { + throw new Error(`advisory fetch failed: ${response.status}`); + } + return response.json(); +} + +exports.fetchAdvisoriesBulk = fetchAdvisoriesBulk; + +global.createNodeConnector("ph-npm-intel", exports); diff --git a/src-node/package-lock.json b/src-node/package-lock.json index b97c6c92be..673a465423 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -20,6 +20,7 @@ "node-pty": "^1.1.0", "npm": "11.8.0", "open": "^10.1.0", + "vscode-langservers-extracted": "4.10.0", "which": "^2.0.1", "ws": "^8.17.1", "zod": "^4.0.0" @@ -604,6 +605,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -733,6 +740,17 @@ "node": ">=6.6.0" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -763,6 +781,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -835,6 +881,61 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -864,6 +965,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1194,6 +1307,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hono": { "version": "4.12.18", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", @@ -1558,6 +1680,16 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, "node_modules/node-pty": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", @@ -3522,6 +3654,18 @@ "inBundle": true, "license": "ISC" }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3697,6 +3841,18 @@ "node": ">=8.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/request-light": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz", + "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==", + "license": "MIT" + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4043,15 +4199,126 @@ "node": ">= 0.8" } }, + "node_modules/vscode-css-languageservice": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", + "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-css-languageservice/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-html-languageservice": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", + "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-json-languageservice": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.2.tgz", + "integrity": "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "jsonc-parser": "^3.3.1", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, "node_modules/vscode-jsonrpc": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0.tgz", - "integrity": "sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", + "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", "license": "MIT", "engines": { "node": ">=14.0.0" } }, + "node_modules/vscode-langservers-extracted": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/vscode-langservers-extracted/-/vscode-langservers-extracted-4.10.0.tgz", + "integrity": "sha512-EFf9uQI4dAKbzMQFjDvVm1xJq1DXAQvBEuEfPGrK/xzfsL5xWTfIuRr90NgfmqwO+IEt6vLZm9EOj6R66xIifg==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "core-js": "^3.20.1", + "jsonc-parser": "^3.2.1", + "regenerator-runtime": "^0.13.9", + "request-light": "^0.7.0", + "semver": "^7.6.1", + "typescript": "^4.0.5", + "vscode-css-languageservice": "^6.2.14", + "vscode-html-languageservice": "^5.2.0", + "vscode-json-languageservice": "^5.3.11", + "vscode-languageserver": "^10.0.0-next.3", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-languageserver-types": "^3.17.5", + "vscode-markdown-languageservice": "^0.5.0-alpha.6", + "vscode-nls": "^5.2.0", + "vscode-uri": "^3.0.8" + }, + "bin": { + "vscode-css-language-server": "bin/vscode-css-language-server", + "vscode-eslint-language-server": "bin/vscode-eslint-language-server", + "vscode-html-language-server": "bin/vscode-html-language-server", + "vscode-json-language-server": "bin/vscode-json-language-server", + "vscode-markdown-language-server": "bin/vscode-markdown-language-server" + } + }, + "node_modules/vscode-langservers-extracted/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/vscode-langservers-extracted/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/vscode-langservers-extracted/node_modules/vscode-languageserver": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.1.0.tgz", + "integrity": "sha512-9gEWpXkYGXoqG7pBnE8O8hx/yP7+Aabn4+peQ3KDicQv6qunHSWyLTud3OF0w4S2+HfDD+5HqYKiXQW9HAU6mA==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.18.2" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, "node_modules/vscode-languageserver": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", @@ -4065,12 +4332,12 @@ } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.0.tgz", - "integrity": "sha512-Zdz+kJ12Iz6tc11xfZyEo501bBATHXrCjmMfnaR3pMnf1CoqZBKIynba3P+/bi9VEdrMbNtAVKYpKhbODvqy+Q==", + "version": "3.18.2", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz", + "integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==", "license": "MIT", "dependencies": { - "vscode-jsonrpc": "9.0.0", + "vscode-jsonrpc": "9.0.1", "vscode-languageserver-types": "3.18.0" } }, @@ -4111,6 +4378,29 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", "license": "MIT" }, + "node_modules/vscode-markdown-languageservice": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.5.0.tgz", + "integrity": "sha512-+DDXukKWtIHvJtj6tXeLqj8iREnylDQw4yRjY3ldv2J66/oiKRJyLLUy4YhMhfBm9Edjci6VhOSpE84AU3ZFXA==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "node-html-parser": "^6.1.5", + "picomatch": "^2.3.1", + "vscode-languageserver-protocol": "^3.17.1", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.7" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", + "license": "MIT" + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", diff --git a/src-node/package.json b/src-node/package.json index 24f0a1d0f3..2ba6b6e97b 100644 --- a/src-node/package.json +++ b/src-node/package.json @@ -30,8 +30,9 @@ "node-pty": "^1.1.0", "npm": "11.8.0", "open": "^10.1.0", + "vscode-langservers-extracted": "4.10.0", "which": "^2.0.1", "ws": "^8.17.1", "zod": "^4.0.0" } -} \ No newline at end of file +} diff --git a/src/editor/CodeHintManager.js b/src/editor/CodeHintManager.js index a919b60bce..c54bb07137 100644 --- a/src/editor/CodeHintManager.js +++ b/src/editor/CodeHintManager.js @@ -687,7 +687,15 @@ define(function (require, exports, module) { // we redraw the list. _updateHintList(); } else if (event.ctrlKey && event.keyCode === KeyEvent.DOM_VK_SPACE) { - _updateHintList(event); + // Repeated Ctrl+Space rotates the hint selection - but ONLY once results are + // showing. The keyup of the very Ctrl+Space that STARTED the session arrives + // ~100ms after keydown, while an async provider's deferred is still pending; + // _updateHintList would reject that deferred and the list would never appear + // (any provider slower than a keystroke - registry lookups, cold LSP - lost + // this race every time). + if (!deferredHints) { + _updateHintList(event); + } } } } diff --git a/src/extensionsIntegrated/JSONSupport/JsonLsp.js b/src/extensionsIntegrated/JSONSupport/JsonLsp.js new file mode 100644 index 0000000000..b7b1830767 --- /dev/null +++ b/src/extensionsIntegrated/JSONSupport/JsonLsp.js @@ -0,0 +1,238 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * JsonLsp - boots the vscode-json-language-server for schema-aware JSON intelligence: + * completion for known config files (package.json fields, tsconfig options, ...), hover docs + * from schema descriptions, and validation diagnostics in the Problems panel. + * + * Mirrors TypeScriptSupport's lazy-start model: the server is only spawned once a JSON file is + * the active editor, and a project switch repoints the running server instead of restarting it. + * Desktop-only (the server is a node process). After every server (re)start the curated schema + * associations are pushed via workspace/didChangeConfiguration - the server itself downloads and + * caches the schemas over http(s). + * + * @module extensionsIntegrated/JSONSupport/JsonLsp + */ +define(function (require, exports, module) { + + + const EditorManager = require("editor/EditorManager"), + ProjectManager = require("project/ProjectManager"), + NodeConnector = require("NodeConnector"), + SchemaAssociations = require("./schemaAssociations"); + + const SERVER_ID = "json"; + const SUPPORTED_LANGUAGES = ["json"]; + + let lspClientPromise = null; + let client = null; // the LanguageClient once registered + let registered = false; + let starting = false; + let pendingRepoint = false; + let initErrorReported = false; + // Test hook: when set, pushed instead of the curated table (lets integration tests use a + // file:// schema so schema features are verifiable without internet access). + let _schemaAssociationsOverride = null; + + /** + * LSP only runs in the desktop app where the Node engine is available. + * @return {boolean} + */ + function canRun() { + return Phoenix.isNativeApp && NodeConnector.isNodeAvailable(); + } + + // Lazy-load the LSP framework so it stays out of the boot dependency graph. Memoized; retries + // once to ride out any module-load race during startup (same pattern as TypeScriptSupport). + function loadLSPClient() { + if (!lspClientPromise) { + lspClientPromise = new Promise(function (resolve, reject) { + require(["languageTools/LSPClient"], resolve, function () { + setTimeout(function () { + require(["languageTools/LSPClient"], resolve, reject); + }, 500); + }); + }); + } + return lspClientPromise; + } + + function waitForNodeReady(timeout) { + return new Promise(function (resolve) { + const deadline = Date.now() + timeout; + (function check() { + if (NodeConnector.isNodeReady()) { + resolve(true); + } else if (Date.now() > deadline) { + resolve(false); + } else { + setTimeout(check, 300); + } + }()); + }); + } + + /** + * Push the JSON server's settings: validation on, formatting off (Phoenix has its own + * formatters) and the schema-association table. Sent after EVERY server up-transition - + * initial start, manual/project restart, and crash auto-restart - since a fresh server + * process starts with no configuration. + */ + function _pushConfiguration() { + if (!client) { + return; + } + client.sendCustomNotification("workspace/didChangeConfiguration", { + settings: { + json: { + validate: { enable: true }, + format: { enable: false }, + schemas: _schemaAssociationsOverride || SchemaAssociations.SCHEMA_ASSOCIATIONS + } + } + }); + } + + async function start() { + if (registered || !canRun()) { + return; + } + const ready = await waitForNodeReady(30000); + if (!ready) { + console.error("[JSONSupport] Node not ready - JSON LSP disabled"); + return; + } + const LSPClient = await loadLSPClient(); + + // Re-push configuration on every later up-transition (manual restart, crash auto-restart) + // - a fresh server process starts with no settings. The INITIAL start is covered by the + // direct push below instead: this event fires inside registerLanguageServer, before its + // result is assigned to `client`, so the handler would see client === null and skip. + LSPClient.on(LSPClient.EVENT_LANGUAGE_SERVER_STARTED + ".jsonSupport", function (_evt, data) { + if (data.serverId === SERVER_ID) { + _pushConfiguration(); + } + }); + + client = await LSPClient.registerLanguageServer({ + serverId: SERVER_ID, + command: "vscode-json-language-server", + args: ["--stdio"], + languages: SUPPORTED_LANGUAGES, + // The server is comment-tolerant in jsonc mode. Real-world tsconfig/.eslintrc commonly + // carry comments, so serve ALL json documents as jsonc - package.json with comments is + // invalid for npm, but schema validation still applies and the npm CLI reports that + // case better than a squiggly storm would. + languageIdMap: { json: "jsonc" }, + initializationOptions: { + provideFormatter: false + // handledSchemaProtocols deliberately unset: the server then fetches http/https + // schema URLs itself (NodeJS http) - no client-side schema proxying needed. + }, + // The JSON server refuses to offer completion unless the client supports snippets + // (its completions insert `"key": $1` templates). Our insertHint expands snippets via + // TabstopManager, so this is safe to advertise for this server. + completionSnippetSupport: true, + // Yield Phoenix preference files to PrefsCodeHints (priority 0) - it hints actual + // preference keys/values there, which beats generic schema completion. + documentFilter: function (fullPath) { + const name = fullPath.substring(fullPath.lastIndexOf("/") + 1); + return !/^\.?(brackets|phcode)\.json$/.test(name); + } + }); + if (client) { + registered = true; + _pushConfiguration(); + } + } + + function _isServedLanguageActive() { + const editor = EditorManager.getActiveEditor(); + if (!editor) { + return false; + } + return SUPPORTED_LANGUAGES.indexOf(editor.getLanguageForSelection().getId()) !== -1; + } + + /** + * Lazily start the server when a JSON file is active; repoint (not restart) the running server + * after a project switch. Same onLanguage model as TypeScriptSupport - projects that never + * open a JSON file never spawn the server. + */ + function _ensureServerForActiveEditor() { + if (!canRun() || !_isServedLanguageActive()) { + return; + } + if (!registered) { + if (starting) { + return; + } + starting = true; + pendingRepoint = false; + start().catch(function (err) { + if (!initErrorReported) { + initErrorReported = true; + window.logger && window.logger.reportError(err, "[JSONSupport] JSON LSP init failed"); + } + }).finally(function () { + starting = false; + }); + return; + } + if (pendingRepoint) { + pendingRepoint = false; + loadLSPClient().then(function (LSPClient) { + LSPClient.changeWorkspaceRoot(SERVER_ID); + }); + } + } + + /** + * Wire the lazy-start lifecycle. Call from appReady; no-ops outside the desktop app. + */ + function init() { + if (!canRun()) { + return; + } + loadLSPClient(); // pre-warm the framework module (not the server) + EditorManager.on("activeEditorChange.jsonSupport", _ensureServerForActiveEditor); + _ensureServerForActiveEditor(); + ProjectManager.on(ProjectManager.EVENT_PROJECT_OPEN + ".jsonSupport", function () { + pendingRepoint = true; + _ensureServerForActiveEditor(); + }); + } + + /** + * Test hook - override the schema associations (e.g. with a file:// fixture schema) and + * re-push to the running server. Pass null to restore the curated table. + * @param {?Array<{fileMatch: string[], url: string}>} associations + */ + function _setTestSchemaAssociations(associations) { + _schemaAssociationsOverride = associations; + _pushConfiguration(); + } + + exports.init = init; + exports.canRun = canRun; + exports.SERVER_ID = SERVER_ID; + exports._setTestSchemaAssociations = _setTestSchemaAssociations; +}); diff --git a/src/extensionsIntegrated/JSONSupport/NpmHints.js b/src/extensionsIntegrated/JSONSupport/NpmHints.js new file mode 100644 index 0000000000..2761d8f7f4 --- /dev/null +++ b/src/extensionsIntegrated/JSONSupport/NpmHints.js @@ -0,0 +1,306 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * NpmHints - live npm package NAME and VERSION completion inside package.json dependency + * sections. Registered at a higher priority than the JSON language server's schema completion, + * but claims ONLY dependency-section contexts in package.json - everywhere else it declines and + * schema completion serves. + * + * Name mode (cursor in a key under dependencies/devDependencies/...): registry search, relevance + * order preserved. Version mode (cursor in the value): the package's real version list, newest + * first, with ^latest / ~latest range shortcuts on top. Inserting a name chains straight into + * version mode (PrefsCodeHints' string-value flow). + * + * @module extensionsIntegrated/JSONSupport/NpmHints + */ +define(function (require, exports, module) { + + + const JSONUtils = require("language/JSONUtils"), + Strings = require("strings"), + semver = require("thirdparty/semver.browser"), + DefaultProviders = require("languageTools/DefaultProviders"), + _ = require("thirdparty/lodash"), + NpmRegistry = require("./NpmRegistry"); + + const DEP_SECTIONS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]; + const PACKAGE_JSON = "package.json"; + const MIN_QUERY_LENGTH = 2; + const SEARCH_DEBOUNCE_MS = 250; + const MAX_VERSION_HINTS = 50; + + function NpmHints() { + this.editor = null; + this.ctxInfo = null; + this._searchSession = 0; // monotonically increasing; stale debounced searches discard + this._searchTimer = null; + } + + function _isPackageJson(editor) { + return editor && editor.document && editor.document.file && + editor.document.file.name === PACKAGE_JSON; + } + + function _depContext(ctxInfo) { + return !!(ctxInfo && ctxInfo.tokenType && + DEP_SECTIONS.indexOf(ctxInfo.parentKeyName) !== -1); + } + + function _query(ctxInfo) { + const raw = JSONUtils.stripQuotes(ctxInfo.token.string.substr(0, ctxInfo.offset)).trim(); + return JSONUtils.regexAllowedChars.test(raw) ? "" : raw; + } + + // Emphasize the typed query inside a hint's main text (the standard .matched-hint style the + // other hint providers use). Registry search is fuzzy, so a name may not contain the query - + // those rows render plain. + function _highlightMatches($hintObj, mainText, query) { + if (!query) { + $hintObj.text(mainText); + return; + } + const lower = mainText.toLowerCase(), + needle = query.toLowerCase(); + let from = 0, idx; + while ((idx = lower.indexOf(needle, from)) !== -1) { + if (idx > from) { + $hintObj.append(document.createTextNode(mainText.slice(from, idx))); + } + $hintObj.append($("").addClass("matched-hint").text(mainText.slice(idx, idx + needle.length))); + from = idx + needle.length; + } + $hintObj.append(document.createTextNode(mainText.slice(from))); + } + + function _hintItem(mainText, description, mode, query) { + const $hintItem = $("").addClass("brackets-hints npm-hint"), + $hintObj = $("").addClass("hint-obj"); + _highlightMatches($hintObj, mainText, query); + $hintItem.append($hintObj); + if (description) { + if (mode === "name") { + // Package descriptions read in the side docs popup on highlight (onHighlight) - + // keeping them off the rows keeps the list narrow and scannable. + $hintItem.data("npmDoc", description); + } else { + // Version rows carry only a tiny fixed label ("latest, minor updates ok"). + $hintItem.append($("").addClass("hint-description").text(description)); + } + } + $hintItem.data("npmMode", mode); + return $hintItem; + } + + /** + * Claim the session only for dependency-section contexts inside package.json. + * @param {!Editor} editor + * @param {string} implicitChar + * @return {boolean} + */ + NpmHints.prototype.hasHints = function (editor, implicitChar) { + if (!_isPackageJson(editor)) { + return false; + } + this.editor = editor; + this.ctxInfo = JSONUtils.getContextInfo(editor, editor.getCursorPos(), true); + return _depContext(this.ctxInfo); + }; + + NpmHints.prototype._nameHints = function (query, explicitInvocation) { + const self = this, + deferred = $.Deferred(), + session = ++this._searchSession; + if (this._searchTimer) { + clearTimeout(this._searchTimer); + } + // The debounce exists to coalesce keystrokes; an explicit Ctrl-Space is a one-shot ask. + const delay = explicitInvocation ? 0 : SEARCH_DEBOUNCE_MS; + this._searchTimer = setTimeout(function () { + self._searchTimer = null; + NpmRegistry.searchPackages(query).then(function (results) { + if (session !== self._searchSession) { + deferred.reject(); // superseded by newer keystrokes + return; + } + deferred.resolve({ + hints: results.map(function (pkg) { + return _hintItem(pkg.name, pkg.description, "name", query); + }), + match: null, // registry order is relevance order - no re-highlighting + selectInitial: true, + handleWideResults: false + }); + }).catch(function () { + deferred.reject(); + }); + }, delay); + return deferred; + }; + + NpmHints.prototype._versionHints = function (packageName, query) { + const deferred = $.Deferred(); + NpmRegistry.getVersions(packageName).then(function (result) { + const sorted = result.versions.slice().sort(semver.rcompare); + const bare = query.replace(/^[\^~]/, ""); + // Filter the FULL version list before capping - a "5." prefix must surface the 5.x + // train even when the newest 50 versions are all 7.x. If nothing matches the typed + // prefix at all, degrade to the newest versions rather than showing an empty list. + let matching = sorted; + if (bare) { + matching = sorted.filter(function (version) { + return version.indexOf(bare) === 0; + }); + if (!matching.length) { + matching = sorted; + } + } + const hints = []; + if (result.latest && (!bare || result.latest.indexOf(bare) === 0)) { + hints.push(_hintItem("^" + result.latest, Strings.NPM_HINT_LATEST_MINOR, "version")); + hints.push(_hintItem("~" + result.latest, Strings.NPM_HINT_LATEST_PATCH, "version")); + } + matching.slice(0, MAX_VERSION_HINTS).forEach(function (version) { + hints.push(_hintItem(version, null, "version", bare)); + }); + deferred.resolve({ + hints: hints, + match: null, + selectInitial: true, + handleWideResults: false + }); + }).catch(function () { + deferred.reject(); + }); + return deferred; + }; + + /** + * @param {string} implicitChar + * @return {?({hints, match, selectInitial, handleWideResults}|jQuery.Deferred)} + */ + NpmHints.prototype.getHints = function (implicitChar) { + const ctxInfo = this.ctxInfo = + JSONUtils.getContextInfo(this.editor, this.editor.getCursorPos(), true); + if (!_depContext(ctxInfo)) { + return null; + } + let query = _query(ctxInfo); + if (ctxInfo.tokenType === JSONUtils.TOKEN_KEY) { + if (query.length < MIN_QUERY_LENGTH && implicitChar === null) { + // Explicit Ctrl-Space with little or nothing typed BEFORE the cursor (e.g. "n|yc"): + // search the whole token instead - the user is asking about the name under the + // cursor, and returning empty here would silently show nothing. + const fullToken = JSONUtils.stripQuotes(ctxInfo.token.string).trim(); + if (fullToken.length > query.length && !JSONUtils.regexAllowedChars.test(fullToken)) { + query = fullToken; + } + if (!query.length) { + return { hints: [], match: null, selectInitial: true, handleWideResults: false }; + } + } else if (query.length < MIN_QUERY_LENGTH) { + // implicit (typing): wait for enough characters before hitting the registry + return { hints: [], match: null, selectInitial: true, handleWideResults: false }; + } + return this._nameHints(query, implicitChar === null); + } + // TOKEN_VALUE: version suggestions for the entry's package name + return this._versionHints(ctxInfo.keyName, query); + }; + + /** + * Show the highlighted package's full description in the side docs popup - the inline + * description is a single ellipsized line, so long registry descriptions read there instead + * (same surface the LSP completion docs use). + * @param {jQuery} $hint - the highlighted anchor inside the hint list + */ + NpmHints.prototype.onHighlight = function ($hint) { + const $span = $hint.closest("li").data("hint"), + doc = $span && $span.data && $span.data("npmDoc"), + mode = $span && $span.data && $span.data("npmMode"); + // version-mode descriptions are two words - a popup would just echo the row. No name + // header either: the highlighted row already shows exactly that. + if (doc && mode === "name") { + DefaultProviders.showHintDocPopup($hint, "

" + _.escape(doc) + "

"); + } else { + DefaultProviders.hideHintDocPopup(); + } + }; + + /** + * Insert the selected hint. Name inserts produce `"name": ""` with the caret inside the value + * quotes and return true, chaining directly into a version-mode session. + * @param {jQuery} $hint + * @return {boolean} whether another session should start + */ + NpmHints.prototype.insertHint = function ($hint) { + const ctxInfo = JSONUtils.getContextInfo(this.editor, this.editor.getCursorPos(), false, true), + pos = this.editor.getCursorPos(), + mode = $hint.data("npmMode"), + start = { line: pos.line, ch: -1 }, + end = { line: pos.line, ch: -1 }; + let completion = $hint.find(".hint-obj").text(), + startChar, + quoteChar = "\""; + + if (ctxInfo.tokenType === JSONUtils.TOKEN_KEY && mode === "name") { + startChar = ctxInfo.token.string.charAt(0); + if (/^['"]$/.test(startChar)) { + quoteChar = startChar; + } + completion = quoteChar + completion + quoteChar; + if (!ctxInfo.shouldReplace) { + completion += ": \"\""; + } + start.ch = pos.ch - ctxInfo.offset; + end.ch = ctxInfo.token.end; + this.editor.document.replaceRange(completion, start, end); + if (!ctxInfo.shouldReplace) { + // place the caret between the value quotes and open the version list + this.editor.setCursorPos(start.line, start.ch + completion.length - 1); + return true; + } + return false; + } + if (ctxInfo.tokenType === JSONUtils.TOKEN_VALUE) { + if (JSONUtils.regexAllowedChars.test(ctxInfo.token.string)) { + start.ch = end.ch = pos.ch; + } else if (ctxInfo.shouldReplace) { + start.ch = ctxInfo.token.start; + end.ch = ctxInfo.token.end; + } else { + start.ch = pos.ch - ctxInfo.offset; + end.ch = ctxInfo.token.end; + } + startChar = ctxInfo.token.string.charAt(0); + if (/^['"]$/.test(startChar)) { + quoteChar = startChar; + } + this.editor.document.replaceRange(quoteChar + completion + quoteChar, start, end); + return false; + } + return false; + }; + + exports.NpmHints = NpmHints; + exports.DEP_SECTIONS = DEP_SECTIONS; + exports.isPackageJson = _isPackageJson; + exports.depContext = _depContext; +}); diff --git a/src/extensionsIntegrated/JSONSupport/NpmHover.js b/src/extensionsIntegrated/JSONSupport/NpmHover.js new file mode 100644 index 0000000000..66d7879a9a --- /dev/null +++ b/src/extensionsIntegrated/JSONSupport/NpmHover.js @@ -0,0 +1,143 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * NpmHover - QuickView over package.json dependency entries: hovering a dependency's name (or + * version) shows the package's registry summary - description, latest version, license and a + * homepage link - in the same popup surface the LSP hover uses. + * + * @module extensionsIntegrated/JSONSupport/NpmHover + */ +define(function (require, exports, module) { + + + const QuickViewManager = require("features/QuickViewManager"), + JSONUtils = require("language/JSONUtils"), + NativeApp = require("utils/NativeApp"), + Strings = require("strings"), + semver = require("thirdparty/semver.browser"), + _ = require("thirdparty/lodash"), + NpmHints = require("./NpmHints"), + NpmRegistry = require("./NpmRegistry"); + + function _externalLink(label, url) { + return $("").attr({ href: "#", title: url }).text(label) + .on("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + NativeApp.openURLInDefaultBrowser(url); + }); + } + + // The npm page for the DECLARED version when the range anchors a concrete one + // ("^5.4.11" -> /v/5.4.11, where npmjs.com renders that version's README); otherwise the + // package's main page. + function _npmPageUrl(name, declaredRange) { + const base = "https://www.npmjs.com/package/" + name; + const anchor = (declaredRange || "").replace(/^[\s^~>=<]+/, "").trim(); + return semver.valid(anchor) ? base + "/v/" + anchor : base; + } + + function _content(info, declaredRange) { + const $content = $("
").addClass("lsp-hover-quickview npm-hover-quickview"); + const $doc = $("
").addClass("lsp-hover-doc").appendTo($content); + + const meta = [info.version && ("v" + info.version), info.license] + .filter(Boolean).join(" · "); + const $title = $("

").append($("").text(info.name)); + if (meta) { + $title.append(document.createTextNode(" ")) + .append($("").css("opacity", 0.65).text(meta)); + } + $doc.append($title); + + if (info.description) { + $doc.append($("

").text(info.description)); + } + // links row: homepage on the left, "View docs" (the npm page, which renders the README + // for the declared version) pinned bottom-right + const $links = $("

").addClass("npm-hover-links"); + if (info.homepage && /^https?:\/\//.test(info.homepage)) { + $links.append(_externalLink(Strings.NPM_HOVER_HOMEPAGE, info.homepage)); + } + $links.append(_externalLink(Strings.NPM_HOVER_VIEW_DOCS, _npmPageUrl(info.name, declaredRange)) + .addClass("npm-hover-docs-link")); + $doc.append($links); + return $content; + } + + const provider = { + QUICK_VIEW_NAME: "npmPackageHover", + + getQuickView: function (editor, pos, token, line) { + return new Promise(function (resolve, reject) { + if (!NpmHints.isPackageJson(editor)) { + reject(); + return; + } + const ctxInfo = JSONUtils.getContextInfo(editor, pos, true); + if (!NpmHints.depContext(ctxInfo)) { + reject(); + return; + } + // hovering the key gives the name directly; hovering the value gives it via keyName + const onKey = ctxInfo.tokenType === JSONUtils.TOKEN_KEY; + const name = onKey + ? JSONUtils.stripQuotes(ctxInfo.token.string).trim() + : ctxInfo.keyName; + if (!name) { + reject(); + return; + } + // declared range: the hovered token itself in value position; when hovering the + // key, read it off the entry's line (JSONUtils does not fill valueName here) + let declaredRange; + if (onKey) { + const lineMatch = (line || editor.document.getLine(pos.line) || "") + .match(/:\s*"((?:[^"\\]|\\.)*)"/); + declaredRange = (lineMatch && lineMatch[1]) || ""; + } else { + declaredRange = JSONUtils.stripQuotes(ctxInfo.token.string || "").trim(); + } + NpmRegistry.getPackageInfo(name).then(function (info) { + resolve({ + start: { line: pos.line, ch: ctxInfo.token.start }, + end: { line: pos.line, ch: ctxInfo.token.end }, + content: _content(info, declaredRange) + }); + }).catch(function () { + reject(); // unknown/private package - show nothing + }); + }); + } + }; + + /** + * Register the dependency hover. Call once from appReady. + */ + function init() { + QuickViewManager.registerQuickViewProvider(provider, ["json"]); + } + + exports.init = init; + // for unit tests + exports._content = _content; + exports._npmPageUrl = _npmPageUrl; +}); diff --git a/src/extensionsIntegrated/JSONSupport/NpmRegistry.js b/src/extensionsIntegrated/JSONSupport/NpmRegistry.js new file mode 100644 index 0000000000..f634d85584 --- /dev/null +++ b/src/extensionsIntegrated/JSONSupport/NpmRegistry.js @@ -0,0 +1,266 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * NpmRegistry - the npm registry / security-advisory client behind package.json intelligence. + * + * - searchPackages(query): package NAME suggestions (registry search API, CORS-open GET). + * - getVersions(name): version list + dist-tags (abbreviated registry doc, CORS-open GET). + * - getAdvisoriesBulk(pairs): security advisories for exact name@version pairs. The bulk endpoint + * is POST-only WITHOUT CORS headers (verified), so the browser build cannot reach it directly: + * transport is tiered - browser fetch first (in case CORS opens up), then the ph-npm-intel node + * connector on desktop, else advisories are unavailable (empty results). + * + * All results are TTL-cached (session-scoped). Network and clock are injectable for tests. + * + * @module extensionsIntegrated/JSONSupport/NpmRegistry + */ +define(function (require, exports, module) { + + + const NodeUtils = require("utils/NodeUtils"), + NodeConnector = require("NodeConnector"); + + const SEARCH_URL = "https://registry.npmjs.org/-/v1/search"; + const PACKAGE_URL = "https://registry.npmjs.org/"; + const ADVISORY_BULK_URL = "https://registry.npmjs.org/-/npm/v1/security/advisories/bulk"; + + const NODE_NPM_INTEL_MODULE = "./npm-intel"; + const NPM_INTEL_CONNECTOR_ID = "ph-npm-intel"; + + const SEARCH_TTL_MS = 5 * 60 * 1000; + const VERSIONS_TTL_MS = 10 * 60 * 1000; + const ADVISORY_TTL_MS = 60 * 60 * 1000; + const SEARCH_CACHE_MAX = 50; + const SEARCH_RESULT_SIZE = 20; + + // Injectable for tests: _fetchJson(url, options) -> Promise. The default goes + // over window.fetch (cached reference, matching the security-conscious pattern used elsewhere). + const realFetch = window.fetch.bind(window); + let _fetchJson = function (url, options) { + return realFetch(url, options).then(function (response) { + if (!response.ok) { + throw new Error("npm registry request failed: " + response.status); + } + return response.json(); + }); + }; + let _now = function () { + return Date.now(); + }; + let _fetcherInjected = false; // tests inject a fake fetcher - it must own ALL transports + + const _searchCache = new Map(); // query -> {ts, results} + const _versionsCache = new Map(); // name -> {ts, result} + const _advisoryCache = new Map(); // "name@version" -> {ts, advisories[]} (empty = negative) + + function _cacheGet(cache, key, ttl) { + const entry = cache.get(key); + if (entry && (_now() - entry.ts) < ttl) { + return entry.value; + } + cache.delete(key); + return null; + } + + function _cacheSet(cache, key, value, maxEntries) { + if (maxEntries && cache.size >= maxEntries) { + // drop the oldest entry (Map preserves insertion order) + cache.delete(cache.keys().next().value); + } + cache.set(key, { ts: _now(), value: value }); + } + + /** + * Search the npm registry for package names. Results keep the registry's relevance order. + * @param {string} query - at least one character + * @return {Promise>} + */ + function searchPackages(query) { + const cached = _cacheGet(_searchCache, query, SEARCH_TTL_MS); + if (cached) { + return Promise.resolve(cached); + } + const url = SEARCH_URL + "?text=" + encodeURIComponent(query) + "&size=" + SEARCH_RESULT_SIZE; + return _fetchJson(url).then(function (json) { + const results = ((json && json.objects) || []).map(function (entry) { + return { + name: entry.package.name, + version: entry.package.version, + description: entry.package.description || "" + }; + }); + _cacheSet(_searchCache, query, results, SEARCH_CACHE_MAX); + return results; + }); + } + + /** + * Fetch the version list + dist-tags of a package (abbreviated registry document). + * @param {string} name - the exact package name + * @return {Promise<{versions: string[], latest: ?string}>} versions in registry order + */ + function getVersions(name) { + const cached = _cacheGet(_versionsCache, name, VERSIONS_TTL_MS); + if (cached) { + return Promise.resolve(cached); + } + // scoped names keep their "@" but the "/" must be encoded per registry URL rules + const url = PACKAGE_URL + encodeURIComponent(name).replace(/^%40/, "@"); + return _fetchJson(url, { + headers: { "Accept": "application/vnd.npm.install-v1+json" } + }).then(function (json) { + const result = { + versions: Object.keys((json && json.versions) || {}), + latest: (json && json["dist-tags"] && json["dist-tags"].latest) || null + }; + _cacheSet(_versionsCache, name, result); + return result; + }); + } + + const _packageInfoCache = new Map(); // name -> {ts, info} + + /** + * Fetch a package's latest-version summary (small document: name, version, description, + * homepage, license) - powers the dependency hover. + * @param {string} name - the exact package name + * @return {Promise<{name, version, description, homepage, license}>} + */ + function getPackageInfo(name) { + const cached = _cacheGet(_packageInfoCache, name, VERSIONS_TTL_MS); + if (cached) { + return Promise.resolve(cached); + } + const url = PACKAGE_URL + encodeURIComponent(name).replace(/^%40/, "@") + "/latest"; + return _fetchJson(url).then(function (json) { + const info = { + name: (json && json.name) || name, + version: (json && json.version) || "", + description: (json && json.description) || "", + homepage: (json && json.homepage) || "", + license: (json && typeof json.license === "string" && json.license) || "" + }; + _cacheSet(_packageInfoCache, name, info); + return info; + }); + } + + // ----- advisories ----------------------------------------------------------------------------- + + let _nodeConnectorPromise = null; + + function _getNodeIntelConnector() { + if (!_nodeConnectorPromise) { + _nodeConnectorPromise = (async function () { + await NodeUtils._loadNodeExtensionModule(NODE_NPM_INTEL_MODULE); + return NodeConnector.createNodeConnector(NPM_INTEL_CONNECTOR_ID, {}); + }()); + } + return _nodeConnectorPromise; + } + + // The bulk endpoint sends no CORS headers (verified), so a browser fetch ALWAYS fails - and + // Chromium logs the preflight failure to the console regardless of our catch, which reads as an + // alarming error on every scan. Desktop therefore goes straight to the node helper; browser + // builds still try fetch (their only option - degrades quietly, and starts working by itself + // if the registry ever opens up CORS). Injected test fetchers take the fetch path everywhere. + function _browserFetchAdvisories(body) { + return _fetchJson(ADVISORY_BULK_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body) + }); + } + + function _fetchAdvisoriesRaw(body) { + if (_fetcherInjected || !Phoenix.isNativeApp) { + return _browserFetchAdvisories(body); + } + return _getNodeIntelConnector().then(function (conn) { + return conn.execPeer("fetchAdvisoriesBulk", { body: body }); + }); + } + + /** + * Look up security advisories for exact package versions. + * @param {Array<{name: string, version: string}>} pairs - resolved name@version pairs + * @return {Promise>} map of package name -> advisories affecting the + * given version ({id, url, title, severity, vulnerable_versions, ...}); resolves {} when the + * advisory data source is unreachable (browser build without CORS) - callers degrade quietly. + */ + function getAdvisoriesBulk(pairs) { + const result = {}; + const uncached = []; + pairs.forEach(function (pair) { + const key = pair.name + "@" + pair.version; + const cached = _cacheGet(_advisoryCache, key, ADVISORY_TTL_MS); + if (cached) { + if (cached.length) { + result[pair.name] = cached; + } + } else { + uncached.push(pair); + } + }); + if (!uncached.length) { + return Promise.resolve(result); + } + const body = {}; + uncached.forEach(function (pair) { + body[pair.name] = (body[pair.name] || []).concat(pair.version); + }); + return _fetchAdvisoriesRaw(body).then(function (json) { + uncached.forEach(function (pair) { + const advisories = (json && json[pair.name]) || []; + _cacheSet(_advisoryCache, pair.name + "@" + pair.version, advisories); + if (advisories.length) { + result[pair.name] = advisories; + } + }); + return result; + }).catch(function () { + // advisory data unavailable - not an error state for the editor + return result; + }); + } + + // ----- test hooks ----------------------------------------------------------------------------- + + function _setFetcherForTests(fetchJsonFn) { + _fetchJson = fetchJsonFn; + _fetcherInjected = true; + _searchCache.clear(); + _versionsCache.clear(); + _advisoryCache.clear(); + _packageInfoCache.clear(); + } + + function _setNowForTests(nowFn) { + _now = nowFn; + } + + exports.searchPackages = searchPackages; + exports.getVersions = getVersions; + exports.getPackageInfo = getPackageInfo; + exports.getAdvisoriesBulk = getAdvisoriesBulk; + exports._setFetcherForTests = _setFetcherForTests; + exports._setNowForTests = _setNowForTests; +}); diff --git a/src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js b/src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js new file mode 100644 index 0000000000..49096fe926 --- /dev/null +++ b/src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js @@ -0,0 +1,298 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * VulnerabilityInspection - squigglies on vulnerable package.json dependencies. + * + * For each entry in the dependency sections, the declared semver RANGE is resolved to the exact + * version npm would install (semver.maxSatisfying over the package's real version list), then the + * npm security-advisory bulk endpoint - the same data `npm audit` uses - reports advisories for + * those versions. Hits surface as CodeInspection errors underlining the whole `"name": "range"` + * entry, severity-mapped (critical/high -> error, moderate -> warning, low -> info). + * + * The scan callback NEVER blocks on the network: it returns the last computed results immediately + * and kicks a single-flighted background refresh (keyed on a hash of the dependency sections, so + * unrelated edits don't refetch); when fresh results differ, CodeInspection.requestRun() re-runs + * the panel - the same push model the LSP linting provider uses. + * + * @module extensionsIntegrated/JSONSupport/VulnerabilityInspection + */ +define(function (require, exports, module) { + + + const CodeInspection = require("language/CodeInspection"), + StringUtils = require("utils/StringUtils"), + Strings = require("strings"), + semver = require("thirdparty/semver.browser"), + NpmRegistry = require("./NpmRegistry"), + NpmHints = require("./NpmHints"); + + const PACKAGE_JSON_RE = /(^|\/)package\.json$/; + const MAX_ADVISORIES_PER_DEP = 3; + const MAX_DEPS_PER_SCAN = 150; // resolution needs one versions-fetch per distinct dep + + // fullPath -> {depHash, errors} - the last computed results, returned synchronously by scans + const _resultsByPath = new Map(); + // fullPath -> true while a background refresh is in flight (single-flight per file) + const _refreshing = new Map(); + + // ----- dependency-entry position scanning ---------------------------------------------------- + + // Walk a JSON object slice from `openBrace` (index of "{") to its matching "}", skipping string + // literals. Returns the index just past the matching brace, or -1. + function _matchBrace(text, openBrace) { + let depth = 0; + let i = openBrace; + while (i < text.length) { + const ch = text[i]; + if (ch === "\"") { + i++; + while (i < text.length && text[i] !== "\"") { + i += (text[i] === "\\") ? 2 : 1; + } + } else if (ch === "{") { + depth++; + } else if (ch === "}") { + depth--; + if (depth === 0) { + return i + 1; + } + } + i++; + } + return -1; + } + + /** + * Pure text scan locating every `"name": "range"` entry inside the dependency sections. + * Exported for unit tests. + * @param {string} text - the package.json source + * @return {Array<{name: string, range: string, pos: {line, ch}, endPos: {line, ch}, section: string}>} + */ + function _findDependencyRanges(text) { + // precompute line starts for offset -> {line, ch} + const lineStarts = [0]; + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") { + lineStarts.push(i + 1); + } + } + function toPos(offset) { + let low = 0, high = lineStarts.length - 1; + while (low < high) { + const mid = Math.floor((low + high + 1) / 2); + if (lineStarts[mid] <= offset) { + low = mid; + } else { + high = mid - 1; + } + } + return { line: low, ch: offset - lineStarts[low] }; + } + + const results = []; + NpmHints.DEP_SECTIONS.forEach(function (section) { + const keyIdx = text.indexOf("\"" + section + "\""); + if (keyIdx === -1) { + return; + } + const openBrace = text.indexOf("{", keyIdx); + if (openBrace === -1) { + return; + } + const endIdx = _matchBrace(text, openBrace); + if (endIdx === -1) { + return; + } + const slice = text.slice(openBrace, endIdx); + const entryRe = /"((?:[^"\\]|\\.)+)"\s*:\s*"((?:[^"\\]|\\.)*)"/g; + let match; + while ((match = entryRe.exec(slice)) !== null) { + results.push({ + name: match[1], + range: match[2], + section: section, + pos: toPos(openBrace + match.index), + endPos: toPos(openBrace + match.index + match[0].length) + }); + } + }); + return results; + } + + // ----- severity mapping ----------------------------------------------------------------------- + + function _severityToType(severity) { + switch (severity) { + case "critical": + case "high": + return CodeInspection.Type.ERROR; + case "moderate": + return CodeInspection.Type.WARNING; + default: + return CodeInspection.Type.META; + } + } + + function _severityLabel(severity) { + switch (severity) { + case "critical": return Strings.NPM_SEVERITY_CRITICAL; + case "high": return Strings.NPM_SEVERITY_HIGH; + case "moderate": return Strings.NPM_SEVERITY_MODERATE; + default: return Strings.NPM_SEVERITY_LOW; + } + } + + const SEVERITY_ORDER = { critical: 0, high: 1, moderate: 2, low: 3 }; + + // Exported for unit tests: advisories + resolved version + entry -> CodeInspection errors. + function _errorsForEntry(entry, resolvedVersion, advisories) { + const seen = new Set(); + return advisories.filter(function (advisory) { + if (seen.has(advisory.id)) { + return false; + } + seen.add(advisory.id); + return true; + }).sort(function (a, b) { + // `in`-guarded, not ||: critical maps to rank 0, which is falsy. Also NOT `??` - + // the prod bundle's minifier (gulp-minify's terser 3) predates nullish coalescing + // and dies on it with an unhelpful "reading 'replace'" error in makeJSDist. + const rankA = (a.severity in SEVERITY_ORDER) ? SEVERITY_ORDER[a.severity] : 9; + const rankB = (b.severity in SEVERITY_ORDER) ? SEVERITY_ORDER[b.severity] : 9; + return rankA - rankB; + }).slice(0, MAX_ADVISORIES_PER_DEP).map(function (advisory) { + return { + pos: entry.pos, + endPos: entry.endPos, + message: StringUtils.format(Strings.NPM_VULN_MESSAGE, + entry.name, resolvedVersion, advisory.title, _severityLabel(advisory.severity)), + type: _severityToType(advisory.severity) + }; + }); + } + + // ----- background refresh --------------------------------------------------------------------- + + function _depHash(entries) { + return entries.map(function (e) { + return e.name + "@" + e.range; + }).join("|"); + } + + async function _computeErrors(entries) { + // resolve every declared range to the exact version npm would install + const resolved = []; + for (const entry of entries) { + if (!semver.validRange(entry.range)) { + continue; // file:, git+, workspace:, tags - not semver-resolvable + } + try { + const info = await NpmRegistry.getVersions(entry.name); + const version = semver.maxSatisfying(info.versions, entry.range); + if (version) { + resolved.push({ entry: entry, version: version }); + } + } catch (e) { + // unknown/private package - skip quietly + } + } + if (!resolved.length) { + return []; + } + const advisoryMap = await NpmRegistry.getAdvisoriesBulk(resolved.map(function (r) { + return { name: r.entry.name, version: r.version }; + })); + const errors = []; + resolved.forEach(function (r) { + const advisories = advisoryMap[r.entry.name]; + if (advisories && advisories.length) { + errors.push.apply(errors, _errorsForEntry(r.entry, r.version, advisories)); + } + }); + return errors; + } + + function _refreshInBackground(text, fullPath) { + let entries = _findDependencyRanges(text); + if (entries.length > MAX_DEPS_PER_SCAN) { + entries = entries.slice(0, MAX_DEPS_PER_SCAN); + } + const depHash = _depHash(entries); + const cached = _resultsByPath.get(fullPath); + if (cached && cached.depHash === depHash) { + return; // dependencies unchanged - cached errors remain valid + } + if (_refreshing.get(fullPath)) { + return; // single-flight; the running refresh re-checks the hash when done + } + _refreshing.set(fullPath, true); + _computeErrors(entries).then(function (errors) { + const previous = _resultsByPath.get(fullPath); + _resultsByPath.set(fullPath, { depHash: depHash, errors: errors }); + const changed = !previous || JSON.stringify(previous.errors) !== JSON.stringify(errors); + if (changed) { + CodeInspection.requestRun(); + } + }).catch(function () { + // network unavailable - keep whatever we had + }).finally(function () { + _refreshing.delete(fullPath); + }); + } + + // ----- provider ------------------------------------------------------------------------------- + + function _scanFileAsync(text, fullPath) { + const deferred = new $.Deferred(); + if (!PACKAGE_JSON_RE.test(fullPath)) { + deferred.resolve(); + return deferred.promise(); + } + // malformed JSON: the JSON language server reports the syntax error; stale dep positions + // would point at the wrong text, so hold results until it parses again + try { + JSON.parse(text); + } catch (e) { + deferred.resolve(); + return deferred.promise(); + } + _refreshInBackground(text, fullPath); + const cached = _resultsByPath.get(fullPath); + deferred.resolve(cached && cached.errors.length ? { errors: cached.errors } : undefined); + return deferred.promise(); + } + + /** + * Register the inspection provider. Call once from appReady. + */ + function init() { + CodeInspection.register("json", { + name: Strings.NPM_VULN_PROVIDER_NAME, + scanFileAsync: _scanFileAsync + }); + } + + exports.init = init; + // for unit tests + exports._findDependencyRanges = _findDependencyRanges; + exports._errorsForEntry = _errorsForEntry; + exports._severityToType = _severityToType; +}); diff --git a/src/extensionsIntegrated/JSONSupport/main.js b/src/extensionsIntegrated/JSONSupport/main.js new file mode 100644 index 0000000000..9d50a9fe69 --- /dev/null +++ b/src/extensionsIntegrated/JSONSupport/main.js @@ -0,0 +1,50 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * JSONSupport - code intelligence for JSON files: + * - schema-aware completion / hover / validation via the JSON language server (desktop only), + * - npm package name and version completion inside package.json dependencies (all builds), + * - security-advisory squigglies on vulnerable package.json dependencies. + * + * @module extensionsIntegrated/JSONSupport/main + */ +define(function (require, exports, module) { + + + const AppInit = require("utils/AppInit"), + CodeHintManager = require("editor/CodeHintManager"), + JsonLsp = require("./JsonLsp"), + NpmHints = require("./NpmHints"), + NpmHover = require("./NpmHover"), + VulnerabilityInspection = require("./VulnerabilityInspection"); + + // Above the JSON language server's schema completion (priority 1): the npm provider claims + // only dependency-section contexts in package.json and declines everywhere else, so schema + // completion still serves the rest of the file. + const NPM_HINTS_PRIORITY = 2; + + AppInit.appReady(function () { + CodeHintManager.registerHintProvider(new NpmHints.NpmHints(), ["json"], NPM_HINTS_PRIORITY); + VulnerabilityInspection.init(); + NpmHover.init(); + JsonLsp.init(); // no-ops outside the desktop app + }); +}); diff --git a/src/extensionsIntegrated/JSONSupport/schemaAssociations.js b/src/extensionsIntegrated/JSONSupport/schemaAssociations.js new file mode 100644 index 0000000000..28b5376119 --- /dev/null +++ b/src/extensionsIntegrated/JSONSupport/schemaAssociations.js @@ -0,0 +1,69 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/** + * Curated JSON-schema associations for well-known config files, pushed to the JSON language + * server as the `json.schemas` setting. The server downloads the schema URLs itself (http/https + * are in its default handledSchemaProtocols) and caches them for the session. + * + * This is a hand-picked subset of the schemastore.org catalog covering the files web developers + * meet daily. Fetching the full catalog (https://www.schemastore.org/api/json/catalog.json) and + * merging it in is a possible future enhancement - kept out for now to avoid a network dependency + * at server start. + * + * Shape: LSP `json.schemas` entries - { fileMatch: string[], url: string }. + * + * @module extensionsIntegrated/JSONSupport/schemaAssociations + */ +define(function (require, exports, module) { + + + const SCHEMA_ASSOCIATIONS = [ + { fileMatch: ["package.json"], url: "https://json.schemastore.org/package.json" }, + { fileMatch: ["tsconfig.json", "tsconfig.*.json"], url: "https://json.schemastore.org/tsconfig" }, + { fileMatch: ["jsconfig.json", "jsconfig.*.json"], url: "https://json.schemastore.org/jsconfig" }, + { fileMatch: [".eslintrc", ".eslintrc.json"], url: "https://json.schemastore.org/eslintrc" }, + { + fileMatch: [".babelrc", ".babelrc.json", "babel.config.json"], + url: "https://json.schemastore.org/babelrc" + }, + { fileMatch: [".prettierrc", ".prettierrc.json"], url: "https://json.schemastore.org/prettierrc" }, + { fileMatch: [".stylelintrc", ".stylelintrc.json"], url: "https://json.schemastore.org/stylelintrc" }, + { fileMatch: ["composer.json"], url: "https://json.schemastore.org/composer" }, + { fileMatch: ["bower.json"], url: "https://json.schemastore.org/bower" }, + { fileMatch: [".bowerrc"], url: "https://json.schemastore.org/bowerrc" }, + { fileMatch: [".jshintrc"], url: "https://json.schemastore.org/jshintrc" }, + { + fileMatch: ["manifest.json", "*.webmanifest"], + url: "https://json.schemastore.org/web-manifest-combined.json" + }, + { fileMatch: ["lerna.json"], url: "https://json.schemastore.org/lerna" }, + { fileMatch: ["turbo.json"], url: "https://json.schemastore.org/turbo.json" }, + { fileMatch: ["nx.json"], url: "https://json.schemastore.org/nx" }, + { fileMatch: ["firebase.json"], url: "https://json.schemastore.org/firebase" }, + { fileMatch: ["vercel.json"], url: "https://json.schemastore.org/vercel" }, + { + fileMatch: ["renovate.json", ".renovaterc", ".renovaterc.json"], + url: "https://docs.renovatebot.com/renovate-schema.json" + } + ]; + + exports.SCHEMA_ASSOCIATIONS = SCHEMA_ASSOCIATIONS; +}); diff --git a/src/extensionsIntegrated/loader.js b/src/extensionsIntegrated/loader.js index 751353f9d0..f8c3dfc4bb 100644 --- a/src/extensionsIntegrated/loader.js +++ b/src/extensionsIntegrated/loader.js @@ -46,5 +46,6 @@ define(function (require, exports, module) { require("./CustomSnippets/main"); require("./CollapseFolders/main"); require("./Terminal/main"); + require("./JSONSupport/main"); require("./pro-loader"); }); diff --git a/src/languageTools/DefaultProviders.js b/src/languageTools/DefaultProviders.js index f8b0f1ee28..1294dd26a3 100644 --- a/src/languageTools/DefaultProviders.js +++ b/src/languageTools/DefaultProviders.js @@ -1258,5 +1258,10 @@ define(function (require, exports, module) { exports.LintingProvider = LintingProvider; exports.ReferencesProvider = ReferencesProvider; exports.serverRespToSearchModelFormat = serverRespToSearchModelFormat; + // Generic side-docs popup for code-hint lists: any hint provider's onHighlight can show + // supplementary docs beside the list (the LSP providers' own docs use the same surface). + // `html` is TRUSTED - escape untrusted parts before calling. + exports.showHintDocPopup = _showDocPopup; + exports.hideHintDocPopup = _hideDocPopup; exports._docPopupHtml = _docPopupHtml; // exposed for unit tests }); diff --git a/src/languageTools/LSPClient.js b/src/languageTools/LSPClient.js index 6b286db83e..f4bf7c6492 100644 --- a/src/languageTools/LSPClient.js +++ b/src/languageTools/LSPClient.js @@ -290,7 +290,17 @@ define(function (require, exports, module) { * @return {boolean} */ LanguageClient.prototype.servesDocument = function (editor) { - return !!(editor && this.languages.indexOf(editor.document.getLanguage().getId()) !== -1); + if (!editor || this.languages.indexOf(editor.document.getLanguage().getId()) === -1) { + return false; + } + // Optional per-file opt-out (config.documentFilter): lets a server decline specific files + // of a language it otherwise serves, so lower-priority specialised providers can win there + // (e.g. the JSON server yields Phoenix preference files to PrefsCodeHints). + if (this.config && typeof this.config.documentFilter === "function" && + !this.config.documentFilter(editor.document.file.fullPath)) { + return false; + } + return true; }; LanguageClient.prototype._request = function (method, params) { @@ -305,9 +315,26 @@ define(function (require, exports, module) { return getConnector().then(function (conn) { return conn.execPeer("sendNotification", { serverId: serverId, method: method, params: params }); }).catch(function (err) { - // Notifications are best-effort (the server may be restarting). Don't let it become an - // unhandled rejection, but still surface it as a warning so we are not blind. + // Log AND re-throw: callers must be able to react to a lost notification (DocumentSync + // flags a full resync when a didChange never reached the server - swallowing here would + // leave the server's copy silently divergent). Fire-and-forget callers attach their own + // no-op catch. console.warn("[LSP] notification '" + method + "' failed:", err && (err.message || err)); + throw err; + }); + }; + + /** + * Send an arbitrary LSP notification to this client's server (e.g. a feature module pushing + * `workspace/didChangeConfiguration` after start). Best-effort: failures are logged, never + * thrown. + * @param {string} method - LSP notification method name + * @param {Object} params - notification params + * @return {Promise} + */ + LanguageClient.prototype.sendCustomNotification = function (method, params) { + return this._notify(method, params).catch(function () { + // already logged in _notify; custom notifications are fire-and-forget }); }; @@ -327,7 +354,10 @@ define(function (require, exports, module) { }); }; LanguageClient.prototype.notifyDidClose = function (uri) { - return this._notify("textDocument/didClose", { textDocument: { uri: uri } }); + return this._notify("textDocument/didClose", { textDocument: { uri: uri } }).catch(function () { + // ignorable: if the close never arrived the server is usually gone/restarting anyway, + // and (re)start resyncs documents from scratch + }); }; function _positionOf(cursorPos) { @@ -600,7 +630,7 @@ define(function (require, exports, module) { return root ? root.fullPath : null; } - function _clientCapabilities() { + function _clientCapabilities(config) { return { textDocument: { synchronization: { @@ -612,7 +642,11 @@ define(function (require, exports, module) { completion: { dynamicRegistration: false, completionItem: { - snippetSupport: false, + // Off by default: our hint insertion is plain text. A server config can opt + // in (completionSnippetSupport) when the server refuses to offer completion + // without it - vscode-json-language-server does - and the CodeHintsProvider + // strips snippet placeholders on insert for such items. + snippetSupport: !!(config && config.completionSnippetSupport), documentationFormat: ["markdown", "plaintext"], // We render labelDetails.detail/description (e.g. the source module of an // auto-import) so otherwise-identical labels are distinguishable - see the @@ -679,7 +713,7 @@ define(function (require, exports, module) { locale: _uiLocale(), rootUri: rootUri, workspaceFolders: rootUri ? [{ uri: rootUri, name: rootName }] : null, - capabilities: _clientCapabilities(), + capabilities: _clientCapabilities(config), initializationOptions: config.initializationOptions || {} } }); @@ -760,6 +794,14 @@ define(function (require, exports, module) { * When omitted, a generic default is used (identifier chars + the server's non-whitespace * triggerCharacters). Explicit invocation (Ctrl-Space) always shows hints regardless. * @param {function():string} [config.rootUriProvider] - returns the workspace root VFS path + * @param {function(string):boolean} [config.documentFilter] - per-file opt-out: given a file's + * full path, return false to make this server decline the file even though its language + * matches (e.g. the JSON server yields Phoenix pref files to PrefsCodeHints). + * @param {boolean} [config.completionSnippetSupport] - advertise snippet support in the client + * completion capability. Only for servers that refuse to offer completion without it + * (vscode-json-language-server); snippet placeholders are stripped on insert. + * @param {function(Array):Array} [config.filterDiagnostics] - server-specific post-filter for + * published diagnostics * @return {Promise} the client, or null if it could not be started */ async function registerLanguageServer(config) { diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 72d075a6ff..0827bf0bdb 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1800,6 +1800,17 @@ define({ "CODE_INTEL_PANEL_TEXT": "Project-wide code intelligence is off. Enable it for Find Usages, Rename, and Go to Definition across every file — adds a jsconfig.json to the project root.", "CODE_INTEL_PANEL_ENABLE": "Enable", "CODE_INTEL_PANEL_DISMISS": "Dismiss", + // JSON / package.json intelligence (JSONSupport) + "NPM_VULN_PROVIDER_NAME": "npm Security Advisories", + "NPM_VULN_MESSAGE": "{0}@{1} is vulnerable: {2} ({3} severity)", + "NPM_SEVERITY_CRITICAL": "critical", + "NPM_SEVERITY_HIGH": "high", + "NPM_SEVERITY_MODERATE": "moderate", + "NPM_SEVERITY_LOW": "low", + "NPM_HINT_LATEST_MINOR": "latest, minor updates ok", + "NPM_HINT_LATEST_PATCH": "latest, patch updates only", + "NPM_HOVER_HOMEPAGE": "Open homepage", + "NPM_HOVER_VIEW_DOCS": "View docs", // Code Intelligence config settings panel (shown when a root ts/jsconfig is the active file) "CODE_INTEL_CFG_TITLE": "Code Intelligence", "CODE_INTEL_CFG_ALL_OPTIONS": "All options…", diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 4992f0bdfc..79021c4d01 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -2229,7 +2229,7 @@ a, img { align-self: stretch; max-width: 520px; padding: 2px 4px; - font-size: 12.5px; + font-size: 13.5px; line-height: 1.5; color: @bc-text; word-wrap: break-word; @@ -2281,7 +2281,7 @@ a, img { // Inline code: parameter names, types, identifiers. code { font-family: 'SourceCodePro', 'SF Mono', Menlo, Consolas, monospace; - font-size: 11.5px; + font-size: 12.5px; padding: 1px 5px; border-radius: 4px; background: rgba(0, 0, 0, 0.06); @@ -2454,7 +2454,7 @@ a, img { overflow-y: auto; overflow-x: hidden; padding: 8px 12px; - font-size: 12.5px; + font-size: 13px; line-height: 1.5; word-wrap: break-word; @@ -2483,6 +2483,19 @@ a, img { p:first-child { margin-top: 0; } p:last-child { margin-bottom: 0; } + // Headings (e.g. the package-name header in npm hint docs): kill the browser's large default + // margins - a first-child heading otherwise adds its top margin to the popup's own padding and + // the spacing reads top-heavy vs the bottom. + h1, h2, h3, h4, h5, h6 { + margin: 8px 0 5px; + font-size: 13px; + line-height: 1.4; + } + h1:first-child, h2:first-child, h3:first-child, + h4:first-child, h5:first-child, h6:first-child { + margin-top: 0; + } + // Code blocks - the top signature and any fenced blocks inside the docs - stay cohesive with the // popup surface rather than being a separate-coloured panel: the fill is a faint tint *derived // from* the surface (a low-opacity black/white wash, so it reads the same in both themes) with a @@ -2637,6 +2650,34 @@ span.brackets-hints-with-type-details { color: #6495ed !important; } +// npm dependency hover (JSONSupport): links row - homepage left, "View docs" pinned bottom-right. +// (font size comes from the shared .lsp-hover-quickview base) +.npm-hover-quickview .npm-hover-links { + display: flex; + align-items: baseline; + gap: 14px; + .npm-hover-docs-link { + margin-left: auto; + } +} + +// npm package hints (JSONSupport): the registry description renders as ONE ellipsized line after +// the package name - long descriptions read in the side docs popup instead (see NpmHints +// onHighlight). Overrides the generic block layout above, which wraps long text under the name +// and stretches the list arbitrarily wide. +.npm-hint .hint-description, +.highlight .npm-hint .hint-description { + display: inline-block; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; + margin-left: 12px; + font-size: 0.92em; + opacity: 0.8; +} + .hint-doc { display: none; padding-right: 10px !important; diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 9a1a88b27c..e0e8405aa2 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -136,6 +136,8 @@ define(function (require, exports, module) { require("spec/Extn-JSHint-integ-test"); require("spec/Extn-ESLint-integ-test"); require("spec/Extn-CSSColorPreview-integ-test"); + require("spec/Extn-JSONSupport-test"); + require("spec/Extn-JSONSupport-integ-test"); require("spec/Extn-CollapseFolders-integ-test"); require("spec/Extn-Tabbar-integ-test"); require("spec/Extn-CustomSnippets-test"); diff --git a/test/spec/Extn-JSONSupport-integ-test.js b/test/spec/Extn-JSONSupport-integ-test.js new file mode 100644 index 0000000000..749fc805b3 --- /dev/null +++ b/test/spec/Extn-JSONSupport-integ-test.js @@ -0,0 +1,116 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global describe, it, expect, beforeAll, afterAll, awaitsFor, awaitsForDone */ + +define(function (require, exports, module) { + + + if (!Phoenix.isNativeApp) { + // The JSON language server is a node process - desktop builds only. + return; + } + + const SpecRunnerUtils = require("spec/SpecRunnerUtils"); + + describe("integration:JSON LSP", function () { + const testFolder = SpecRunnerUtils.getTestPath("/spec/JSONSupport-test-files"); + let testWindow, + $, + CommandManager, + Commands; + + beforeAll(async function () { + testWindow = await SpecRunnerUtils.createTestWindowAndRun(); + $ = testWindow.$; + CommandManager = testWindow.brackets.test.CommandManager; + Commands = testWindow.brackets.test.Commands; + await SpecRunnerUtils.loadProjectInTestWindow(testFolder); + }, 100000); // cold embedded-window boot can exceed 30s (same budget as the TS LSP suite) + + afterAll(async function () { + testWindow = null; + await SpecRunnerUtils.closeTestWindow(); + }, 30000); + + async function _openFile(fileName) { + await awaitsForDone( + CommandManager.execute(Commands.CMD_ADD_TO_WORKINGSET_AND_OPEN, + { fullPath: testFolder + "/" + fileName })); + } + + function _problemsText() { + return $("#problems-panel").text(); + } + + it("should flag JSON syntax errors via the language server", async function () { + // First spec doubles as the server warm-up (spawn + initialize), hence the + // larger budget. Syntax validation needs no schema download - network-free. + await _openFile("broken.json"); + await awaitsFor(function () { + return /json \(/.test(_problemsText()); + }, "JSON server syntax diagnostics in the problems panel", 90000); + }, 120000); + + it("should validate against a pushed schema association (inline schema, no network)", async function () { + const JsonLsp = testWindow.require("extensionsIntegrated/JSONSupport/JsonLsp"); + JsonLsp._setTestSchemaAssociations([{ + fileMatch: ["appsettings.json"], + schema: { + type: "object", + properties: { + mode: { type: "string", enum: ["dev", "prod"] } + } + } + }]); + await _openFile("appsettings.json"); + await awaitsFor(function () { + // the fixture's "invalid-mode" violates the enum + return /not accepted|allowed values|invalid-mode|dev/i.test(_problemsText()); + }, "schema enum diagnostic for appsettings.json", 30000); + JsonLsp._setTestSchemaAssociations(null); + }, 45000); + + it("should squiggle vulnerable dependencies using advisory data", async function () { + const NpmRegistry = testWindow.require("extensionsIntegrated/JSONSupport/NpmRegistry"); + NpmRegistry._setFetcherForTests(function (url, options) { + if (url.indexOf("/security/advisories/bulk") !== -1) { + return Promise.resolve({ + lodash: [{ + id: 1, url: "https://example.test/advisory", + title: "Fixture Prototype Pollution", severity: "high", + vulnerable_versions: "<4.18.0" + }] + }); + } + // abbreviated version doc for lodash + return Promise.resolve({ + "dist-tags": { latest: "4.17.21" }, + versions: { "4.17.19": {}, "4.17.21": {} } + }); + }); + await _openFile("package.json"); + await awaitsFor(function () { + return _problemsText().indexOf("Fixture Prototype Pollution") !== -1; + }, "vulnerability advisory in the problems panel", 30000); + expect(_problemsText()).toContain("lodash@4.17.21"); + }, 45000); + }); +}); diff --git a/test/spec/Extn-JSONSupport-test.js b/test/spec/Extn-JSONSupport-test.js new file mode 100644 index 0000000000..9c9842c1b9 --- /dev/null +++ b/test/spec/Extn-JSONSupport-test.js @@ -0,0 +1,326 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +/*global describe, it, expect, beforeEach, afterEach, awaitsFor, jasmine */ + +define(function (require, exports, module) { + + + const SpecRunnerUtils = require("spec/SpecRunnerUtils"), + CodeInspection = require("language/CodeInspection"), + NpmRegistry = require("extensionsIntegrated/JSONSupport/NpmRegistry"), + NpmHints = require("extensionsIntegrated/JSONSupport/NpmHints"), + NpmHover = require("extensionsIntegrated/JSONSupport/NpmHover"), + VulnerabilityInspection = require("extensionsIntegrated/JSONSupport/VulnerabilityInspection"); + + describe("unit:JSONSupport npm intelligence", function () { + + describe("VulnerabilityInspection._findDependencyRanges", function () { + const find = VulnerabilityInspection._findDependencyRanges; + + it("should locate entries in dependencies and devDependencies", function () { + const text = '{\n' + + ' "dependencies": {\n' + + ' "lodash": "^4.17.19",\n' + + ' "express": "~4.18.0"\n' + + ' },\n' + + ' "devDependencies": {\n' + + ' "jasmine": "5.0.0"\n' + + ' }\n' + + '}\n'; + const entries = find(text); + expect(entries.length).toBe(3); + expect(entries[0]).toEqual(jasmine.objectContaining({ + name: "lodash", range: "^4.17.19", section: "dependencies" + })); + expect(entries[0].pos).toEqual({ line: 2, ch: 8 }); + expect(entries[0].endPos).toEqual({ line: 2, ch: 28 }); + expect(entries[2]).toEqual(jasmine.objectContaining({ + name: "jasmine", range: "5.0.0", section: "devDependencies" + })); + }); + + it("should handle the same package in two sections and scoped names", function () { + const text = '{"dependencies": {"@types/node": "^20.0.0", "a": "1.0.0"},' + + ' "peerDependencies": {"a": "2.0.0"}}'; + const entries = find(text); + expect(entries.length).toBe(3); + expect(entries[0].name).toBe("@types/node"); + expect(entries[1].pos.line).toBe(0); // single-line JSON maps to line 0 + expect(entries[2]).toEqual(jasmine.objectContaining({ + name: "a", range: "2.0.0", section: "peerDependencies" + })); + }); + + it("should not scan outside the dependency sections or crash on missing ones", function () { + const text = '{"scripts": {"build": "tsc"}, "dependencies": {"x": "1.0.0"}}'; + const entries = find(text); + expect(entries.length).toBe(1); + expect(entries[0].name).toBe("x"); + expect(find('{"name": "no-deps"}').length).toBe(0); + }); + + it("should skip past escaped quotes inside strings while brace matching", function () { + const text = '{"dependencies": {"weird\\"pkg": "1.0.0", "ok": "2.0.0"}}'; + const entries = find(text); + expect(entries.length).toBe(2); + expect(entries[1].name).toBe("ok"); + }); + }); + + describe("VulnerabilityInspection severity mapping and error building", function () { + it("should map severities to CodeInspection types", function () { + expect(VulnerabilityInspection._severityToType("critical")).toBe(CodeInspection.Type.ERROR); + expect(VulnerabilityInspection._severityToType("high")).toBe(CodeInspection.Type.ERROR); + expect(VulnerabilityInspection._severityToType("moderate")).toBe(CodeInspection.Type.WARNING); + expect(VulnerabilityInspection._severityToType("low")).toBe(CodeInspection.Type.META); + }); + + it("should dedup advisories by id, sort by severity and cap the count", function () { + const entry = { name: "p", pos: { line: 1, ch: 0 }, endPos: { line: 1, ch: 10 } }; + const advisories = [ + { id: 1, title: "low one", severity: "low" }, + { id: 2, title: "crit", severity: "critical" }, + { id: 2, title: "crit dup", severity: "critical" }, + { id: 3, title: "mod", severity: "moderate" }, + { id: 4, title: "high", severity: "high" }, + { id: 5, title: "low two", severity: "low" } + ]; + const errors = VulnerabilityInspection._errorsForEntry(entry, "1.0.0", advisories); + expect(errors.length).toBe(3); // capped + expect(errors[0].message).toContain("crit"); + expect(errors[0].message).toContain("p@1.0.0"); + expect(errors[1].message).toContain("high"); + expect(errors[2].message).toContain("mod"); + expect(errors[0].pos).toEqual(entry.pos); + expect(errors[0].endPos).toEqual(entry.endPos); + }); + }); + + describe("NpmHover npm page url", function () { + it("should link the declared version's doc page when the range anchors one", function () { + expect(NpmHover._npmPageUrl("vite", "^5.4.11")) + .toBe("https://www.npmjs.com/package/vite/v/5.4.11"); + expect(NpmHover._npmPageUrl("vite", "~7.2.3")) + .toBe("https://www.npmjs.com/package/vite/v/7.2.3"); + expect(NpmHover._npmPageUrl("vite", "7.2.3")) + .toBe("https://www.npmjs.com/package/vite/v/7.2.3"); + expect(NpmHover._npmPageUrl("@types/node", ">=20.1.0")) + .toBe("https://www.npmjs.com/package/@types/node/v/20.1.0"); + }); + + it("should fall back to the package page for anchorless ranges", function () { + expect(NpmHover._npmPageUrl("vite", "*")).toBe("https://www.npmjs.com/package/vite"); + expect(NpmHover._npmPageUrl("vite", "")).toBe("https://www.npmjs.com/package/vite"); + expect(NpmHover._npmPageUrl("vite", "latest")).toBe("https://www.npmjs.com/package/vite"); + }); + }); + + describe("NpmRegistry with injected fetcher", function () { + let fetchCalls; + + function fakeFetcher(responder) { + fetchCalls = []; + NpmRegistry._setFetcherForTests(function (url, options) { + fetchCalls.push({ url: url, options: options || {} }); + return Promise.resolve(responder(url, options)); + }); + } + + afterEach(function () { + // restore a fetcher that fails loudly if any spec forgets to inject + NpmRegistry._setFetcherForTests(function () { + return Promise.reject(new Error("no fetcher injected")); + }); + }); + + it("should map search results and serve repeats from cache", async function () { + fakeFetcher(function () { + return { objects: [ + { package: { name: "lodash", version: "4.18.1", description: "utils" } }, + { package: { name: "lodash-es", version: "4.18.1" } } + ] }; + }); + const first = await NpmRegistry.searchPackages("loda"); + expect(first.length).toBe(2); + expect(first[0]).toEqual({ name: "lodash", version: "4.18.1", description: "utils" }); + expect(first[1].description).toBe(""); + expect(fetchCalls[0].url).toContain("search?text=loda&size="); + + await NpmRegistry.searchPackages("loda"); + expect(fetchCalls.length).toBe(1); // cached + }); + + it("should fetch abbreviated version docs and encode scoped names", async function () { + fakeFetcher(function () { + return { "dist-tags": { latest: "20.1.0" }, versions: { "20.0.0": {}, "20.1.0": {} } }; + }); + const info = await NpmRegistry.getVersions("@types/node"); + expect(info.latest).toBe("20.1.0"); + expect(info.versions).toEqual(["20.0.0", "20.1.0"]); + expect(fetchCalls[0].url).toBe("https://registry.npmjs.org/@types%2Fnode"); + expect(fetchCalls[0].options.headers.Accept).toBe("application/vnd.npm.install-v1+json"); + }); + + it("should build the bulk advisory body, cache results and negative results", async function () { + fakeFetcher(function () { + return { lodash: [{ id: 9, title: "bad", severity: "high" }] }; + }); + const pairs = [ + { name: "lodash", version: "4.17.19" }, + { name: "safe-pkg", version: "1.0.0" } + ]; + const result = await NpmRegistry.getAdvisoriesBulk(pairs); + expect(result.lodash.length).toBe(1); + expect(result["safe-pkg"]).toBeUndefined(); + expect(JSON.parse(fetchCalls[0].options.body)).toEqual({ + lodash: ["4.17.19"], "safe-pkg": ["1.0.0"] + }); + + const again = await NpmRegistry.getAdvisoriesBulk(pairs); + expect(fetchCalls.length).toBe(1); // both hits AND misses cached + expect(again.lodash.length).toBe(1); + }); + }); + + describe("NpmHints context detection", function () { + let mockEditor, testEditor, testDocument, provider; + const content = '{\n' + + ' "name": "fixture",\n' + + ' "dependencies": {\n' + + ' "lodash": "^4.17.19"\n' + + ' }\n' + + '}\n'; + + beforeEach(function () { + mockEditor = SpecRunnerUtils.createMockEditor(content, "json"); + testEditor = mockEditor.editor; + testDocument = mockEditor.doc; + testDocument.file._name = "package.json"; + provider = new NpmHints.NpmHints(); + }); + + afterEach(function () { + testEditor.destroy(); + testDocument = null; + }); + + it("should claim a key inside dependencies (name mode)", function () { + testEditor.setCursorPos({ line: 3, ch: 11 }); // inside "lodash" key + expect(provider.hasHints(testEditor, null)).toBe(true); + }); + + it("should claim a value inside dependencies (version mode)", function () { + testEditor.setCursorPos({ line: 3, ch: 21 }); // inside "^4.17.19" value + expect(provider.hasHints(testEditor, null)).toBe(true); + }); + + it("should decline top-level keys so schema completion serves them", function () { + testEditor.setCursorPos({ line: 1, ch: 8 }); // inside "name" key + expect(provider.hasHints(testEditor, null)).toBe(false); + }); + + it("should decline files that are not package.json", function () { + testDocument.file._name = "config.json"; + testEditor.setCursorPos({ line: 3, ch: 11 }); + expect(provider.hasHints(testEditor, null)).toBe(false); + }); + + it("should serve version hints newest-first with range shortcuts on top", async function () { + NpmRegistry._setFetcherForTests(function () { + return Promise.resolve({ + "dist-tags": { latest: "4.18.1" }, + versions: { "4.17.19": {}, "4.18.1": {}, "4.2.0": {} } + }); + }); + testEditor.setCursorPos({ line: 3, ch: 20 }); // just inside the value quote + expect(provider.hasHints(testEditor, null)).toBe(true); + const deferred = provider.getHints(null); + let hintObj = null; + deferred.done(function (result) { hintObj = result; }); + await awaitsFor(function () { return !!hintObj; }, "version hints to resolve"); + const texts = hintObj.hints.map(function ($item) { + return $item.find(".hint-obj").text(); + }); + expect(texts[0]).toBe("^4.18.1"); + expect(texts[1]).toBe("~4.18.1"); + expect(texts.indexOf("4.18.1")).toBeLessThan(texts.indexOf("4.17.19")); + expect(texts.indexOf("4.17.19")).toBeLessThan(texts.indexOf("4.2.0")); + }); + + async function _versionHintTexts(provider, cursorCh) { + testEditor.setCursorPos({ line: 3, ch: cursorCh }); + expect(provider.hasHints(testEditor, null)).toBe(true); + const deferred = provider.getHints(null); + let hintObj = null; + deferred.done(function (result) { hintObj = result; }); + await awaitsFor(function () { return !!hintObj; }, "version hints to resolve"); + return hintObj.hints.map(function ($item) { + return $item.find(".hint-obj").text(); + }); + } + + it("should match a typed prefix against the FULL version list, not just the newest", async function () { + // many 7.x versions on top; the 5.x train is old. Typing "5" must surface 5.x. + const versions = {}; + for (let i = 0; i < 60; i++) { + versions["7." + i + ".0"] = {}; + } + versions["5.4.11"] = {}; + NpmRegistry._setFetcherForTests(function () { + return Promise.resolve({ "dist-tags": { latest: "7.59.0" }, versions: versions }); + }); + // value is "^4.17.19": cursor after `"^` is ch 20; the doc token query becomes "^". + // Type-sim: place cursor after the caret and 5 -> query "^5" via ch offset math is + // brittle here, so instead exercise the provider path directly with a mock editor + // whose value starts with ^5: reuse content by moving the cursor inside "^4" and + // relying on the fallback assertion below for the no-match case; for the 5-prefix + // case, use a fresh editor. + testEditor.destroy(); + mockEditor = SpecRunnerUtils.createMockEditor( + '{\n "name": "x",\n "dependencies": {\n "vite": "^5"\n }\n}\n', "json"); + testEditor = mockEditor.editor; + testDocument = mockEditor.doc; + testDocument.file._name = "package.json"; + const texts = await _versionHintTexts(provider, 19); // inside "^5" + expect(texts.indexOf("5.4.11")).not.toBe(-1); + expect(texts.indexOf("7.59.0")).toBe(-1); // filtered out by the 5-prefix + }); + + it("should fall back to newest versions when the typed prefix matches nothing", async function () { + NpmRegistry._setFetcherForTests(function () { + return Promise.resolve({ + "dist-tags": { latest: "7.2.0" }, + versions: { "7.1.0": {}, "7.2.0": {} } + }); + }); + testEditor.destroy(); + mockEditor = SpecRunnerUtils.createMockEditor( + '{\n "name": "x",\n "dependencies": {\n "vite": "^9"\n }\n}\n', "json"); + testEditor = mockEditor.editor; + testDocument = mockEditor.doc; + testDocument.file._name = "package.json"; + const texts = await _versionHintTexts(provider, 19); // inside "^9" - no 9.x exists + expect(texts.length).toBeGreaterThan(0); + expect(texts.indexOf("7.2.0")).not.toBe(-1); + }); + }); + }); +}); diff --git a/test/spec/JSONSupport-test-files/appsettings.json b/test/spec/JSONSupport-test-files/appsettings.json new file mode 100644 index 0000000000..47c41623cc --- /dev/null +++ b/test/spec/JSONSupport-test-files/appsettings.json @@ -0,0 +1,3 @@ +{ + "mode": "invalid-mode" +} diff --git a/test/spec/JSONSupport-test-files/broken.json b/test/spec/JSONSupport-test-files/broken.json new file mode 100644 index 0000000000..a2c2ed1fc7 --- /dev/null +++ b/test/spec/JSONSupport-test-files/broken.json @@ -0,0 +1,3 @@ +{ + "a": 1,, +} diff --git a/test/spec/JSONSupport-test-files/package.json b/test/spec/JSONSupport-test-files/package.json new file mode 100644 index 0000000000..1d18d4e44d --- /dev/null +++ b/test/spec/JSONSupport-test-files/package.json @@ -0,0 +1,7 @@ +{ + "name": "jsonsupport-fixture", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.19" + } +}