From d1a823f95f6796913fb8c67cf62b1d99646c6a98 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 2 Jul 2026 23:32:29 +0530 Subject: [PATCH 1/3] fix(codehints): don't kill a pending hint request on the invoking Ctrl+Space keyup Repeated Ctrl+Space rotates the hint selection - but 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 begins by rejecting the pending deferred, so the list never appeared: any provider slower than a keystroke (npm registry lookups, cold LSP completions) lost this race on every explicit invocation. Rotate only when results are already showing (deferredHints null); a pending initial request is left to land. Regression-checked: LegacyInteg:CodeHintManager 13/13 both with and without this change (an apparent failure turned out to be OS-focus flakiness of that suite, not this fix). --- src/editor/CodeHintManager.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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); + } } } } From 86604fc0ceef303b7f681e7c92dfa56b8e1933a3 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 2 Jul 2026 23:32:57 +0530 Subject: [PATCH 2/3] feat(json): JSON code intelligence - schema LSP, npm hints, vuln squigglies, dep hover New integrated extension src/extensionsIntegrated/JSONSupport with WebStorm-class intelligence for JSON files, on the existing multi-server LSP framework: 1. JSON language server (desktop): vscode-json-language-server (from vscode-langservers-extracted, resolved via src-node/node_modules/.bin) with lazy start on the first JSON file and repoint-not-restart on project switch. Curated schemastore.org associations (package.json, ts/jsconfig, eslintrc, babelrc, prettierrc, composer, manifests, ...) are pushed via workspace/didChangeConfiguration after every server up-transition; the server downloads the schemas itself. Serves all json docs as jsonc (comment-tolerant, matching real-world tsconfig/.eslintrc), yields Phoenix pref files to PrefsCodeHints via the new documentFilter, and opts into completionSnippetSupport (the server refuses to offer completion without snippet support; insertHint already expands snippets through TabstopManager). 2. npm package intelligence in package.json (all builds): - Name completion in dependency keys: registry search in relevance order, typed-query emphasis via the standard .matched-hint style, descriptions in the reusable side docs popup (showHintDocPopup / hideHintDocPopup now exported from DefaultProviders) instead of widening the rows. Explicit Ctrl-Space searches the whole token under the cursor and skips the typing debounce. - Version completion in values: the package's real version list newest first with ^latest/~latest range shortcuts; typed prefixes filter the FULL list (a 5.x prefix surfaces the 5.x train even when the newest 50 are 7.x) and fall back to newest when nothing matches. Name insertion chains straight into version mode. - Dependency hover (QuickView): registry summary - name, latest version, license, description - with Open homepage and a View docs link pinned bottom-right that opens the npm page anchored at the DECLARED version ("^5.4.11" -> /v/5.4.11, where npm renders that version's README). 3. Vulnerability squigglies: each declared range is resolved to the version npm would install (semver.maxSatisfying over the real version list) and checked against npm's bulk security-advisory endpoint (the npm-audit data source). Severity-mapped (critical/high error, moderate warning, low info), whole-entry underlines, capped and deduped per dep. The endpoint has no CORS headers (verified), so desktop goes straight to the new ph-npm-intel node helper - no doomed browser POST spamming the console; browser builds try fetch and degrade quietly. scanFileAsync never blocks on the network: cached results return immediately and a single-flighted, dep-hash-gated background refresh requestRun()s on change. Framework hardening along the way: - src-node/lsp-client.js answers server-initiated requests (spec-shaped null results for workspace/configuration & friends, -32601 otherwise) so no server can hang awaiting a reply the browser never sends. - LanguageClient.sendCustomNotification; per-server documentFilter and completionSnippetSupport (per-config client capabilities); _notify now logs AND propagates failures so DocumentSync's lost-notification resync hardening actually engages. - Hover typography: the shared .lsp-hover-quickview family (JSON schema hover, JS/TS hover, hint docs popup) moves to the 13px+ readability baseline; popup headings lose their asymmetric browser margins. Tests: unit:JSONSupport npm intelligence 18/18 (dep-range scanner, severity mapping/dedup, registry client with injected fetcher incl. caching + bulk body, hint context detection, full-list version filter + fallback, npm page URLs); integration:JSON LSP 3/3 (server syntax diagnostics, inline-schema validation with no network, advisory squigglies with fake fetcher); regressions integration:TypeScript LSP 20/20 and LegacyInteg:CodeHintManager 13/13. --- docs/API-Reference/language/CodeInspection.md | 4 +- docs/API-Reference/widgets/NotificationUI.md | 30 ++ src-node/lsp-client.js | 44 ++- src-node/npm-intel.js | 52 +++ src-node/package-lock.json | 304 +++++++++++++++- src-node/package.json | 3 +- .../JSONSupport/JsonLsp.js | 238 +++++++++++++ .../JSONSupport/NpmHints.js | 306 ++++++++++++++++ .../JSONSupport/NpmHover.js | 143 ++++++++ .../JSONSupport/NpmRegistry.js | 266 ++++++++++++++ .../JSONSupport/VulnerabilityInspection.js | 294 ++++++++++++++++ src/extensionsIntegrated/JSONSupport/main.js | 50 +++ .../JSONSupport/schemaAssociations.js | 69 ++++ src/extensionsIntegrated/loader.js | 1 + src/languageTools/DefaultProviders.js | 5 + src/languageTools/LSPClient.js | 56 ++- src/nls/root/strings.js | 11 + src/styles/brackets.less | 47 ++- test/UnitTestSuite.js | 2 + test/spec/Extn-JSONSupport-integ-test.js | 116 +++++++ test/spec/Extn-JSONSupport-test.js | 326 ++++++++++++++++++ .../JSONSupport-test-files/appsettings.json | 3 + test/spec/JSONSupport-test-files/broken.json | 3 + test/spec/JSONSupport-test-files/package.json | 7 + 24 files changed, 2359 insertions(+), 21 deletions(-) create mode 100644 src-node/npm-intel.js create mode 100644 src/extensionsIntegrated/JSONSupport/JsonLsp.js create mode 100644 src/extensionsIntegrated/JSONSupport/NpmHints.js create mode 100644 src/extensionsIntegrated/JSONSupport/NpmHover.js create mode 100644 src/extensionsIntegrated/JSONSupport/NpmRegistry.js create mode 100644 src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js create mode 100644 src/extensionsIntegrated/JSONSupport/main.js create mode 100644 src/extensionsIntegrated/JSONSupport/schemaAssociations.js create mode 100644 test/spec/Extn-JSONSupport-integ-test.js create mode 100644 test/spec/Extn-JSONSupport-test.js create mode 100644 test/spec/JSONSupport-test-files/appsettings.json create mode 100644 test/spec/JSONSupport-test-files/broken.json create mode 100644 test/spec/JSONSupport-test-files/package.json 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/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..dd26c8e691 --- /dev/null +++ b/src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js @@ -0,0 +1,294 @@ +/* + * 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) { + // ?? not ||: critical maps to 0, which is falsy + return (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9); + }).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" + } +} From aadee815dd09dbe2eeb6b4ea1ec566d850a467c4 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 2 Jul 2026 23:43:33 +0530 Subject: [PATCH 3/3] fix(build): releaseProd died on nullish coalescing - terser 3 can't parse it gulp-minify@3.1.0 bundles terser 3.17 (2019), which predates ES2020 syntax. A single `?? 9` in VulnerabilityInspection.js (part of the core bundle via extensionsIntegrated) crashed makeJSDist with the line-less "Cannot read properties of undefined (reading 'replace')" error. Dev builds run unminified in Chromium, which parses `??` natively - so this only surfaces in the prod pipeline. Replaced with an `in`-guarded rank lookup (same semantics: critical maps to rank 0, which is falsy, so a plain || fallback would mis-sort it - covered by the existing dedup/sort spec). Swept all shipped src for other ES2020+ syntax (?. / ?? / logical assignment): this was the only real occurrence. Constraint: shipped src/ must stay at ES2019 syntax until the minifier is upgraded; src-node, tests and src-mdviewer are exempt. releaseProd now completes: 53s, dist size validation green (72.94 MB). unit:JSONSupport 18/18. --- .../JSONSupport/VulnerabilityInspection.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js b/src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js index dd26c8e691..49096fe926 100644 --- a/src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js +++ b/src/extensionsIntegrated/JSONSupport/VulnerabilityInspection.js @@ -172,8 +172,12 @@ define(function (require, exports, module) { seen.add(advisory.id); return true; }).sort(function (a, b) { - // ?? not ||: critical maps to 0, which is falsy - return (SEVERITY_ORDER[a.severity] ?? 9) - (SEVERITY_ORDER[b.severity] ?? 9); + // `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,