diff --git a/_typos.toml b/_typos.toml index ee6489a24..0b501dc4c 100644 --- a/_typos.toml +++ b/_typos.toml @@ -54,3 +54,4 @@ IZ = "IZ" shft = "shft" multline = "multline" sav = "sav" +ist = "ist" diff --git a/bun.lock b/bun.lock index 3de02944f..e4fc68743 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "@codemirror/language-data": "^6.5.2", "@codemirror/legacy-modes": "^6.5.3", "@codemirror/lint": "^6.9.7", - "@codemirror/lsp-client": "^6.2.5", + "@codemirror/lsp-client": "github:Acode-Foundation/codemirror-lsp-client#db6930c2b6db67e14978f08ae8f5aeb149d29468", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", @@ -126,6 +126,7 @@ "cordova-plugin-system": "file:src/plugins/system", "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.4", + "happy-dom": "^20.11.1", "mini-css-extract-plugin": "^2.10.2", "path-browserify": "^1.0.1", "postcss-loader": "^8.2.1", @@ -425,7 +426,7 @@ "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], - "@codemirror/lsp-client": ["@codemirror/lsp-client@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/language": "^6.11.0", "@codemirror/lint": "^6.8.5", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.37.0", "@lezer/highlight": "^1.2.1", "marked": "^15.0.12", "vscode-languageserver-protocol": "^3.17.5" } }, "sha512-1EqhGRmCZOV7Me+rRuwwkTuvkNoD4Nz6UcE1yx5gdwTVTLD4D9xIy48MJc0LeBQGFLn/HNRW/pHmet4EAEkJFQ=="], + "@codemirror/lsp-client": ["@codemirror/lsp-client@github:Acode-Foundation/codemirror-lsp-client#db6930c", { "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/language": "^6.11.0", "@codemirror/lint": "^6.8.5", "@codemirror/state": "^6.5.2", "@codemirror/view": "^6.37.0", "@lezer/highlight": "^1.2.1", "marked": "^15.0.12", "vscode-languageserver-protocol": "^3.17.5" } }, "Acode-Foundation-codemirror-lsp-client-db6930c", "sha512-yENf3NqsXGERWcvUL98FziXbUgkB1R4jU2kTQGYxwcZ6HwTP8+uwebs/j+HDRD7VbxRyemYJClvMNU8n0p8UPg=="], "@codemirror/search": ["@codemirror/search@6.7.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="], @@ -767,6 +768,10 @@ "@types/url-parse": ["@types/url-parse@1.4.11", "", {}, "sha512-FKvKIqRaykZtd4n47LbK/W/5fhQQ1X7cxxzG9A48h0BGN+S04NH7ervcCjM8tyR0lyGru83FAHSmw2ObgKoESg=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@ungap/custom-elements": ["@ungap/custom-elements@1.3.0", "", {}, "sha512-f4q/s76+8nOy+fhrNHyetuoPDR01lmlZB5czfCG+OOnBw/Wf+x48DcCDPmMQY7oL8xYFL8qfenMoiS8DUkKBUw=="], "@ungap/promise-all-settled": ["@ungap/promise-all-settled@1.1.2", "", {}, "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q=="], @@ -957,6 +962,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "builtins": ["builtins@1.0.3", "", {}, "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1301,7 +1308,7 @@ "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1447,6 +1454,8 @@ "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "happy-dom": ["happy-dom@20.11.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg=="], + "har-schema": ["har-schema@2.0.0", "", {}, "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q=="], "har-validator": ["har-validator@5.1.5", "", { "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" } }, "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w=="], @@ -2315,6 +2324,8 @@ "webpack-sources": ["webpack-sources@3.3.3", "", {}, "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], @@ -2607,6 +2618,8 @@ "make-fetch-happen/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "mermaid/dompurify": ["dompurify@3.4.2", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA=="], "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], diff --git a/package-lock.json b/package-lock.json index f3da5995f..641498358 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "@codemirror/language-data": "^6.5.2", "@codemirror/legacy-modes": "^6.5.3", "@codemirror/lint": "^6.9.7", - "@codemirror/lsp-client": "^6.2.5", + "@codemirror/lsp-client": "github:Acode-Foundation/codemirror-lsp-client#db6930c2b6db67e14978f08ae8f5aeb149d29468", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", @@ -130,6 +130,7 @@ "cordova-plugin-system": "file:src/plugins/system", "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.4", + "happy-dom": "^20.11.1", "mini-css-extract-plugin": "^2.10.2", "path-browserify": "^1.0.1", "postcss-loader": "^8.2.1", @@ -3964,9 +3965,9 @@ } }, "node_modules/@codemirror/lsp-client": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@codemirror/lsp-client/-/lsp-client-6.2.5.tgz", - "integrity": "sha512-1EqhGRmCZOV7Me+rRuwwkTuvkNoD4Nz6UcE1yx5gdwTVTLD4D9xIy48MJc0LeBQGFLn/HNRW/pHmet4EAEkJFQ==", + "version": "6.2.5-acode.1", + "resolved": "git+ssh://git@github.com/Acode-Foundation/codemirror-lsp-client.git#db6930c2b6db67e14978f08ae8f5aeb149d29468", + "integrity": "sha512-rmtrQojm4rSJCmqZhoskG4UeEth+lzrcGsmrlnKqSU0EJCeRYE1kjZKhjI+XsMVD5YFgup31IGN+NsxfOirDMg==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.20.0", @@ -6182,7 +6183,6 @@ "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -6201,6 +6201,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@ungap/custom-elements": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/custom-elements/-/custom-elements-1.3.0.tgz", @@ -7741,6 +7758,19 @@ "license": "MIT", "peer": true }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/builtins": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", @@ -11271,6 +11301,38 @@ "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", "license": "MIT" }, + "node_modules/happy-dom": { + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -17435,8 +17497,7 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", @@ -18128,6 +18189,16 @@ "node": ">=10.13.0" } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", diff --git a/package.json b/package.json index 30516d10a..d1fa0f3da 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "plugins": { "cordova-clipboard": {}, "cordova-plugin-device": {}, - "cordova-plugin-file": {}, + "cordova-plugin-file": { + "ANDROIDX_WEBKIT_VERSION": "1.4.0" + }, "cordova-plugin-server": {}, "cordova-plugin-ftp": {}, "cordova-plugin-sdcard": {}, @@ -102,6 +104,7 @@ "cordova-plugin-system": "file:src/plugins/system", "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.4", + "happy-dom": "^20.11.1", "mini-css-extract-plugin": "^2.10.2", "path-browserify": "^1.0.1", "postcss-loader": "^8.2.1", @@ -146,7 +149,7 @@ "@codemirror/language-data": "^6.5.2", "@codemirror/legacy-modes": "^6.5.3", "@codemirror/lint": "^6.9.7", - "@codemirror/lsp-client": "^6.2.5", + "@codemirror/lsp-client": "github:Acode-Foundation/codemirror-lsp-client#db6930c2b6db67e14978f08ae8f5aeb149d29468", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/src/cm/colorChip.ts b/src/cm/colorChip.ts index 91f43133e..3f055cfa5 100644 --- a/src/cm/colorChip.ts +++ b/src/cm/colorChip.ts @@ -1,3 +1,4 @@ +import type { LSPClient } from "@codemirror/lsp-client"; import { LSPPlugin } from "@codemirror/lsp-client"; import type { Range } from "@codemirror/state"; import { Decoration, EditorView, WidgetType } from "@codemirror/view"; @@ -16,6 +17,8 @@ export interface ColorChipPayload { css: string; pickerSeed?: string; source: ColorChipSource; + /** Originating provider for LSP color-presentation requests. */ + lspClient?: LSPClient; } const chipState = new WeakMap(); @@ -43,6 +46,7 @@ export class ColorChipWidget extends WidgetType { other.payload.to === this.payload.to && other.payload.css === this.payload.css && other.payload.source === this.payload.source && + other.payload.lspClient === this.payload.lspClient && (other.payload.pickerSeed || "") === (this.payload.pickerSeed || "") ); } @@ -90,14 +94,10 @@ export function isViewEditable(view: EditorView): boolean { } export function hasLspColorProvider(view: EditorView): boolean { - const lsp = LSPPlugin.get(view) as { - client?: { - connected?: boolean; - serverCapabilities?: { colorProvider?: boolean | object } | null; - }; - } | null; - return !!( - lsp?.client?.connected && lsp.client.serverCapabilities?.colorProvider + return LSPPlugin.getAll(view, "documentColor").some( + (lsp) => + lsp.client.connected && + !!lsp.client.serverCapabilities?.colorProvider, ); } diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index 94ecd40cf..59eae1966 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -1,5 +1,8 @@ import { getIndentUnit, indentUnit } from "@codemirror/language"; -import type { LSPClientExtension } from "@codemirror/lsp-client"; +import type { + LSPClientExtension, + LSPPluginOptions, +} from "@codemirror/lsp-client"; import { LSPClient, LSPPlugin, @@ -272,6 +275,22 @@ function buildBuiltinExtensions( return { extensions, diagnosticsExtension }; } +function buildPluginOptions(server: LspServerDefinition): LSPPluginOptions { + const builtin = server.clientConfig?.builtinExtensions ?? {}; + return { + priority: server.priority, + features: { + completion: builtin.completion !== false, + hover: builtin.hover !== false, + signatureHelp: builtin.signature !== false, + diagnostics: builtin.diagnostics !== false, + formatting: builtin.formatting !== false, + inlayHint: builtin.inlayHints === true, + documentColor: builtin.documentColors !== false, + }, + }; +} + interface InitContext { key: string; normalizedRootUri: string | null; @@ -434,6 +453,7 @@ export class LspClientManager { const plugin = clientState.client.plugin( normalizedUri, targetLanguageId, + buildPluginOptions(server), ); if (server.clientConfig?.builtinExtensions?.completion !== false) { lspExtensions.push(lspCompletionEnabled.of(true)); @@ -442,14 +462,6 @@ export class LspClientManager { originalUri && originalUri !== normalizedUri ? [originalUri] : []; clientState.attach(normalizedUri, view as EditorView, aliases); lspExtensions.push(plugin); - if (diagnosticsUiExtension) { - lspExtensions.push( - lspDiagnosticsAutoSyncExtension( - clientState.client, - normalizedUri, - ), - ); - } } catch (error) { console.error( `Failed to initialize LSP client for ${server.id}`, @@ -459,6 +471,7 @@ export class LspClientManager { } if (diagnosticsUiExtension && lspExtensions.length) { + lspExtensions.push(lspDiagnosticsAutoSyncExtension()); lspExtensions.push(...asArray(diagnosticsUiExtension)); } @@ -508,7 +521,7 @@ export class LspClientManager { const capabilities = state.client.serverCapabilities; if (!capabilities?.documentFormattingProvider) continue; state.attach(normalizedUri, view); - const plugin = LSPPlugin.get(view); + const plugin = LSPPlugin.get(view, state.client); if (!plugin) continue; plugin.client.sync(); const edits = await state.client.request< @@ -1140,11 +1153,22 @@ export class LspClientManager { logLspInfo(`[LSP:${server.id}] attached to ${uri}${suffix}`); }; + const clearClientDiagnostics = (view: EditorView): void => { + try { + view.dispatch({ effects: clearDiagnosticsEffect(client) }); + } catch { + /* View may already be destroyed or may not have diagnostics state. */ + } + }; + const dispose = async (): Promise => { if (disposed) return; disposed = true; disposePullDiagnostics(client); this.#clients.delete(key); + for (const views of fileRefs.values()) { + for (const view of views) clearClientDiagnostics(view); + } try { client.disconnect(); } catch (error) { @@ -1161,7 +1185,10 @@ export class LspClientManager { const actualUri = uriAliases.get(uri) ?? uri; const existing = fileRefs.get(actualUri); if (!existing) return; - if (view) existing.delete(view); + if (view) { + existing.delete(view); + clearClientDiagnostics(view); + } if (!view || !existing.size) { fileRefs.delete(actualUri); for (const [alias, target] of uriAliases.entries()) { diff --git a/src/cm/lsp/codeActions.ts b/src/cm/lsp/codeActions.ts index 736909dbd..7022e5f1e 100644 --- a/src/cm/lsp/codeActions.ts +++ b/src/cm/lsp/codeActions.ts @@ -197,12 +197,9 @@ async function applyChangesToFile( } async function applyWorkspaceEdit( - view: EditorView, + plugin: LSPPlugin, edit: WorkspaceEdit, ): Promise { - const plugin = LSPPlugin.get(view); - if (!plugin) return false; - const workspace = plugin.client.workspace as AcodeWorkspace; if (!workspace) return false; @@ -248,12 +245,9 @@ async function applyWorkspaceEdit( * "If both edit and command are supplied, first the edit is applied, then the command is executed" */ async function applyCodeAction( - view: EditorView, + plugin: LSPPlugin, action: CodeAction, ): Promise { - const plugin = LSPPlugin.get(view); - if (!plugin) return false; - plugin.client.sync(); // Resolve to get the edit if not already present @@ -262,7 +256,7 @@ async function applyCodeAction( // Step 1: Apply workspace edit if present if (resolved.edit) { - success = await applyWorkspaceEdit(view, resolved.edit); + success = await applyWorkspaceEdit(plugin, resolved.edit); } // Step 2: Execute command if present (after edit per LSP spec) @@ -283,46 +277,70 @@ export interface CodeActionItem { disabled?: boolean; disabledReason?: string; action: CodeAction | Command; + /** Client binding that produced this action; resolution must use it. */ + plugin: LSPPlugin; } export async function fetchCodeActions( view: EditorView, ): Promise { - const plugin = LSPPlugin.get(view); - if (!plugin) return []; - - const capabilities = plugin.client.serverCapabilities; - if (!capabilities?.codeActionProvider) return []; + const plugins = LSPPlugin.getAll(view, "codeAction").filter( + (plugin) => !!plugin.client.serverCapabilities?.codeActionProvider, + ); + if (!plugins.length) return []; const { from, to } = view.state.selection.main; - const range: LspRange = { - start: plugin.toPosition(from), - end: plugin.toPosition(to), - }; - - plugin.client.sync(); - - try { - const response = await requestCodeActions(plugin, range); - if (!response?.length) return []; - - const items: CodeActionItem[] = response.map((item) => { - if (isCommand(item)) { - return { title: item.title, icon: "terminal", action: item }; - } - return { - title: item.title, - kind: item.kind, - icon: getCodeActionIcon(item.kind), - isPreferred: item.isPreferred, - disabled: !!item.disabled, - disabledReason: item.disabled?.reason, - action: item, + const settled = await Promise.allSettled( + plugins.map(async (plugin): Promise => { + const range: LspRange = { + start: plugin.toPosition(from), + end: plugin.toPosition(to), }; - }); + plugin.client.sync(); + const response = await requestCodeActions(plugin, range); + if (!response?.length) return []; + return response.map((item) => { + if (isCommand(item)) { + return { + title: item.title, + icon: "terminal", + action: item, + plugin, + }; + } + return { + title: item.title, + kind: item.kind, + icon: getCodeActionIcon(item.kind), + isPreferred: item.isPreferred, + disabled: !!item.disabled, + disabledReason: item.disabled?.reason, + action: item, + plugin, + }; + }); + }), + ); - // Sort: preferred first, then quickfixes, then alphabetically - items.sort((a, b) => { + const items: CodeActionItem[] = []; + for (let index = 0; index < settled.length; index++) { + const result = settled[index]; + if (result.status === "fulfilled") { + items.push(...result.value); + } else { + addLspLogFor( + plugins[index], + "error", + "Code action fetch failed", + result.reason, + ); + console.error("[LSP:CodeAction] Provider failed:", result.reason); + } + } + + // Sort: preferred first, then quickfixes, then alphabetically. Stable + // sorting preserves server priority for otherwise identical actions. + items.sort((a, b) => { if (a.isPreferred && !b.isPreferred) return -1; if (!a.isPreferred && b.isPreferred) return 1; if (a.kind?.startsWith("quickfix") && !b.kind?.startsWith("quickfix")) @@ -330,21 +348,15 @@ export async function fetchCodeActions( if (!a.kind?.startsWith("quickfix") && b.kind?.startsWith("quickfix")) return 1; return a.title.localeCompare(b.title); - }); - - return items; - } catch (error) { - addLspLogFor(plugin, "error", "Code action fetch failed", error); - console.error("[LSP:CodeAction] Failed to fetch:", error); - return []; - } + }); + return items; } export async function executeCodeAction( view: EditorView, item: CodeActionItem, ): Promise { - const plugin = LSPPlugin.get(view); + const plugin = LSPPlugin.get(view, item.plugin.client); if (!plugin) return false; try { @@ -356,7 +368,7 @@ export async function executeCodeAction( } // Handle CodeAction - return applyCodeAction(view, item.action); + return applyCodeAction(plugin, item.action); } catch (error) { addLspLogFor(plugin, "error", "Code action execution failed", error); console.error("[LSP:CodeAction] Failed to execute:", error); @@ -365,8 +377,9 @@ export async function executeCodeAction( } export function supportsCodeActions(view: EditorView): boolean { - const plugin = LSPPlugin.get(view); - return !!plugin?.client.serverCapabilities?.codeActionProvider; + return LSPPlugin.getAll(view, "codeAction").some( + (plugin) => !!plugin.client.serverCapabilities?.codeActionProvider, + ); } export async function showCodeActionsMenu(view: EditorView): Promise { diff --git a/src/cm/lsp/diagnostics.ts b/src/cm/lsp/diagnostics.ts index 53aec8eb3..906c50516 100644 --- a/src/cm/lsp/diagnostics.ts +++ b/src/cm/lsp/diagnostics.ts @@ -20,7 +20,14 @@ import type { RawDiagnostic, } from "./types"; -const setPublishedDiagnostics = StateEffect.define(); +interface PublishedDiagnosticsUpdate { + client?: LSPClient; + diagnostics?: LspDiagnostic[]; + clearAll?: boolean; +} + +const setPublishedDiagnostics = + StateEffect.define(); let diagnosticsEventTimer: ReturnType | null = null; let diagnosticsViewCount = 0; @@ -54,6 +61,13 @@ function supportsPullDiagnostics(client: LSPClient): boolean { return !!client.serverCapabilities?.diagnosticProvider; } +function diagnosticsEnabledForFile(client: LSPClient, uri: string): boolean { + const file = client.workspace.getFile(uri); + const view = file?.getView(); + const plugin = view && LSPPlugin.get(view, client); + return !!plugin?.featureEnabled("diagnostics"); +} + function isCoarsePointerDevice(): boolean { if (typeof window !== "undefined") { try { @@ -105,17 +119,52 @@ function clearScheduledDiagnosticsUpdated(): void { diagnosticsEventTimer = null; } -const lspPublishedDiagnostics = StateField.define({ - create(): LspDiagnostic[] { - return []; +const lspPublishedDiagnostics = StateField.define< + ReadonlyMap +>({ + create(): ReadonlyMap { + return new Map(); }, - update(value: LspDiagnostic[], tr): LspDiagnostic[] { + update(value: ReadonlyMap, tr) { + let next = value; + if (tr.docChanged && value.size) { + next = new Map(); + for (const [client, diagnostics] of value) { + const mapped: LspDiagnostic[] = []; + for (const diagnostic of diagnostics) { + const from = tr.changes.mapPos( + diagnostic.from, + 1, + MapMode.TrackDel, + ); + const to = tr.changes.mapPos( + diagnostic.to, + -1, + MapMode.TrackDel, + ); + if (from != null && to != null) { + mapped.push({ ...diagnostic, from, to }); + } + } + (next as Map).set(client, mapped); + } + } for (const effect of tr.effects) { if (effect.is(setPublishedDiagnostics)) { - value = effect.value; + if (effect.value.clearAll) { + next = new Map(); + } else if (effect.value.client) { + const updated = new Map(next); + if (effect.value.diagnostics?.length) { + updated.set(effect.value.client, effect.value.diagnostics); + } else { + updated.delete(effect.value.client); + } + next = updated; + } } } - return value; + return next; }, }); @@ -133,8 +182,6 @@ function collectLspDiagnostics( diagnostics: RawDiagnostic[], ): LspDiagnostic[] { const items: LspDiagnostic[] = []; - const { syncedDoc } = plugin; - for (const diagnostic of diagnostics) { let from: number; let to: number; @@ -155,7 +202,7 @@ function collectLspDiagnostics( } catch (_) { continue; } - if (to > syncedDoc.length) continue; + if (to > plugin.unsyncedChanges.newLength) continue; const severity = severities[diagnostic.severity ?? 0] ?? "info"; const source = diagnostic.code @@ -175,9 +222,10 @@ function collectLspDiagnostics( } function storeLspDiagnostics( + client: LSPClient, items: LspDiagnostic[], -): StateEffect { - return setPublishedDiagnostics.of(items); +): StateEffect { + return setPublishedDiagnostics.of({ client, diagnostics: items }); } function sameDiagnostics( @@ -214,17 +262,21 @@ function applyDiagnostics( } const view = file.getView(); if (!view) return false; - const plugin = LSPPlugin.get(view) as LSPPluginAPI | null; - if (!plugin) return false; + const plugin = LSPPlugin.get(view, client); + if (!plugin || !plugin.featureEnabled("diagnostics")) return false; - const diagnostics = collectLspDiagnostics(plugin, rawDiagnostics); - const current = view.state.field(lspPublishedDiagnostics, false) ?? []; + const diagnostics = collectLspDiagnostics( + plugin as unknown as LSPPluginAPI, + rawDiagnostics, + ); + const current = + view.state.field(lspPublishedDiagnostics, false)?.get(client) ?? []; if (sameDiagnostics(current, diagnostics)) { return true; } view.dispatch({ - effects: storeLspDiagnostics(diagnostics), + effects: storeLspDiagnostics(client, diagnostics), }); scheduleDiagnosticsUpdated(); return true; @@ -235,7 +287,12 @@ async function pullDiagnostics( uri: string, generation: number, ): Promise { - if (!supportsPullDiagnostics(client)) return; + if ( + !supportsPullDiagnostics(client) || + !diagnosticsEnabledForFile(client, uri) + ) { + return; + } client.sync(); const clientWithWorkspace = client as unknown as LSPClientWithWorkspace; @@ -312,6 +369,7 @@ export function schedulePullDiagnostics( uri: string, delay = PULL_DIAGNOSTICS_DELAY, ): void { + if (!diagnosticsEnabledForFile(client, uri)) return; if (!supportsPullDiagnostics(client)) { if (client.connected && !client.serverCapabilities) { void client.initializing @@ -370,26 +428,33 @@ export function disposePullDiagnostics(client: LSPClient): void { pullDiagnosticsStates.delete(client); } -export function lspDiagnosticsAutoSyncExtension( - client: LSPClient, - uri: string, -): Extension { +export function lspDiagnosticsAutoSyncExtension(): Extension { return ViewPlugin.fromClass( class { pending: ReturnType | null = null; + view: EditorView; - constructor() { - schedulePullDiagnostics(client, uri, 0); + constructor(view: EditorView) { + this.view = view; + this.schedule(0); } update(update: ViewUpdate): void { if (!update.docChanged) return; + this.schedule(500); + } + + schedule(delay: number): void { if (this.pending != null) clearTimeout(this.pending); - this.pending = setTimeout(() => { - this.pending = null; - client.sync(); - schedulePullDiagnostics(client, uri, 0); - }, 500); + this.pending = setTimeout(() => this.flush(), delay); + } + + flush(): void { + this.pending = null; + for (const plugin of LSPPlugin.getAll(this.view, "diagnostics")) { + plugin.client.sync(); + schedulePullDiagnostics(plugin.client, plugin.uri, 0); + } } destroy(): void { @@ -424,35 +489,14 @@ const diagnosticsLifecyclePlugin = ViewPlugin.fromClass( }, ); -function mapDiagnostics( - plugin: LSPPluginAPI, - state: EditorState, -): Diagnostic[] { - const stored = state.field(lspPublishedDiagnostics); - const changes = plugin.unsyncedChanges; - const mapped: Diagnostic[] = []; - - for (const diagnostic of stored) { - let from: number | null; - let to: number | null; - try { - from = changes.mapPos(diagnostic.from, 1, MapMode.TrackDel); - to = changes.mapPos(diagnostic.to, -1, MapMode.TrackDel); - } catch (_) { - continue; - } - if (from != null && to != null) { - mapped.push({ ...diagnostic, from, to }); - } - } - - return mapped; -} - function lspLinterSource(view: EditorView): Diagnostic[] { - const plugin = LSPPlugin.get(view) as LSPPluginAPI | null; - if (!plugin) return []; - return mapDiagnostics(plugin, view.state); + const stored = view.state.field(lspPublishedDiagnostics); + const diagnostics: Diagnostic[] = []; + for (const [client, items] of stored) { + const plugin = LSPPlugin.get(view, client); + if (plugin?.featureEnabled("diagnostics")) diagnostics.push(...items); + } + return diagnostics; } export function lspDiagnosticsClientExtension(): { @@ -552,16 +596,22 @@ export function lspDiagnosticsExtension( export default lspDiagnosticsExtension; -export function clearDiagnosticsEffect(): StateEffect { - return setPublishedDiagnostics.of([]); +export function clearDiagnosticsEffect( + client?: LSPClient, +): StateEffect { + return setPublishedDiagnostics.of( + client ? { client, diagnostics: [] } : { clearAll: true }, + ); } export function getLspDiagnostics(state: EditorState | null): LspDiagnostic[] { if (!state || typeof state.field !== "function") return []; try { const stored = state.field(lspPublishedDiagnostics, false); - if (!stored || !Array.isArray(stored)) return []; - return stored.map((diagnostic) => ({ ...diagnostic })); + if (!stored) return []; + return Array.from(stored.values()).flatMap((diagnostics) => + diagnostics.map((diagnostic) => ({ ...diagnostic })), + ); } catch (_) { return []; } diff --git a/src/cm/lsp/documentColors.ts b/src/cm/lsp/documentColors.ts index 3717acb68..99bc055c9 100644 --- a/src/cm/lsp/documentColors.ts +++ b/src/cm/lsp/documentColors.ts @@ -1,4 +1,7 @@ -import type { LSPClientExtension } from "@codemirror/lsp-client"; +import type { + LSPClient, + LSPClientExtension, +} from "@codemirror/lsp-client"; import { LSPPlugin } from "@codemirror/lsp-client"; import type { Extension, Range } from "@codemirror/state"; import { MapMode, StateEffect, StateField } from "@codemirror/state"; @@ -47,6 +50,7 @@ export interface LspDocumentColor { to: number; color: Color; css: string; + client: LSPClient; } interface DocumentColorParams { @@ -146,6 +150,7 @@ function toChipPayload(c: LspDocumentColor): ColorChipPayload { css: c.css, pickerSeed: lspColorToHex(c.color), source: "lsp", + lspClient: c.client, }; } @@ -289,8 +294,10 @@ function createPlugin(config: DocumentColorsConfig) { return; } - const lsp = LSPPlugin.get(this.view) as LSPPluginAPI | null; - if (!lsp?.client.connected) { + const bindings = LSPPlugin.getAll(this.view, "documentColor") as + readonly LSPPluginAPI[]; + const connected = bindings.filter((lsp) => lsp.client.connected); + if (!connected.length) { if (this.hasProvider || this.providerKnown) { this.hasProvider = false; this.providerKnown = false; @@ -305,12 +312,7 @@ function createPlugin(config: DocumentColorsConfig) { } this.connectRetries = 0; - const caps = lsp.client.serverCapabilities as - | { colorProvider?: boolean | object } - | null - | undefined; - - if (!caps) { + if (connected.some((lsp) => !lsp.client.serverCapabilities)) { if (this.capRetries < MAX_CAP_RETRIES) { this.capRetries++; this.schedule(false); @@ -319,9 +321,11 @@ function createPlugin(config: DocumentColorsConfig) { } this.capRetries = 0; - const provider = !!caps.colorProvider; + const providers = connected.filter( + (lsp) => !!lsp.client.serverCapabilities?.colorProvider, + ); this.providerKnown = true; - if (!provider) { + if (!providers.length) { if (this.hasProvider) { this.hasProvider = false; this.clearColors(); @@ -330,28 +334,39 @@ function createPlugin(config: DocumentColorsConfig) { } this.hasProvider = true; - lsp.client.sync(); const id = ++this.reqId; - - try { - const result = await lsp.client.request< - DocumentColorParams, - ColorInformation[] | null - >("textDocument/documentColor", { - textDocument: { uri: lsp.uri }, - }); - - if (id !== this.reqId) return; - - const stored = this.process( - lsp, - result ?? [], - this.view.state.doc.length, - ); - this.view.dispatch({ effects: setColors.of(stored) }); - } catch { - /* keep previous chips */ + const settled = await Promise.allSettled( + providers.map(async (lsp) => { + lsp.client.sync(); + const result = await lsp.client.request< + DocumentColorParams, + ColorInformation[] | null + >("textDocument/documentColor", { + textDocument: { uri: lsp.uri }, + }); + return this.process( + lsp, + result ?? [], + this.view.state.doc.length, + ); + }), + ); + if (id !== this.reqId) return; + const stored: LspDocumentColor[] = []; + const seen = new Set(); + for (const result of settled) { + if (result.status !== "fulfilled") continue; + for (const color of result.value) { + const key = `${color.from}:${color.to}:${color.css}`; + if (seen.has(key)) continue; + seen.add(key); + stored.push(color); + if (stored.length >= maxColors) break; + } + if (stored.length >= maxColors) break; } + stored.sort((a, b) => a.from - b.from || a.to - b.to); + this.view.dispatch({ effects: setColors.of(stored) }); } process( @@ -377,6 +392,7 @@ function createPlugin(config: DocumentColorsConfig) { to: mapped.to, color, css: lspColorToCss(color), + client: lsp.client, }); if (out.length >= maxColors) break; @@ -422,7 +438,9 @@ async function handleColorPick( view: EditorView, payload: ColorChipPayload, ): Promise { - const lsp = LSPPlugin.get(view) as LSPPluginAPI | null; + const lsp = (payload.lspClient + ? LSPPlugin.get(view, payload.lspClient) + : LSPPlugin.getForFeature(view, "documentColor")) as LSPPluginAPI | null; if (!lsp?.client.connected) return; const doc = view.state.doc; @@ -436,7 +454,9 @@ async function handleColorPick( const picked = await openColorPicker(seed); if (!picked) return; - const lsp2 = LSPPlugin.get(view) as LSPPluginAPI | null; + const lsp2 = (payload.lspClient + ? LSPPlugin.get(view, payload.lspClient) + : LSPPlugin.getForFeature(view, "documentColor")) as LSPPluginAPI | null; if (!lsp2?.client.connected) return; const live = findLiveColor(view, payload, currentText); @@ -520,10 +540,20 @@ export function documentColorsClientExtension(): LSPClientExtension { }; } +let defaultDocumentColorsEditorExtension: Extension | null = null; + export function documentColorsEditorExtension( config: DocumentColorsConfig = {}, ): Extension { if (config.enabled === false) return []; + if (!Object.keys(config).length) { + defaultDocumentColorsEditorExtension ??= [ + lspDocumentColorsField, + createPlugin({}), + colorChipTheme, + ]; + return defaultDocumentColorsEditorExtension; + } return [lspDocumentColorsField, createPlugin(config), colorChipTheme]; } diff --git a/src/cm/lsp/documentSymbols.ts b/src/cm/lsp/documentSymbols.ts index 8e4dc3d92..6d973f117 100644 --- a/src/cm/lsp/documentSymbols.ts +++ b/src/cm/lsp/documentSymbols.ts @@ -215,7 +215,10 @@ function flattenSymbols( export async function fetchDocumentSymbols( view: EditorView, ): Promise { - const plugin = LSPPlugin.get(view) as LSPPluginAPI | null; + const plugin = LSPPlugin.getAll(view, "documentSymbol").find( + (candidate) => + !!candidate.client.serverCapabilities?.documentSymbolProvider, + ) as LSPPluginAPI | undefined; if (!plugin) { return null; } @@ -308,12 +311,11 @@ export async function navigateToSymbol( } export function supportsDocumentSymbols(view: EditorView): boolean { - const plugin = LSPPlugin.get(view) as LSPPluginAPI | null; - if (!plugin?.client.connected) { - return false; - } - - return !!plugin.client.serverCapabilities?.documentSymbolProvider; + return LSPPlugin.getAll(view, "documentSymbol").some( + (plugin) => + plugin.client.connected && + !!plugin.client.serverCapabilities?.documentSymbolProvider, + ); } export interface DocumentSymbolsResult { diff --git a/src/cm/lsp/inlayHints.ts b/src/cm/lsp/inlayHints.ts index b68080665..477c82d1b 100644 --- a/src/cm/lsp/inlayHints.ts +++ b/src/cm/lsp/inlayHints.ts @@ -171,13 +171,14 @@ function createPlugin(config: InlayHintsConfig) { } async fetch(): Promise { - const lsp = LSPPlugin.get(this.view) as LSPPluginAPI | null; - if (!lsp?.client.connected) return; - - const caps = lsp.client.serverCapabilities; - if (!caps?.inlayHintProvider) return; - - lsp.client.sync(); + const providers = ( + LSPPlugin.getAll(this.view, "inlayHint") as readonly LSPPluginAPI[] + ).filter( + (lsp) => + lsp.client.connected && + !!lsp.client.serverCapabilities?.inlayHintProvider, + ); + if (!providers.length) return; const id = ++this.reqId; const doc = this.view.state.doc; @@ -190,25 +191,38 @@ function createPlugin(config: InlayHintsConfig) { doc.lineAt(Math.min(doc.length, to)).number + buf, ); - try { - const hints = await lsp.client.request< - InlayHintParams, - InlayHint[] | null - >("textDocument/inlayHint", { - textDocument: { uri: lsp.uri }, - range: { - start: lsp.toPosition(doc.line(startLn).from), - end: lsp.toPosition(doc.line(endLn).to), - }, - }); - - if (id !== this.reqId) return; - - const processed = this.process(lsp, hints ?? [], doc.length); - this.view.dispatch({ effects: setHints.of(processed) }); - } catch { - // Non-critical - silently ignore + const settled = await Promise.allSettled( + providers.map(async (lsp) => { + lsp.client.sync(); + const hints = await lsp.client.request< + InlayHintParams, + InlayHint[] | null + >("textDocument/inlayHint", { + textDocument: { uri: lsp.uri }, + range: { + start: lsp.toPosition(doc.line(startLn).from), + end: lsp.toPosition(doc.line(endLn).to), + }, + }); + return this.process(lsp, hints ?? [], doc.length); + }), + ); + if (id !== this.reqId) return; + const processed: ProcessedHint[] = []; + const seen = new Set(); + for (const result of settled) { + if (result.status !== "fulfilled") continue; + for (const hint of result.value) { + const key = `${hint.pos}:${hint.label}`; + if (seen.has(key)) continue; + seen.add(key); + processed.push(hint); + } } + processed.sort((a, b) => a.pos - b.pos); + this.view.dispatch({ + effects: setHints.of(processed.slice(0, max)), + }); } process( @@ -326,10 +340,20 @@ export function inlayHintsClientExtension(): LSPClientExtension { }; } +let defaultInlayHintsEditorExtension: Extension | null = null; + export function inlayHintsEditorExtension( config: InlayHintsConfig = {}, ): Extension { if (config.enabled === false) return []; + if (!Object.keys(config).length) { + defaultInlayHintsEditorExtension ??= [ + hintsField, + createPlugin({}), + styles, + ]; + return defaultInlayHintsEditorExtension; + } return [hintsField, createPlugin(config), styles]; } diff --git a/src/cm/lsp/references.ts b/src/cm/lsp/references.ts index 79bc31cc6..def69288c 100644 --- a/src/cm/lsp/references.ts +++ b/src/cm/lsp/references.ts @@ -102,15 +102,10 @@ function getWordAtCursor(view: EditorView): string { async function fetchReferences( view: EditorView, ): Promise<{ symbolName: string; references: ReferenceWithContext[] } | null> { - const plugin = LSPPlugin.get(view); - if (!plugin) { - return null; - } - - const client = plugin.client; - const capabilities = client.serverCapabilities; - - if (!capabilities?.referencesProvider) { + const plugins = LSPPlugin.getAll(view, "references").filter( + (plugin) => !!plugin.client.serverCapabilities?.referencesProvider, + ); + if (!plugins.length) { const toast = (globalThis as Record).toast as | ((msg: string) => void) | undefined; @@ -123,24 +118,37 @@ async function fetchReferences( const line = state.doc.lineAt(pos); const lineNumber = line.number - 1; const character = pos - line.from; - const uri = plugin.uri; - const symbolName = getWordAtCursor(view); - - client.sync(); - - const params: ReferenceParams = { - textDocument: { uri }, - position: { line: lineNumber, character }, - context: { includeDeclaration: true }, - }; - - const locations = await client.request( - "textDocument/references", - params, + const settled = await Promise.allSettled( + plugins.map(async (plugin) => { + plugin.client.sync(); + return ( + (await plugin.client.request( + "textDocument/references", + { + textDocument: { uri: plugin.uri }, + position: { line: lineNumber, character }, + context: { includeDeclaration: true }, + }, + )) ?? [] + ); + }), ); - - if (!locations || locations.length === 0) { + const locations: Location[] = []; + const seen = new Set(); + for (const result of settled) { + if (result.status === "rejected") { + console.warn("[LSP:References] Provider failed", result.reason); + continue; + } + for (const location of result.value) { + const key = `${location.uri}:${location.range.start.line}:${location.range.start.character}:${location.range.end.line}:${location.range.end.character}`; + if (seen.has(key)) continue; + seen.add(key); + locations.push(location); + } + } + if (!locations.length) { return { symbolName, references: [] }; } diff --git a/src/cm/lsp/rename.ts b/src/cm/lsp/rename.ts index 6ce0ff212..75b89693c 100644 --- a/src/cm/lsp/rename.ts +++ b/src/cm/lsp/rename.ts @@ -55,7 +55,9 @@ function getPrepareRename(plugin: LSPPlugin, pos: number) { async function performRename(view: EditorView): Promise { const wordRange = view.state.wordAt(view.state.selection.main.head); - const plugin = LSPPlugin.get(view); + const plugin = LSPPlugin.getAll(view, "rename").find( + (candidate) => !!candidate.client.serverCapabilities?.renameProvider, + ); if (!plugin) { return false; @@ -133,7 +135,7 @@ async function performRename(view: EditorView): Promise { } try { - await doRename(view, String(newName), wordRange.from); + await doRename(plugin, String(newName), wordRange.from); } catch (error) { addLspLogFor(plugin, "error", "Rename failed", error); console.error("[LSP:Rename] Rename failed:", error); @@ -198,13 +200,10 @@ async function applyChangesToFile( } async function doRename( - view: EditorView, + plugin: LSPPlugin, newName: string, position: number, ): Promise { - const plugin = LSPPlugin.get(view); - if (!plugin) return; - plugin.client.sync(); const response = await plugin.client.withMapping((mapping) => @@ -265,7 +264,7 @@ async function doRename( export const renameSymbol: Command = (view) => { performRename(view).catch((error) => { - const plugin = LSPPlugin.get(view); + const plugin = LSPPlugin.getForFeature(view, "rename"); addLspLogFor(plugin, "error", "Rename command failed", error); console.error("[LSP:Rename] Rename command failed:", error); }); diff --git a/src/cm/lsp/serverRegistry.ts b/src/cm/lsp/serverRegistry.ts index 155ab3152..24b255630 100644 --- a/src/cm/lsp/serverRegistry.ts +++ b/src/cm/lsp/serverRegistry.ts @@ -299,6 +299,11 @@ function sanitizeDefinition( id, label: definition.label ?? id, enabled: definition.enabled !== false, + priority: + typeof definition.priority === "number" && + Number.isFinite(definition.priority) + ? definition.priority + : 0, languages: sanitizeLanguages(definition.languages), transport: sanitizedTransport, initializationOptions: clone(definition.initializationOptions), @@ -417,10 +422,12 @@ export function getServersForLanguage( const langKey = toKey(languageId); if (!langKey) return []; - return listServers().filter((server) => { - if (!includeDisabled && !server.enabled) return false; - return server.languages.includes(langKey); - }); + return listServers() + .filter((server) => { + if (!includeDisabled && !server.enabled) return false; + return server.languages.includes(langKey); + }) + .sort((left, right) => right.priority - left.priority); } export function onRegistryChange(listener: RegistryEventListener): () => void { diff --git a/src/cm/lsp/tooltipExtensions.ts b/src/cm/lsp/tooltipExtensions.ts index 5e54537bc..2a9a157ea 100644 --- a/src/cm/lsp/tooltipExtensions.ts +++ b/src/cm/lsp/tooltipExtensions.ts @@ -5,6 +5,7 @@ import { language as languageFacet, } from "@codemirror/language"; import { languages } from "@codemirror/language-data"; +import type { LSPClient } from "@codemirror/lsp-client"; import { LSPPlugin } from "@codemirror/lsp-client"; import { type Extension, @@ -28,6 +29,7 @@ import { import { highlightCode } from "@lezer/highlight"; import type { HoverParams, + ServerCapabilities, SignatureHelpContext, SignatureHelpParams, } from "vscode-languageserver-protocol"; @@ -43,7 +45,6 @@ interface LspClientInternals { config?: { highlightLanguage?: (language: string) => Language | null | undefined; }; - hasCapability?: (name: string) => boolean; } const SIGNATURE_TRIGGER_DELAY = 120; @@ -55,6 +56,13 @@ const pluginHoverLanguageLoads = new WeakMap< Promise >(); +function clientHasCapability( + client: LSPClient, + name: keyof ServerCapabilities, +): boolean { + return !client.serverCapabilities || !!client.serverCapabilities[name]; +} + function normalizeLanguageName(value: string): string { return String(value ?? "") .trim() @@ -349,8 +357,7 @@ function closeHoverIfNeeded(view: EditorView): void { } function hoverRequest(plugin: LSPPlugin, pos: number) { - const client = plugin.client as typeof plugin.client & LspClientInternals; - if (client.hasCapability?.("hoverProvider") === false) { + if (!clientHasCapability(plugin.client, "hoverProvider")) { return Promise.resolve(null); } @@ -368,22 +375,50 @@ function lspTooltipSource( view: EditorView, pos: number, ): Promise { - const plugin = LSPPlugin.get(view); - if (!plugin) return Promise.resolve(null); + const plugins = LSPPlugin.getAll(view, "hover").filter( + (plugin) => clientHasCapability(plugin.client, "hoverProvider"), + ); + if (!plugins.length) return Promise.resolve(null); + + return Promise.allSettled( + plugins.map((plugin) => hoverRequest(plugin, pos)), + ).then(async (settled) => { + const results: Array<{ plugin: LSPPlugin; result: Hover }> = []; + for (let index = 0; index < settled.length; index++) { + const item = settled[index]; + if (item.status === "fulfilled" && item.value) { + results.push({ plugin: plugins[index], result: item.value }); + } else if (item.status === "rejected") { + console.warn("[LSP:Hover] Provider failed", item.reason); + } + } + if (!results.length) return null; + await Promise.all( + results.map(({ result }) => loadHoverContentLanguages(result.contents)), + ); - return hoverRequest(plugin, pos).then(async (result) => { - if (!result) return null; - await loadHoverContentLanguages(result.contents); + let from = pos; + let to = pos; + for (const { result } of results) { + if (!result.range) continue; + from = Math.min(from, fromPosition(view.state.doc, result.range.start)); + to = Math.max(to, fromPosition(view.state.doc, result.range.end)); + } return { - pos: result.range - ? fromPosition(view.state.doc, result.range.start) - : pos, - end: result.range ? fromPosition(view.state.doc, result.range.end) : pos, + pos: from, + end: to, create() { const dom = document.createElement("div"); dom.className = "cm-lsp-hover-tooltip cm-lsp-documentation"; - dom.innerHTML = renderTooltipContent(plugin, result.contents); + for (let index = 0; index < results.length; index++) { + if (index) dom.appendChild(document.createElement("hr")); + const section = dom.appendChild(document.createElement("div")); + section.innerHTML = renderTooltipContent( + results[index].plugin, + results[index].result.contents, + ); + } return { dom }; }, above: true, @@ -418,8 +453,7 @@ function getSignatureHelp( pos: number, context: SignatureHelpContext, ) { - const client = plugin.client as typeof plugin.client & LspClientInternals; - if (client.hasCapability?.("signatureHelpProvider") === false) { + if (!clientHasCapability(plugin.client, "signatureHelpProvider")) { return Promise.resolve(null); } @@ -461,6 +495,7 @@ class SignatureState { readonly data: LspSignatureHelp, readonly active: number, readonly tooltip: Tooltip, + readonly client: LSPClient, ) {} } @@ -468,17 +503,19 @@ const signatureEffect = StateEffect.define<{ data: LspSignatureHelp; active: number; pos: number; + client: LSPClient; } | null>(); function signatureTooltip( data: LspSignatureHelp, active: number, pos: number, + client: LSPClient, ): Tooltip { return { pos, above: true, - create: (view) => drawSignatureTooltip(view, data, active), + create: (view) => drawSignatureTooltip(view, data, active, client), }; } @@ -497,7 +534,9 @@ const signatureState = StateField.define({ effect.value.data, effect.value.active, effect.value.pos, + effect.value.client, ), + effect.value.client, ); } return null; @@ -505,10 +544,15 @@ const signatureState = StateField.define({ } if (value && tr.docChanged) { - return new SignatureState(value.data, value.active, { - ...value.tooltip, - pos: tr.changes.mapPos(value.tooltip.pos), - }); + return new SignatureState( + value.data, + value.active, + { + ...value.tooltip, + pos: tr.changes.mapPos(value.tooltip.pos), + }, + value.client, + ); } return value; @@ -521,6 +565,7 @@ function drawSignatureTooltip( view: EditorView, data: LspSignatureHelp, active: number, + client: LSPClient, ) { const dom = document.createElement("div"); dom.className = "cm-lsp-signature-tooltip"; @@ -570,7 +615,7 @@ function drawSignatureTooltip( } if (signature.documentation) { - const plugin = LSPPlugin.get(view); + const plugin = LSPPlugin.get(view, client); if (plugin) { const docs = dom.appendChild(document.createElement("div")); docs.className = "cm-lsp-signature-documentation cm-lsp-documentation"; @@ -602,7 +647,10 @@ const signaturePlugin = ViewPlugin.fromClass( } } - const plugin = LSPPlugin.get(update.view); + const plugin = LSPPlugin.getAll(update.view, "signatureHelp").find( + (candidate) => + clientHasCapability(candidate.client, "signatureHelpProvider"), + ); if (!plugin) return; const sigState = update.view.state.field(signatureState); @@ -700,6 +748,7 @@ const signaturePlugin = ViewPlugin.fromClass( data: result, active, pos: same ? current!.tooltip.pos : request.pos, + client: plugin.client, }), }); } else if (view.state.field(signatureState, false)) { @@ -760,7 +809,10 @@ export const showSignatureHelp: Command = (view) => { const field = view.state.field(signatureState); if (!plugin || field === undefined) return false; - const lspPlugin = LSPPlugin.get(view); + const lspPlugin = LSPPlugin.getAll(view, "signatureHelp").find( + (candidate) => + clientHasCapability(candidate.client, "signatureHelpProvider"), + ); if (!lspPlugin) return false; plugin.startRequest(lspPlugin, { @@ -780,6 +832,7 @@ export const nextSignature: Command = (view) => { data: field.data, active: field.active + 1, pos: field.tooltip.pos, + client: field.client, }), }); } @@ -795,6 +848,7 @@ export const prevSignature: Command = (view) => { data: field.data, active: field.active - 1, pos: field.tooltip.pos, + client: field.client, }), }); } @@ -807,7 +861,13 @@ export const signatureKeymap: readonly KeyBinding[] = [ { key: "Mod-Shift-ArrowDown", run: nextSignature }, ]; +const defaultHoverTooltipsExtension: Extension = [ + hoverTooltip(lspTooltipSource, { hideOnChange: true }), + closeHoverOnInteraction, +]; + export function hoverTooltips(config: { hoverTime?: number } = {}): Extension { + if (config.hoverTime == null) return defaultHoverTooltipsExtension; return [ hoverTooltip(lspTooltipSource, { hideOnChange: true, diff --git a/src/cm/lsp/types.ts b/src/cm/lsp/types.ts index 3fa29bdad..692762462 100644 --- a/src/cm/lsp/types.ts +++ b/src/cm/lsp/types.ts @@ -282,6 +282,8 @@ export interface LspServerManifest { id?: string; label?: string; enabled?: boolean; + /** Higher-priority servers own single-provider features such as formatting. */ + priority?: number; languages?: string[]; transport?: TransportDescriptor; initializationOptions?: Record; @@ -338,6 +340,7 @@ export interface LspServerDefinition { id: string; label: string; enabled: boolean; + priority: number; languages: string[]; transport: TransportDescriptor; initializationOptions?: Record; @@ -635,6 +638,7 @@ export interface LSPPluginAPI { unsyncedChanges: { mapPos: (pos: number, assoc?: number, mode?: MapMode) => number | null; empty: boolean; + newLength: number; }; /** Clear pending changes */ clear: () => void; diff --git a/src/cm/lsp/workspace.ts b/src/cm/lsp/workspace.ts index 23fd9588b..e570928f5 100644 --- a/src/cm/lsp/workspace.ts +++ b/src/cm/lsp/workspace.ts @@ -137,7 +137,7 @@ export default class AcodeWorkspace extends Workspace { for (const file of this.files) { const view = file.getView(); if (!view) continue; - const plugin = LSPPlugin.get(view); + const plugin = LSPPlugin.get(view, this.client); if (!plugin) continue; const { unsyncedChanges } = plugin; if (unsyncedChanges.empty) continue; diff --git a/tests/unit/lspMultiClient.test.js b/tests/unit/lspMultiClient.test.js new file mode 100644 index 000000000..d118a3134 --- /dev/null +++ b/tests/unit/lspMultiClient.test.js @@ -0,0 +1,328 @@ +// @vitest-environment happy-dom + +import {CompletionContext} from "@codemirror/autocomplete"; +import {forEachDiagnostic} from "@codemirror/lint"; +import {Compartment} from "@codemirror/state"; +import {EditorView} from "@codemirror/view"; +import { + LSPClient, + LSPPlugin, + Workspace, + serverCompletionSource, + serverDiagnostics, +} from "@codemirror/lsp-client"; +import { + getLspDiagnostics, + lspDiagnosticsAutoSyncExtension, + lspDiagnosticsClientExtension, + lspDiagnosticsUiExtension, + schedulePullDiagnostics, +} from "cm/lsp/diagnostics"; +import {afterEach, describe, expect, it, vi} from "vitest"; + +const views = []; + +afterEach(() => { + while (views.length) views.pop().destroy(); + document.body.replaceChildren(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +class TestWorkspace extends Workspace { + files = []; + opened = 0; + closed = 0; + + openFile(uri, languageId, view) { + this.opened++; + this.files.push({ + uri, + languageId, + version: 0, + doc: view.state.doc, + getView: () => view, + }); + } + + closeFile(uri, view) { + const index = this.files.findIndex( + (file) => file.uri === uri && file.getView() === view, + ); + if (index >= 0) { + this.closed++; + this.files.splice(index, 1); + } + } + + syncFiles() { + const updates = []; + for (const file of this.files) { + const view = file.getView(); + const plugin = LSPPlugin.get(view, this.client); + if (!plugin) continue; + const changes = plugin.unsyncedChanges; + if (changes.empty) continue; + const prevDoc = file.doc; + file.doc = changes.apply(prevDoc); + file.version++; + plugin.clear(); + updates.push({file, prevDoc, changes}); + } + return updates; + } +} + +function client() { + return new LSPClient({workspace: (lspClient) => new TestWorkspace(lspClient)}); +} + +function editor(doc, extensions) { + const view = new EditorView({ + doc, + extensions, + parent: document.body, + }); + views.push(view); + return view; +} + +describe("multiple LSP clients on one editor", () => { + it("tracks one change stream with an independent sync checkpoint per client", () => { + const primary = client(); + const supplemental = client(); + const bindings = new Compartment(); + const uri = "file:///workspace/example.ts"; + const primaryBinding = primary.plugin(uri, "typescript", {priority: 100}); + const supplementalBinding = supplemental.plugin(uri, "typescript", { + priority: 20, + features: {formatting: false, rename: false}, + }); + const view = editor( + "const value = 1", + bindings.of([supplementalBinding, primaryBinding]), + ); + + expect(LSPPlugin.getAll(view).map((plugin) => plugin.client)).toEqual([ + primary, + supplemental, + ]); + expect(LSPPlugin.getForFeature(view, "formatting")?.client).toBe(primary); + expect(LSPPlugin.get(view, supplemental)?.client).toBe(supplemental); + + view.dispatch({changes: {from: view.state.doc.length, insert: ";"}}); + const firstText = view.state.doc.toString(); + expect(LSPPlugin.get(view, primary).unsyncedChanges.apply( + primary.workspace.files[0].doc, + ).toString()).toBe(firstText); + expect(LSPPlugin.get(view, supplemental).unsyncedChanges.apply( + supplemental.workspace.files[0].doc, + ).toString()).toBe(firstText); + + expect(primary.workspace.syncFiles()).toHaveLength(1); + expect(LSPPlugin.get(view, primary).unsyncedChanges.empty).toBe(true); + expect(LSPPlugin.get(view, supplemental).unsyncedChanges.empty).toBe(false); + + view.dispatch({changes: {from: 0, insert: "export "}}); + expect(LSPPlugin.get(view, supplemental).unsyncedChanges.apply( + supplemental.workspace.files[0].doc, + ).toString()).toBe(view.state.doc.toString()); + expect(supplemental.workspace.syncFiles()).toHaveLength(1); + expect(LSPPlugin.get(view, supplemental).unsyncedChanges.empty).toBe(true); + + view.dispatch({ + effects: bindings.reconfigure(primaryBinding), + }); + expect(LSPPlugin.getAll(view)).toHaveLength(1); + expect(supplemental.workspace.closed).toBe(1); + expect(primary.workspace.closed).toBe(0); + + view.destroy(); + views.pop(); + expect(primary.workspace.closed).toBe(1); + }); + + it("merges completion providers by priority and isolates failures", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const primary = client(); + const supplemental = client(); + const failing = client(); + primary.serverCapabilities = {completionProvider: {}}; + supplemental.serverCapabilities = {completionProvider: {}}; + failing.serverCapabilities = {completionProvider: {}}; + primary.sync = vi.fn(); + supplemental.sync = vi.fn(); + failing.sync = vi.fn(); + primary.request = vi.fn().mockResolvedValue([ + {label: "typescriptItem", detail: "TypeScript"}, + {label: "sharedItem"}, + ]); + supplemental.request = vi.fn().mockResolvedValue([ + {label: "tailwindItem", detail: "Tailwind CSS"}, + {label: "sharedItem"}, + ]); + failing.request = vi.fn().mockRejectedValue(new Error("server unavailable")); + + const view = editor("di", [ + primary.plugin("file:///workspace/example.tsx", "typescriptreact", { + priority: 100, + }), + supplemental.plugin("file:///workspace/example.tsx", "typescriptreact", { + priority: 50, + }), + failing.plugin("file:///workspace/example.tsx", "typescriptreact", { + priority: 10, + }), + ]); + const context = new CompletionContext( + view.state, + view.state.doc.length, + true, + view, + ); + + const result = await serverCompletionSource(context); + expect(result.options.map((option) => option.label)).toEqual([ + "typescriptItem", + "sharedItem", + "tailwindItem", + ]); + expect(primary.request).toHaveBeenCalledOnce(); + expect(supplemental.request).toHaveBeenCalledOnce(); + expect(failing.request).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith( + "[lsp] Completion provider failed", + expect.objectContaining({message: "server unavailable"}), + ); + }); + + it("does not query a client when completion is disabled for its binding", async () => { + const primary = client(); + const diagnosticsOnly = client(); + primary.serverCapabilities = {completionProvider: {}}; + diagnosticsOnly.serverCapabilities = {completionProvider: {}}; + primary.sync = vi.fn(); + diagnosticsOnly.sync = vi.fn(); + primary.request = vi.fn().mockResolvedValue([{label: "primaryItem"}]); + diagnosticsOnly.request = vi.fn().mockResolvedValue([{label: "wrongItem"}]); + const view = editor("x", [ + primary.plugin("file:///workspace/example.ts", "typescript"), + diagnosticsOnly.plugin("file:///workspace/example.ts", "typescript", { + features: {completion: false}, + }), + ]); + + const result = await serverCompletionSource( + new CompletionContext(view.state, 1, true, view), + ); + expect(result.options.map((option) => option.label)).toEqual(["primaryItem"]); + expect(diagnosticsOnly.request).not.toHaveBeenCalled(); + }); + + it("keeps diagnostics from each server when another server publishes", () => { + const primary = client(); + const supplemental = client(); + const diagnosticsExtension = serverDiagnostics(); + const view = editor("const x = 1", [ + primary.plugin("file:///workspace/example.ts", "typescript"), + supplemental.plugin("file:///workspace/example.ts", "typescript"), + diagnosticsExtension.editorExtension, + ]); + const publish = + diagnosticsExtension.notificationHandlers[ + "textDocument/publishDiagnostics" + ]; + const diagnostic = (message, character) => ({ + message, + severity: 2, + range: { + start: {line: 0, character}, + end: {line: 0, character: character + 1}, + }, + }); + + publish(primary, { + uri: "file:///workspace/example.ts", + version: 0, + diagnostics: [diagnostic("TypeScript", 6)], + }); + publish(supplemental, { + uri: "file:///workspace/example.ts", + version: 0, + diagnostics: [diagnostic("Tailwind CSS", 8)], + }); + const messages = []; + forEachDiagnostic(view.state, (item) => messages.push(item.message)); + expect(messages).toEqual(["TypeScript", "Tailwind CSS"]); + + publish(primary, { + uri: "file:///workspace/example.ts", + version: 0, + diagnostics: [], + }); + const remaining = []; + forEachDiagnostic(view.state, (item) => remaining.push(item.message)); + expect(remaining).toEqual(["Tailwind CSS"]); + }); + + it("does not synchronize a server whose diagnostics feature is disabled", async () => { + vi.useFakeTimers(); + const enabled = client(); + const disabled = client(); + enabled.sync = vi.fn(); + disabled.sync = vi.fn(); + const uri = "file:///workspace/example.ts"; + const view = editor("const x = 1", [ + enabled.plugin(uri, "typescript"), + disabled.plugin(uri, "typescript", { + features: {diagnostics: false}, + }), + lspDiagnosticsAutoSyncExtension(), + ]); + + await vi.advanceTimersByTimeAsync(0); + expect(enabled.sync).toHaveBeenCalledOnce(); + expect(disabled.sync).not.toHaveBeenCalled(); + + view.dispatch({changes: {from: view.state.doc.length, insert: ";"}}); + await vi.advanceTimersByTimeAsync(500); + expect(enabled.sync).toHaveBeenCalledTimes(2); + expect(disabled.sync).not.toHaveBeenCalled(); + }); + + it("rejects pull and push diagnostics from a disabled provider", () => { + const disabled = client(); + disabled.serverCapabilities = {diagnosticProvider: {}}; + disabled.request = vi.fn(); + const uri = "file:///workspace/example.ts"; + const view = editor("const x = 1", [ + disabled.plugin(uri, "typescript", { + features: {diagnostics: false}, + }), + lspDiagnosticsUiExtension(false), + ]); + + schedulePullDiagnostics(disabled, uri, 0); + expect(disabled.request).not.toHaveBeenCalled(); + + const publish = + lspDiagnosticsClientExtension().notificationHandlers[ + "textDocument/publishDiagnostics" + ]; + publish(disabled, { + uri, + version: 0, + diagnostics: [ + { + message: "must stay hidden", + severity: 2, + range: { + start: {line: 0, character: 6}, + end: {line: 0, character: 7}, + }, + }, + ], + }); + expect(getLspDiagnostics(view.state)).toEqual([]); + }); +});