From 16fa86aff4517f7a821cf1d115281acad81a8908 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:17:03 +0530 Subject: [PATCH 1/5] feat(lsp): support multiple language servers per editor --- bun.lock | 19 +- package-lock.json | 110 ++++- package.json | 3 +- packages/lsp-client/CHANGELOG.md | 91 ++++ packages/lsp-client/LICENSE | 21 + packages/lsp-client/README.md | 95 ++++ packages/lsp-client/UPSTREAM.md | 25 + packages/lsp-client/package.json | 31 ++ packages/lsp-client/src/README.md | 67 +++ packages/lsp-client/src/client.ts | 512 +++++++++++++++++++++ packages/lsp-client/src/completion.ts | 287 ++++++++++++ packages/lsp-client/src/definition.ts | 85 ++++ packages/lsp-client/src/diagnostics.ts | 60 +++ packages/lsp-client/src/formatting.ts | 53 +++ packages/lsp-client/src/hover.ts | 94 ++++ packages/lsp-client/src/index.ts | 69 +++ packages/lsp-client/src/plugin.ts | 270 +++++++++++ packages/lsp-client/src/pos.ts | 20 + packages/lsp-client/src/references.ts | 200 ++++++++ packages/lsp-client/src/rename.ts | 77 ++++ packages/lsp-client/src/signature.ts | 274 +++++++++++ packages/lsp-client/src/text.ts | 56 +++ packages/lsp-client/src/theme.ts | 79 ++++ packages/lsp-client/src/workspace.ts | 161 +++++++ packages/lsp-client/test/server.ts | 207 +++++++++ packages/lsp-client/test/webtest-client.ts | 373 +++++++++++++++ src/cm/colorChip.ts | 16 +- src/cm/lsp/clientManager.ts | 49 +- src/cm/lsp/codeActions.ts | 117 ++--- src/cm/lsp/diagnostics.ts | 149 +++--- src/cm/lsp/documentColors.ts | 96 ++-- src/cm/lsp/documentSymbols.ts | 16 +- src/cm/lsp/inlayHints.ts | 74 ++- src/cm/lsp/references.ts | 58 ++- src/cm/lsp/rename.ts | 13 +- src/cm/lsp/serverRegistry.ts | 15 +- src/cm/lsp/tooltipExtensions.ts | 92 +++- src/cm/lsp/types.ts | 4 + src/cm/lsp/workspace.ts | 2 +- tests/unit/lspMultiClient.test.js | 259 +++++++++++ tsconfig.json | 2 +- 41 files changed, 4029 insertions(+), 272 deletions(-) create mode 100644 packages/lsp-client/CHANGELOG.md create mode 100644 packages/lsp-client/LICENSE create mode 100644 packages/lsp-client/README.md create mode 100644 packages/lsp-client/UPSTREAM.md create mode 100644 packages/lsp-client/package.json create mode 100644 packages/lsp-client/src/README.md create mode 100644 packages/lsp-client/src/client.ts create mode 100644 packages/lsp-client/src/completion.ts create mode 100644 packages/lsp-client/src/definition.ts create mode 100644 packages/lsp-client/src/diagnostics.ts create mode 100644 packages/lsp-client/src/formatting.ts create mode 100644 packages/lsp-client/src/hover.ts create mode 100644 packages/lsp-client/src/index.ts create mode 100644 packages/lsp-client/src/plugin.ts create mode 100644 packages/lsp-client/src/pos.ts create mode 100644 packages/lsp-client/src/references.ts create mode 100644 packages/lsp-client/src/rename.ts create mode 100644 packages/lsp-client/src/signature.ts create mode 100644 packages/lsp-client/src/text.ts create mode 100644 packages/lsp-client/src/theme.ts create mode 100644 packages/lsp-client/src/workspace.ts create mode 100644 packages/lsp-client/test/server.ts create mode 100644 packages/lsp-client/test/webtest-client.ts create mode 100644 tests/unit/lspMultiClient.test.js diff --git a/bun.lock b/bun.lock index 3de02944f..12941fb5f 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": "file:packages/lsp-client", "@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@file:packages/lsp-client", { "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" } }], "@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..eaa2514c9 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": "file:packages/lsp-client", "@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,20 +3965,8 @@ } }, "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==", - "license": "MIT", - "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" - } + "resolved": "packages/lsp-client", + "link": true }, "node_modules/@codemirror/search": { "version": "6.7.1", @@ -6182,7 +6171,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 +6189,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 +7746,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 +11289,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 +17485,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 +18177,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", @@ -18573,6 +18632,21 @@ "node": ">= 6" } }, + "packages/lsp-client": { + "name": "@codemirror/lsp-client", + "version": "6.2.5-acode.1", + "license": "MIT", + "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" + } + }, "src/plugins/auth": { "name": "com.foxdebug.acode.rk.auth", "version": "1.0.0", diff --git a/package.json b/package.json index 30516d10a..87430c1c4 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,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 +147,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": "file:packages/lsp-client", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/packages/lsp-client/CHANGELOG.md b/packages/lsp-client/CHANGELOG.md new file mode 100644 index 000000000..95fdefb29 --- /dev/null +++ b/packages/lsp-client/CHANGELOG.md @@ -0,0 +1,91 @@ +## 6.2.5-acode.1 (2026-08-04) + +### New features + +Allow multiple LSP clients to bind to one editor, with per-client feature +selection, priority routing, independent document synchronization, and merged +completion and hover providers. + +## 6.2.5 (2026-06-09) + +### Bug fixes + +Strip backslash escapes in snippets in places where CodeMirror doesn't support them. + +Support the `extraTextEdits` field in completions. + +## 6.2.4 (2026-05-15) + +### Bug fixes + +Tell the server we prefer Markdown docs over plain text. + +Support completion-set-wide `insertTextFormat` options. + +Support separate filter and display names for completions. + +## 6.2.3 (2026-04-15) + +### Bug fixes + +Don't crash when trying to jump to a symbol that the server does not provide a location for. + +Fix the argument passed to cancel requests. + +## 6.2.2 (2026-03-02) + +### Bug fixes + +In completion snippets from the server, treat `$1` the same as `${1}`. + +## 6.2.1 (2025-11-20) + +### Bug fixes + +Support `sortText` properties on LSP completions. + +## 6.2.0 (2025-10-27) + +### New features + +`serverCompletion` now takes an option `validFor` that can be used to configure the regexp used in the result. + +## 6.1.2 (2025-09-10) + +### Bug fixes + +Fix an issue where snippet completions would display the snippet source as their label. + +## 6.1.1 (2025-09-02) + +### Bug fixes + +Properly declare the @codemirror/lint dependency. + +Make sure document changes are eagerly pushed to the server when `serverDiagnostics` is active. + +## 6.1.0 (2025-08-23) + +### New features + +`LSPClient` now accepts an array of extensions directly in its configuration. These can also add behavior (client capabilities and notification handlers) to the client itself. + +The new `serverDiagnostics` extension makes the client receive diagnostics from the server, and show them via the CodeMirror linter. + +The new `languageServerExtensions` function provides an extension bundle interface, that works for client extensions as well as editor extensions. + +`LSPClient` now has a `plugin` method for conveniently creating an editor extension. `LSPPlugin.create` is deprecated in favor of this method. + +## 6.0.1 (2025-08-05) + +### Bug fixes + +Fix a bug that prevented reuse of completions when adding to the completed identifier. + +Properly render plain strings included in arrays for a hover tooltip's `content` data as Markdown content. + +## 6.0.0 (2025-07-02) + +### Breaking changes + +First numbered release. diff --git a/packages/lsp-client/LICENSE b/packages/lsp-client/LICENSE new file mode 100644 index 000000000..75a4b6348 --- /dev/null +++ b/packages/lsp-client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (C) 2025 by Marijn Haverbeke and others + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/lsp-client/README.md b/packages/lsp-client/README.md new file mode 100644 index 000000000..5831c8b4a --- /dev/null +++ b/packages/lsp-client/README.md @@ -0,0 +1,95 @@ +# @codemirror/lsp-client [![NPM version](https://img.shields.io/npm/v/@codemirror/lsp-client.svg)](https://www.npmjs.org/package/@codemirror/lsp-client) + +[ [**WEBSITE**](https://codemirror.net/) | [**DOCS**](https://codemirror.net/docs/ref/#lsp-client) | [**ISSUES**](https://code.haverbeke.berlin/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/v6/) | [**CHANGELOG**](https://code.haverbeke.berlin/codemirror/lsp-client/src/branch/main/CHANGELOG.md) ] + +This package implements a language server protocol (LSP) client for +the [CodeMirror](https://codemirror.net/) code editor. + +The [project page](https://codemirror.net/) has more information, a +number of [examples](https://codemirror.net/examples/) and the +[documentation](https://codemirror.net/docs/ref/#lsp-client). + +This code is released under an +[MIT license](https://code.haverbeke.berlin/codemirror/lsp-client/tree/main/LICENSE). + +We aim to be an inclusive, welcoming community. To make that explicit, +we have a [code of +conduct](http://contributor-covenant.org/version/1/1/0/) that applies +to communication around the project. + +## Usage + +There are various ways to run a language server and connect it to a +web page. You can run it on the server and proxy it through a web +socket, or, if it is written in JavaScript or can be compiled to WASM, +run it directly in the client. The @codemirror/lsp-client package +talks to the server through a ([`Transport`](#lsp-client.Transport)) +object, which exposes a small interface for sending and receiving JSON +messages. + +Responsibility for how to actually talk to the server, how to connect +and to handle disconnects are left to the code that implements the +transport. + +## Multiple servers per editor (Acode fork) + +This in-tree Acode fork allows several independent clients to attach to the +same `EditorView`. For example, TypeScript can be the primary provider while +Tailwind CSS contributes completions, hover information, and diagnostics. + +```javascript +new EditorView({ + extensions: [ + typescriptClient.plugin(uri, "typescriptreact", {priority: 100}), + tailwindClient.plugin(uri, "typescriptreact", { + priority: 50, + features: {formatting: false, rename: false} + }) + ] +}) +``` + +Bindings are ordered by descending `priority`. Completion and hover results +are combined across eligible providers. Features that need one owner, such as +formatting, rename, navigation, and signature help, use the first capable +eligible binding. A feature is enabled unless its binding sets that feature to +`false`. + +Document edits are recorded once per editor. Each client keeps an independent +sync revision, and its pending `ChangeSet` is composed lazily only when that +client synchronizes. Thus, attaching idle supplemental clients does not add a +per-client loop to the typing path. + +This example uses a crude transport that doesn't handle errors at all. + + +```javascript +import {Transport, LSPClient, languageServerExtensions} from "@codemirror/lsp-client" +import {basicSetup, EditorView} from "codemirror" +import {typescriptLanguage} from "@codemirror/lang-javascript" + +function simpleWebSocketTransport(uri: string): Promise { + let handlers: ((value: string) => void)[] = [] + let sock = new WebSocket(uri) + sock.onmessage = e => { for (let h of handlers) h(e.data.toString()) } + return new Promise(resolve => { + sock.onopen = () => resolve({ + send(message: string) { sock.send(message) }, + subscribe(handler: (value: string) => void) { handlers.push(handler) }, + unsubscribe(handler: (value: string) => void) { handlers = handlers.filter(h => h != handler) } + }) + }) +} + +let transport = await simpleWebSocketTransport("ws://host:port") +let client = new LSPClient({extensions: languageServerExtensions()}).connect(transport) + +new EditorView({ + extensions: [ + basicSetup, + typescriptLanguage, + client.plugin("file:///some/file.ts"), + ], + parent: document.body +}) +``` diff --git a/packages/lsp-client/UPSTREAM.md b/packages/lsp-client/UPSTREAM.md new file mode 100644 index 000000000..396748243 --- /dev/null +++ b/packages/lsp-client/UPSTREAM.md @@ -0,0 +1,25 @@ +# Upstream + +- Repository: https://code.haverbeke.berlin/codemirror/lsp-client +- Imported package: `@codemirror/lsp-client@6.2.5` +- License: MIT; the original notice is preserved in `LICENSE` +- Imported on: 2026-08-04 + +This directory is an in-tree Acode fork. Its primary change is support for +attaching multiple independent LSP clients to one CodeMirror `EditorView`. + +Keep upstream imports and Acode changes in separate commits when practical. + +To sync a release: + +1. Download or clone the new upstream version into a temporary directory. +2. Diff its `src`, `test`, `README.md`, `CHANGELOG.md`, `LICENSE`, and + `package.json` against this directory. +3. Apply upstream-only changes first, keeping this package's local metadata and + the multi-client changes described in the Acode changelog. +4. Run Acode's typecheck, `tests/unit/lspMultiClient.test.js`, full unit suite, + and production build. + +The local implementation is deliberately confined to this package and Acode's +`src/cm/lsp` integration, so an upstream comparison does not require searching +generated bundles or patched files under `node_modules`. diff --git a/packages/lsp-client/package.json b/packages/lsp-client/package.json new file mode 100644 index 000000000..a8781a8be --- /dev/null +++ b/packages/lsp-client/package.json @@ -0,0 +1,31 @@ +{ + "name": "@codemirror/lsp-client", + "version": "6.2.5-acode.1", + "description": "Acode's vendored CodeMirror language server protocol client with multi-server support", + "private": true, + "keywords": [ + "codemirror", + "lsp" + ], + "license": "MIT", + "author": "Marijn Haverbeke ", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "marked": "^15.0.12", + "@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", + "vscode-languageserver-protocol": "^3.17.5" + } +} diff --git a/packages/lsp-client/src/README.md b/packages/lsp-client/src/README.md new file mode 100644 index 000000000..e896d606a --- /dev/null +++ b/packages/lsp-client/src/README.md @@ -0,0 +1,67 @@ +### Client + +@LSPClient + +@LSPClientConfig + +@LSPClientExtension + +@Transport + +@LSPPlugin + +@WorkspaceMapping + +### Workspaces + +@Workspace + +@WorkspaceFile + +### Extensions + +@languageServerExtensions + +@languageServerSupport + +@serverCompletion + +@serverCompletionSource + +@serverDiagnostics + +@hoverTooltips + +@formatDocument + +@formatKeymap + +@renameSymbol + +@renameKeymap + +@signatureHelp + +@showSignatureHelp + +@nextSignature + +@prevSignature + +@signatureKeymap + +@jumpToDefinition + +@jumpToDeclaration + +@jumpToTypeDefinition + +@jumpToImplementation + +@jumpToDefinitionKeymap + +@findReferences + +@closeReferencePanel + +@findReferencesKeymap diff --git a/packages/lsp-client/src/client.ts b/packages/lsp-client/src/client.ts new file mode 100644 index 000000000..0892bba03 --- /dev/null +++ b/packages/lsp-client/src/client.ts @@ -0,0 +1,512 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {showDialog} from "@codemirror/view" +import {ChangeSet, ChangeDesc, MapMode, Text, Extension} from "@codemirror/state" +import {Language} from "@codemirror/language" +import {LSPPlugin, LSPPluginOptions, lspPluginBinding} from "./plugin" +import {toPosition, fromPosition} from "./pos" +import {Workspace, WorkspaceFile, DefaultWorkspace} from "./workspace" +import {lspTheme} from "./theme" + +class Request { + declare resolve: (result: Result) => void + declare reject: (error: any) => void + promise: Promise + + constructor( + readonly id: number, + readonly params: any, + readonly timeout: ReturnType + ) { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve + this.reject = reject + }) + } +} + +const clientCapabilities: lsp.ClientCapabilities = { + general: { + markdown: { + parser: "marked", + }, + }, + textDocument: { + completion: { + completionItem: { + snippetSupport: true, + documentationFormat: ["markdown", "plaintext"], + insertReplaceSupport: false, + }, + completionList: { + itemDefaults: ["commitCharacters", "editRange", "insertTextFormat"] + }, + completionItemKind: {valueSet: []}, + contextSupport: true, + }, + hover: { + contentFormat: ["markdown", "plaintext"] + }, + formatting: {}, + rename: {}, + signatureHelp: { + contextSupport: true, + signatureInformation: { + documentationFormat: ["markdown", "plaintext"], + parameterInformation: {labelOffsetSupport: true}, + activeParameterSupport: true, + }, + }, + definition: {}, + declaration: {}, + implementation: {}, + typeDefinition: {}, + references: {}, + diagnostic: {}, + }, + window: { + showMessage: {} + } +} + +/// A workspace mapping is used to track changes made to open +/// documents, so that positions returned by a request can be +/// interpreted in terms of the current, potentially changed document. +export class WorkspaceMapping { + /// @internal + mappings: Map = new Map + private startDocs: Map = new Map + + /// @internal + constructor(private client: LSPClient) { + for (let file of client.workspace.files) { + this.mappings.set(file.uri, ChangeSet.empty(file.doc.length)) + this.startDocs.set(file.uri, file.doc) + } + } + + /// @internal + addChanges(uri: string, changes: ChangeDesc) { + let known = this.mappings.get(uri) + if (known) this.mappings.set(uri, known.composeDesc(changes)) + } + + /// Get the changes made to the document with the given URI since + /// the mapping was created. Returns null for documents that aren't + /// open. + getMapping(uri: string) { + let known = this.mappings.get(uri) + if (!known) return null + let file = this.client.workspace.getFile(uri), view = file?.getView(), plugin = view && LSPPlugin.get(view, this.client) + return plugin ? known.composeDesc(plugin.unsyncedChanges) : known + } + + /// Map a position in the given file forward to the current document state. + mapPos(uri: string, pos: number, assoc?: number): number + mapPos(uri: string, pos: number, assoc: number, mode: MapMode): number | null + mapPos(uri: string, pos: number, assoc = -1, mode: MapMode = MapMode.Simple): number | null { + let changes = this.getMapping(uri) + return changes ? changes.mapPos(pos, assoc, mode) : pos + } + + /// Convert an LSP-style position referring to a document at the + /// time the mapping was created to an offset in the current document. + mapPosition(uri: string, pos: lsp.Position, assoc?: number): number + mapPosition(uri: string, pos: lsp.Position, assoc: number, mode: MapMode): number | null + mapPosition(uri: string, pos: lsp.Position, assoc = -1, mode: MapMode = MapMode.Simple): number | null { + let start = this.startDocs.get(uri) + if (!start) throw new Error("Cannot map from a file that's not in the workspace") + let off = fromPosition(start, pos) + let changes = this.getMapping(uri) + return changes ? changes.mapPos(off, assoc, mode) : off + } + + /// Disconnect this mapping from the client so that it will no + /// longer be notified of new changes. You must make sure to call + /// this on every mapping you create, except when you use + /// [`withMapping`](#lsp-client.LSPClient.withMapping), which will + /// automatically schedule a disconnect when the given promise + /// resolves or aborts. + destroy() { + this.client.activeMappings = this.client.activeMappings.filter(m => m != this) + } +} + +/// An object of this type should be used to wrap whatever transport +/// layer you use to talk to your language server. Messages should +/// contain only the JSON messages, no LSP headers. +export type Transport = { + /// Send a message to the server. Should throw if the connection is + /// broken somehow. + send(message: string): void + /// Register a handler for messages coming from the server. + subscribe(handler: (value: string) => void): void + /// Unregister a handler registered with `subscribe`. + unsubscribe(handler: (value: string) => void): void +} + +const defaultNotificationHandlers: {[method: string]: (client: LSPClient, params: any) => void} = { + "window/logMessage": (client, params: lsp.LogMessageParams) => { + if (params.type == 1) console.error("[lsp] " + params.message) + else if (params.type == 2) console.warn("[lsp] " + params.message) + }, + "window/showMessage": (client, params: lsp.ShowMessageParams) => { + if (params.type > 3 /* Info */) return + let view + for (let f of client.workspace.files) if (view = f.getView()) break + if (view) showDialog(view, { + label: params.message, + class: "cm-lsp-message cm-lsp-message-" + (params.type == 1 ? "error" : params.type == 2 ? "warning" : "info"), + top: true + }) + } +} + +/// Configuration options that can be passed to the LSP client. +export type LSPClientConfig = { + /// The project root URI passed to the server, when necessary. + rootUri?: string + /// An optional function to create a + /// [workspace](#lsp-client.Workspace) object for the client to use. + /// When not given, this will default to a simple workspace that + /// only opens files that have an active editor, and only allows one + /// editor per file. + workspace?: (client: LSPClient) => Workspace + /// The amount of milliseconds after which requests are + /// automatically timed out. Defaults to 3000. + timeout?: number + /// LSP servers can send Markdown code, which the client must render + /// and display as HTML. Markdown can contain arbitrary HTML and is + /// thus a potential channel for cross-site scripting attacks, if + /// someone is able to compromise your LSP server or your connection + /// to it. You can pass an HTML sanitizer here to strip out + /// suspicious HTML structure. + sanitizeHTML?: (html: string) => string + /// By default, the Markdown renderer will only be able to highlght + /// code embedded in the Markdown text when its language tag matches + /// the name of the language used by the editor. You can provide a + /// function here that returns a CodeMirror language object for a + /// given language tag to support more languages. + highlightLanguage?: (name: string) => Language | null + /// By default, the client will only handle the server notifications + /// `window/logMessage` (logging warnings and errors to the console) + /// and `window/showMessage`. You can pass additional handlers here. + /// They will be tried before the built-in handlers, and override + /// those when they return true. + notificationHandlers?: {[method: string]: (client: LSPClient, params: any) => boolean} + /// When no handler is found for a notification, it will be passed + /// to this function, if given. + unhandledNotification?: (client: LSPClient, method: string, params: any) => void + /// Provide a set of extensions, which may be plain CodeMirror + /// extensions, or objects containing additional client capabilities + /// or notification handlers. Any CodeMirror extensions provided + /// here will be included in the extension returned by + /// [`LSPPlugin.create`](#lsp-client.LSPPlugin^create). + extensions?: readonly (Extension | LSPClientExtension)[] +} + +/// Objects of this type can be included in the +/// [`extensions`](#lsp-client.LSPClientConfig.extensions) option to +/// `LSPClient` to modularly configure client capabilities or +/// notification handlers. +export type LSPClientExtension = { + /// Extra [client + /// capabilities](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#clientCapabilities) + /// to send to the server when initializing. The object provided + /// here will be merged with the capabilities the client provides by + /// default. + clientCapabilities?: Record, + /// Additional [notification + /// handlers](#lsp-client.LSPClientConfig.notificationHandlers). + /// These will be tried after notification handlers defined directly + /// in the config object, and then in order of appearance in the + /// [`extensions`](#lsp-client.LSPClientConfig.extensions) array. + notificationHandlers?: {[method: string]: (client: LSPClient, params: any) => boolean}, + /// An optional CodeMirror extension to include. + editorExtension?: Extension +} + +/// An LSP client manages a connection to a language server. It should +/// be explicitly [connected](#lsp-client.LSPClient.connect) before +/// use. +export class LSPClient { + /// @internal + transport: Transport | null = null + /// The client's [workspace](#lsp-client.Workspace). + workspace: Workspace + private nextReqID = 0 + private requests: Request[] = [] + /// @internal + activeMappings: WorkspaceMapping[] = [] + /// The capabilities advertised by the server. Will be null when not + /// connected or initialized. + serverCapabilities: lsp.ServerCapabilities | null = null + private supportSync = -1 + /// A promise that resolves once the client connection is initialized. Will be + /// replaced by a new promise object when you call `disconnect`. + initializing: Promise + declare private init: {resolve: (value: null) => void, reject: (err: any) => void} + private timeout: number + /// @internal + extensions: Extension[] = [] + + /// Create a client object. + constructor( + /// @internal + readonly config: LSPClientConfig = {} + ) { + this.receiveMessage = this.receiveMessage.bind(this) + this.initializing = new Promise((resolve, reject) => this.init = {resolve, reject}) + this.timeout = config.timeout ?? 3000 + this.workspace = config.workspace ? config.workspace(this) : new DefaultWorkspace(this) + + if (config.extensions) for (let ext of config.extensions) { + if (Array.isArray(ext) || (ext as any).extension) this.extensions.push(ext as Extension) + else if ((ext as LSPClientExtension).editorExtension) this.extensions.push((ext as LSPClientExtension).editorExtension!) + } + } + + /// Whether this client is connected (has a transport). + get connected() { return !!this.transport } + + /// Connect this client to a server over the given transport. Will + /// immediately start the initialization exchange with the server, + /// and resolve `this.initializing` (which it also returns) when + /// successful. + connect(transport: Transport) { + if (this.transport) this.transport.unsubscribe(this.receiveMessage) + this.transport = transport + transport.subscribe(this.receiveMessage) + let capabilities = clientCapabilities + if (this.config.extensions) for (let ext of this.config.extensions) { + let {clientCapabilities} = ext as LSPClientExtension + if (clientCapabilities) capabilities = mergeCapabilities(capabilities, clientCapabilities) + } + this.requestInner("initialize", { + processId: null, + clientInfo: {name: "@codemirror/lsp-client"}, + rootUri: this.config.rootUri || null, + capabilities + }).promise.then(resp => { + this.serverCapabilities = resp.capabilities + let sync = resp.capabilities.textDocumentSync + this.supportSync = sync == null ? 0 : typeof sync == "number" ? sync : sync.change ?? 0 + transport.send(JSON.stringify({jsonrpc: "2.0", method: "initialized", params: {}})) + this.init.resolve(null) + }, this.init.reject) + this.workspace.connected() + return this + } + + /// Disconnect the client from the server. + disconnect() { + if (this.transport) this.transport.unsubscribe(this.receiveMessage) + this.serverCapabilities = null + this.initializing = new Promise((resolve, reject) => this.init = {resolve, reject}) + this.workspace.disconnected() + } + + /// Create a plugin for this client, to add to an editor + /// configuration. This extension is necessary to use LSP-related + /// functionality exported by this package. The returned extension + /// will include the editor + /// extensions included in this client's + /// [configuration](#lsp-client.LSPClientConfig.extensions). + /// + /// Creating an editor with this plugin will cause + /// [`openFile`](#lsp-client.Workspace.openFile) to be called on the + /// workspace. + /// + /// By default, the language ID given to the server for this file is + /// derived from the editor's language configuration via + /// [`Language.name`](#language.Language.name). You can pass in + /// a specific ID as a third parameter. + plugin(fileURI: string, languageID?: string, options?: LSPPluginOptions): Extension { + return [ + lspPluginBinding({client: this, uri: fileURI, languageID, options}), + lspTheme, + this.extensions + ] + } + + /// Send a `textDocument/didOpen` notification to the server. + didOpen(file: WorkspaceFile) { + this.notification("textDocument/didOpen", { + textDocument: { + uri: file.uri, + languageId: file.languageId, + text: file.doc.toString(), + version: file.version + } + }) + } + + /// Send a `textDocument/didClose` notification to the server. + didClose(uri: string) { + this.notification("textDocument/didClose", {textDocument: {uri}}) + } + + private receiveMessage(msg: string) { + const value = JSON.parse(msg) as lsp.ResponseMessage | lsp.NotificationMessage | lsp.RequestMessage + if ("id" in value && !("method" in value)) { + let index = this.requests.findIndex(r => r.id == value.id) + if (index < 0) { + console.warn(`[lsp] Received a response for non-existent request ${value.id}`) + } else { + let req = this.requests[index] + clearTimeout(req.timeout) + this.requests.splice(index, 1) + if (value.error) req.reject(value.error) + else req.resolve(value.result) + } + } else if (!("id" in value)) { + let handler = this.config.notificationHandlers?.[value.method] + if (handler && handler(this, value.params)) return + if (this.config.extensions) for (let ext of this.config.extensions) { + let {notificationHandlers} = ext as LSPClientExtension + let handler = notificationHandlers?.[value.method] + if (handler && handler(this, value.params)) return + } + let deflt = defaultNotificationHandlers[value.method] + if (deflt) deflt(this, value.params) + else if (this.config.unhandledNotification) this.config.unhandledNotification(this, value.method, value.params) + } else { + let resp: lsp.ResponseMessage = { + jsonrpc: "2.0", + id: value.id, + error: {code: -32601 /* MethodNotFound */, message: "Method not implemented"} + } + this.transport!.send(JSON.stringify(resp)) + } + } + + /// Make a request to the server. Returns a promise that resolves to + /// the response or rejects with a failure message. You'll probably + /// want to use types from the `vscode-languageserver-protocol` + /// package for the type parameters. + /// + /// The caller is responsible for + /// [synchronizing](#lsp-client.LSPClient.sync) state before the + /// request and correctly handling state drift caused by local + /// changes that happend during the request. + request(method: string, params: Params): Promise { + if (!this.transport) return Promise.reject(new Error("Client not connected")) + return this.initializing.then(() => this.requestInner(method, params).promise) + } + + private requestInner( + method: string, + params: Params, + mapped = false + ): Request { + let id = ++this.nextReqID, data: lsp.RequestMessage = { + jsonrpc: "2.0", + id, + method, + params: params as any + } + let req = new Request(id, params, setTimeout(() => this.timeoutRequest(req), this.timeout)) + this.requests.push(req) + try { this.transport!.send(JSON.stringify(data)) } + catch(e) { req.reject(e) } + return req + } + + /// Send a notification to the server. + notification(method: string, params: Params) { + if (!this.transport) return + this.initializing.then(() => { + let data: lsp.NotificationMessage = { + jsonrpc: "2.0", + method, + params: params as any + } + this.transport!.send(JSON.stringify(data)) + }) + } + + /// Cancel the in-progress request with the given parameter value + /// (which is compared by identity). + cancelRequest(params: any) { + let found = this.requests.find(r => r.params === params) + if (found) this.notification("$/cancelRequest", {id: found.id}) + } + + /// @internal + hasCapability(name: keyof lsp.ServerCapabilities) { + return this.serverCapabilities ? !!this.serverCapabilities[name] : null + } + + /// Create a [workspace mapping](#lsp-client.WorkspaceMapping) that + /// tracks changes to files in this client's workspace, relative to + /// the moment where it was created. Make sure you call + /// [`destroy`](#lsp-client.WorkspaceMapping.destroy) on the mapping + /// when you're done with it. + workspaceMapping() { + let mapping = new WorkspaceMapping(this) + this.activeMappings.push(mapping) + return mapping + } + + /// Run the given promise with a [workspace + /// mapping](#lsp-client.WorkspaceMapping) active. Automatically + /// release the mapping when the promise resolves or rejects. + withMapping(f: (mapping: WorkspaceMapping) => Promise): Promise { + let mapping = this.workspaceMapping() + return f(mapping).finally(() => mapping.destroy()) + } + + /// Push any [pending changes](#lsp-client.Workspace.syncFiles) in + /// the open files to the server. You'll want to call this before + /// most types of requests, to make sure the server isn't working + /// with outdated information. + sync() { + for (let {file, changes, prevDoc} of this.workspace.syncFiles()) { + for (let mapping of this.activeMappings) + mapping.addChanges(file.uri, changes) + if (this.supportSync) this.notification("textDocument/didChange", { + textDocument: {uri: file.uri, version: file.version}, + contentChanges: contentChangesFor(file, prevDoc, changes, this.supportSync == 2 /* Incremental */) + }) + } + } + + private timeoutRequest(req: Request) { + let index = this.requests.indexOf(req) + if (index > -1) { + req.reject(new Error("Request timed out")) + this.requests.splice(index, 1) + } + } +} + +const enum Sync { AlwaysIfSmaller = 1024 } + +function contentChangesFor( + file: WorkspaceFile, + startDoc: Text, + changes: ChangeSet, + supportInc: boolean +): lsp.TextDocumentContentChangeEvent[] { + if (!supportInc || file.doc.length < Sync.AlwaysIfSmaller) + return [{text: file.doc.toString()}] + let events: lsp.TextDocumentContentChangeEvent[] = [] + changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + events.push({ + range: {start: toPosition(startDoc, fromA), end: toPosition(startDoc, toA)}, + text: inserted.toString() + }) + }) + return events.reverse() +} + +function mergeCapabilities(base: any, add?: any) { + if (add == null) return base + if (typeof base != "object" || typeof add != "object") return add + let result: Record = {} + let baseProps = Object.keys(base), addProps = Object.keys(add) + for (let prop of baseProps) + result[prop] = addProps.indexOf(prop) > -1 ? mergeCapabilities(base[prop], add[prop]) : base[prop] + for (let prop of addProps) + if (baseProps.indexOf(prop) < 0) result[prop] = add[prop] + return result +} diff --git a/packages/lsp-client/src/completion.ts b/packages/lsp-client/src/completion.ts new file mode 100644 index 000000000..1a40af582 --- /dev/null +++ b/packages/lsp-client/src/completion.ts @@ -0,0 +1,287 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {EditorState, Extension, Facet, ChangeDesc, ChangeSpec} from "@codemirror/state" +import {EditorView} from "@codemirror/view" +import {CompletionSource, Completion, CompletionResult, CompletionContext, + snippet, autocompletion, insertCompletionText} from "@codemirror/autocomplete" +import {LSPPlugin} from "./plugin" +import {fromPositionChecked} from "./pos" + +// Convert from LSP's snippet syntax to @codemirror/autocompletion's +// one. Remove backslashes before `$}]`, which don't support backslash +// escapes in CodeMirror, and expand `$1` to `${1}`. +function lspToSnippet(text: string): string { + return text.replace(/\\([$}\\])|\$(\d+)/g, (_m, esc, field) => esc || `\${${field}}`) +} + +/// Register the [language server completion +/// source](#lsp-client.serverCompletionSource) as an autocompletion +/// source. +let defaultServerCompletion: Extension | null = null + +export function serverCompletion(config: { + /// By default, the completion source that asks the language server + /// for completions is added as a regular source, in addition to any + /// other sources. Set this to true to make it replace all + /// completion sources. + override?: boolean + /// Set a custom + /// [`validFor`](#autocomplete.CompletionResult.validFor) expression + /// to use in the completion results. By default, the library uses an + /// expression that accepts word characters, optionally prefixed by + /// any non-word prefixes found in the results. + validFor?: RegExp +} = {}): Extension { + if (!config.override && !config.validFor && defaultServerCompletion) + return defaultServerCompletion + let result: Extension[] + if (config.override) { + result = [autocompletion({override: [serverCompletionSource]})] + } else { + let data = [{autocomplete: serverCompletionSource}] + result = [autocompletion(), EditorState.languageData.of(() => data)] + } + if (config.validFor) result.push(completionConfig.of({validFor: config.validFor})) + if (!config.override && !config.validFor) defaultServerCompletion = result + return result +} + +const completionConfig = Facet.define<{validFor: RegExp}, {validFor: RegExp | null}>({ + combine: results => results.length ? results[0] : {validFor: null} +}) + +function getCompletions(plugin: LSPPlugin, pos: number, context: lsp.CompletionContext, abort?: CompletionContext) { + if (plugin.client.hasCapability("completionProvider") === false) return Promise.resolve(null) + plugin.client.sync() + let params: lsp.CompletionParams = { + position: plugin.toPosition(pos), + textDocument: {uri: plugin.uri}, + context + } + if (abort) abort.addEventListener("abort", () => plugin.client.cancelRequest(params)) + return plugin.client.request( + "textDocument/completion", params) +} + +// Look for non-alphanumeric prefixes in the completions, and return a +// regexp that matches them, to use in validFor +function prefixRegexp(items: readonly lsp.CompletionItem[]) { + let step = Math.ceil(items.length / 50), prefixes: string[] = [] + for (let i = 0; i < items.length; i += step) { + let item = items[i], text = item.textEdit?.newText || item.textEditText || item.insertText || item.label + if (!/^\w/.test(text)) { + let prefix = /^[^\w]*/.exec(text)![0] + if (prefixes.indexOf(prefix) < 0) prefixes.push(prefix) + } + } + if (!prefixes.length) return /^\w*$/ + return new RegExp("^(?:" + prefixes.map((RegExp as any).escape || (s => s.replace(/[^\w\s]/g, "\\$&"))).join("|") + ")?\\w*$") +} + +function shouldTriggerCompletion(plugin: LSPPlugin, character: string) : "identifier" | "triggerCharacter" | null { + let triggers = plugin.client.serverCapabilities?.completionProvider?.triggerCharacters + if (triggers && triggers.indexOf(character) > -1) return "triggerCharacter" + if (/[a-zA-Z_]/.test(character)) return "identifier" + return null +} + +type TextEdit = {from: number, to: number, text: string} + +type ExtraEdits = {index: number, edits: readonly TextEdit[], text: string} + +function resultMapper(changes: ChangeDesc | null, extraEdits: ExtraEdits[]) { + return (result: CompletionResult, newChanges: ChangeDesc): CompletionResult => { + changes = changes ? changes.composeDesc(newChanges) : newChanges + let options = result.options.slice() + for (let {index, edits, text} of extraEdits) + options[index] = {...options[index], apply: applyEdits(edits, text, changes)} + return { + ...result, + options, + map: resultMapper(changes, extraEdits) + } + } +} + +function applyEdits(edits: readonly TextEdit[], text: string, mapped: ChangeDesc | null) { + return (view: EditorView, completion: Completion, from: number, to: number) => { + let base = insertCompletionText(view.state, text, from, to) + let changes: ChangeSpec[] = [] + for (let {from, to, text} of edits) { + if (mapped) { + if (mapped.touchesRange(from, to)) continue + let len = to - from + from = mapped.mapPos(from, 1) + to = from + len + } + changes.push({from, to, insert: text}) + } + view.dispatch(base, {changes}) + } +} + +function completionForPlugin(plugin: LSPPlugin, context: CompletionContext): Promise | null { + let triggerChar = context.state.sliceDoc(context.pos - 1, context.pos) + let triggerReason = context.explicit ? "invoked" : shouldTriggerCompletion(plugin, triggerChar) + if (!triggerReason) return null + let completionContext: lsp.CompletionContext = triggerReason == "triggerCharacter" + ? {triggerKind: 2 /* TriggerCharacter */, triggerCharacter: triggerChar} + : {triggerKind: 1 /* Invoked */} + return getCompletions(plugin, context.pos, completionContext, context).then(result => { + if (!result) return null + if (Array.isArray(result)) result = {items: result} as lsp.CompletionList + let {from, to} = completionResultRange(context, result) + let defaultCommitChars = result.itemDefaults?.commitCharacters + let config = context.state.facet(completionConfig) + let extraEdits: ExtraEdits[] = [] + + return { + from, to, + options: result.items.map((item, i) => { + let text = item.textEdit?.newText || item.textEditText || item.insertText || item.label + let option: Completion = { + label: item.filterText || item.label, + displayLabel: item.label, + type: item.kind && kindToType[item.kind], + } + let insertTextFormat = item.insertTextFormat ?? result.itemDefaults?.insertTextFormat + if (item.commitCharacters && item.commitCharacters != defaultCommitChars) + option.commitCharacters = item.commitCharacters + if (item.detail) option.detail = item.detail + if (item.sortText) option.sortText = item.sortText + if (insertTextFormat == 2 /* Snippet */) { + option.apply = (view, c, from, to) => snippet(lspToSnippet(text))(view, c, from, to) + } else if (item.additionalTextEdits) { + let edits: TextEdit[] = [] + for (let edit of item.additionalTextEdits) { + let from = fromPositionChecked(context.state.doc, edit.range.start) + let to = fromPositionChecked(context.state.doc, edit.range.end) + if (from != null && to != null) edits.push({from, to, text: edit.newText}) + } + extraEdits.push({index: i, text, edits}) + option.apply = applyEdits(edits, text, null) + } else if (option.label != text) { + option.apply = text + } + if (item.documentation) option.info = () => renderDocInfo(plugin, item.documentation!) + return option + }), + commitCharacters: defaultCommitChars, + validFor: result.isIncomplete ? undefined : (config.validFor ?? prefixRegexp(result.items)), + map: extraEdits.length ? resultMapper(null, extraEdits) : undefined + } + }, err => { + if ("code" in err && (err as lsp.ResponseError).code == -32800 /* RequestCancelled */) + return null + throw err + }) +} + +function mappedOptionRange(option: Completion, source: CompletionResult, from: number, to: number): Completion { + const sourceFrom = source.from, sourceTo = source.to ?? sourceFrom + if (sourceFrom == from && sourceTo == to) return option + const apply = option.apply ?? option.label + return { + ...option, + apply(view, completion) { + if (typeof apply == "function") apply(view, completion, sourceFrom, sourceTo) + else view.dispatch(insertCompletionText(view.state, apply, sourceFrom, sourceTo)) + } + } +} + +function mergeCompletionResults(results: readonly CompletionResult[]): CompletionResult | null { + if (!results.length) return null + if (results.length == 1) return results[0] + + let from = results[0].from, to = results[0].to ?? results[0].from + for (let i = 1; i < results.length; i++) { + from = Math.min(from, results[i].from) + to = Math.max(to, results[i].to ?? results[i].from) + } + + const options: Completion[] = [], seen = new Set() + for (const result of results) { + for (const raw of result.options) { + const option = result.commitCharacters && !raw.commitCharacters + ? {...raw, commitCharacters: result.commitCharacters} + : raw + // Providers often return identical keyword entries. Keep the first one, + // which follows binding priority, while preserving distinct details. + const key = `${option.label}\u0000${option.detail || ""}\u0000${option.type || ""}` + if (seen.has(key)) continue + seen.add(key) + options.push(mappedOptionRange(option, result, from, to)) + } + } + + const firstValidFor = results[0].validFor + const validFor = results.every(result => String(result.validFor) == String(firstValidFor)) + ? firstValidFor + : undefined + return {from, to, options, validFor} +} + +/// A completion source that requests completions from every eligible language +/// server attached to the editor and combines their results. Requests are +/// independent so one failing supplemental server cannot suppress the others. +export const serverCompletionSource: CompletionSource = context => { + if (!context.view) return null + const plugins = LSPPlugin.getAll(context.view, "completion") + .filter(plugin => plugin.client.hasCapability("completionProvider") !== false) + if (!plugins.length) return null + const requests = plugins.map(plugin => completionForPlugin(plugin, context)) + .filter((request): request is Promise => !!request) + if (!requests.length) return null + if (requests.length == 1) return requests[0] + return Promise.allSettled(requests).then(settled => { + const results: CompletionResult[] = [] + for (const item of settled) { + if (item.status == "fulfilled") { + if (item.value) results.push(item.value) + } else if (!context.aborted) { + console.warn("[lsp] Completion provider failed", item.reason) + } + } + return mergeCompletionResults(results) + }) +} + +function completionResultRange(cx: CompletionContext, result: lsp.CompletionList): {from: number, to: number} { + if (!result.items.length) return {from: cx.pos, to: cx.pos} + let defaultRange = result.itemDefaults?.editRange, item0 = result.items[0] + let range = defaultRange ? ("insert" in defaultRange ? defaultRange.insert : defaultRange) + : item0.textEdit ? ("range" in item0.textEdit ? item0.textEdit.range : item0.textEdit.insert) + : null + if (!range) return cx.state.wordAt(cx.pos) || {from: cx.pos, to: cx.pos} + let line = cx.state.doc.lineAt(cx.pos) + return {from: line.from + range.start.character, to: line.from + range.end.character} +} + +function renderDocInfo(plugin: LSPPlugin, doc: string | lsp.MarkupContent) { + let elt = document.createElement("div") + elt.className = "cm-lsp-documentation cm-lsp-completion-documentation" + elt.innerHTML = plugin.docToHTML(doc) + return elt +} + +const kindToType: {[kind: number]: string} = { + 1: "text", // Text + 2: "method", // Method + 3: "function", // Function + 4: "class", // Constructor + 5: "property", // Field + 6: "variable", // Variable + 7: "class", // Class + 8: "interface", // Interface + 9: "namespace", // Module + 10: "property", // Property + 11: "keyword", // Unit + 12: "constant", // Value + 13: "constant", // Enum + 14: "keyword", // Keyword + 16: "constant", // Color + 20: "constant", // EnumMember + 21: "constant", // Constant + 22: "class", // Struct + 25: "type" // TypeParameter +} diff --git a/packages/lsp-client/src/definition.ts b/packages/lsp-client/src/definition.ts new file mode 100644 index 000000000..0fda59e0c --- /dev/null +++ b/packages/lsp-client/src/definition.ts @@ -0,0 +1,85 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {EditorView, Command, KeyBinding} from "@codemirror/view" +import {LSPPlugin} from "./plugin" + +function getDefinition(plugin: LSPPlugin, pos: number) { + return plugin.client.request("textDocument/definition", { + textDocument: {uri: plugin.uri}, + position: plugin.toPosition(pos) + }) +} + +function getDeclaration(plugin: LSPPlugin, pos: number) { + return plugin.client.request("textDocument/declaration", { + textDocument: {uri: plugin.uri}, + position: plugin.toPosition(pos) + }) +} + +function getTypeDefinition(plugin: LSPPlugin, pos: number) { + return plugin.client.request("textDocument/typeDefinition", { + textDocument: {uri: plugin.uri}, + position: plugin.toPosition(pos) + }) +} + +function getImplementation(plugin: LSPPlugin, pos: number) { + return plugin.client.request("textDocument/implementation", { + textDocument: {uri: plugin.uri}, + position: plugin.toPosition(pos) + }) +} + +function jumpToOrigin(view: EditorView, type: {get: typeof getDefinition, capability: keyof lsp.ServerCapabilities}): boolean { + const feature = type.capability == "declarationProvider" ? "declaration" + : type.capability == "typeDefinitionProvider" ? "typeDefinition" + : type.capability == "implementationProvider" ? "implementation" + : "definition" + const plugin = LSPPlugin.getAll(view, feature) + .find(plugin => plugin.client.hasCapability(type.capability) !== false) + if (!plugin || plugin.client.hasCapability(type.capability) === false) return false + plugin.client.sync() + plugin.client.withMapping(mapping => type.get(plugin, view.state.selection.main.head).then(response => { + let loc = Array.isArray(response) ? response[0] : response + if (!loc) return + + return (loc.uri == plugin.uri ? Promise.resolve(view) : plugin.client.workspace.displayFile(loc.uri)).then(target => { + if (!target) return + let pos = mapping.getMapping(loc.uri) ? mapping.mapPosition(loc.uri, loc.range.start) + : plugin.fromPosition(loc.range.start, target.state.doc) + target.dispatch({selection: {anchor: pos}, scrollIntoView: true, userEvent: "select.definition"}) + }) + }, error => plugin.reportError("Find definition failed", error))) + return true +} + +/// Jump to the definition of the symbol at the cursor. To support +/// cross-file jumps, you'll need to implement +/// [`Workspace.displayFile`](#lsp-client.Workspace.displayFile). +export const jumpToDefinition: Command = view => jumpToOrigin(view, { + get: getDefinition, + capability: "definitionProvider" +}) + +/// Jump to the declaration of the symbol at the cursor. +export const jumpToDeclaration: Command = view => jumpToOrigin(view, { + get: getDeclaration, + capability: "declarationProvider" +}) + +/// Jump to the type definition of the symbol at the cursor. +export const jumpToTypeDefinition: Command = view => jumpToOrigin(view, { + get: getTypeDefinition, + capability: "typeDefinitionProvider" +}) + +/// Jump to the implementation of the symbol at the cursor. +export const jumpToImplementation: Command = view => jumpToOrigin(view, { + get: getImplementation, + capability: "implementationProvider" +}) + +/// Binds F12 to [`jumpToDefinition`](#lsp-client.jumpToDefinition). +export const jumpToDefinitionKeymap: readonly KeyBinding[] = [ + {key: "F12", run: jumpToDefinition, preventDefault: true}, +] diff --git a/packages/lsp-client/src/diagnostics.ts b/packages/lsp-client/src/diagnostics.ts new file mode 100644 index 000000000..4f5e8021a --- /dev/null +++ b/packages/lsp-client/src/diagnostics.ts @@ -0,0 +1,60 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {forEachDiagnostic, setDiagnostics} from "@codemirror/lint" +import type {Diagnostic} from "@codemirror/lint" +import {ViewPlugin, ViewUpdate} from "@codemirror/view" +import {LSPPlugin} from "./plugin" +import type {LSPClient, LSPClientExtension} from "./client" + +function toSeverity(sev: lsp.DiagnosticSeverity): Diagnostic["severity"] { + return sev == 1 ? "error" : sev == 2 ? "warning" : sev == 3 ? "info" : "hint" +} + +const diagnosticClient = Symbol("lspDiagnosticClient") +type ClientDiagnostic = Diagnostic & {[diagnosticClient]?: LSPClient} + +const autoSync = ViewPlugin.fromClass(class { + pending: ReturnType | null = null + update(update: ViewUpdate) { + if (update.docChanged) { + if (this.pending != null) clearTimeout(this.pending) + this.pending = setTimeout(() => { + this.pending = null + LSPPlugin.syncAll(update.view) + }, 500) + } + } + destroy() { + if (this.pending != null) clearTimeout(this.pending) + } +}) + +export function serverDiagnostics(): LSPClientExtension { + return { + clientCapabilities: {textDocument: {publishDiagnostics: {versionSupport: true}}}, + notificationHandlers: { + "textDocument/publishDiagnostics": (client, params: lsp.PublishDiagnosticsParams) => { + let file = client.workspace.getFile(params.uri) + if (!file || params.version != null && params.version != file.version) return false + const view = file.getView(), plugin = view && LSPPlugin.get(view, client) + if (!view || !plugin) return false + // The lint package already maps stored diagnostics through editor + // changes. Preserve those current positions for every other provider + // and replace only this client's contribution. + const diagnostics: ClientDiagnostic[] = [] + forEachDiagnostic(view.state, (item: ClientDiagnostic, from, to) => { + if (item[diagnosticClient] !== client) diagnostics.push({...item, from, to}) + }) + diagnostics.push(...params.diagnostics.map(item => ({ + from: plugin.unsyncedChanges.mapPos(plugin.fromPosition(item.range.start, plugin.syncedDoc)), + to: plugin.unsyncedChanges.mapPos(plugin.fromPosition(item.range.end, plugin.syncedDoc)), + severity: toSeverity(item.severity ?? 1), + message: item.message, + [diagnosticClient]: client + }))) + view.dispatch(setDiagnostics(view.state, diagnostics)) + return true + } + }, + editorExtension: autoSync + } +} diff --git a/packages/lsp-client/src/formatting.ts b/packages/lsp-client/src/formatting.ts new file mode 100644 index 000000000..82fea1640 --- /dev/null +++ b/packages/lsp-client/src/formatting.ts @@ -0,0 +1,53 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {Command, KeyBinding} from "@codemirror/view" +import {ChangeSpec} from "@codemirror/state" +import {indentUnit, getIndentUnit} from "@codemirror/language" +import {LSPPlugin} from "./plugin" + +function getFormatting(plugin: LSPPlugin, options: lsp.FormattingOptions) { + return plugin.client.request("textDocument/formatting", { + options, + textDocument: {uri: plugin.uri}, + }) +} + +/// This command asks the language server to reformat the document, +/// and then applies the changes it returns. +export const formatDocument: Command = view => { + const plugin = LSPPlugin.getAll(view, "formatting") + .find(plugin => plugin.client.hasCapability("documentFormattingProvider") !== false) + if (!plugin) return false + plugin.client.sync() + plugin.client.withMapping(mapping => getFormatting(plugin, { + tabSize: getIndentUnit(view.state), + insertSpaces: view.state.facet(indentUnit).indexOf("\t") < 0, + }).then(response => { + if (!response) return + let changed = mapping.getMapping(plugin.uri) + let changes: ChangeSpec[] = [] + for (let change of response) { + let from = mapping.mapPosition(plugin.uri, change.range.start) + let to = mapping.mapPosition(plugin.uri, change.range.end) + if (changed) { + // Don't try to apply the changes if code inside of any of them was touched + if (changed.touchesRange(from, to)) return + from = changed.mapPos(from, 1) + to = changed.mapPos(to, -1) + } + changes.push({from, to, insert: change.newText}) + } + view.dispatch({ + changes, + userEvent: "format" + }) + }, err => { + plugin.reportError("Formatting request failed", err) + })) + return true +} + +/// A keymap that binds Shift-Alt-f to +/// [`formatDocument`](#lsp-client.formatDocument). +export const formatKeymap: readonly KeyBinding[] = [ + {key: "Shift-Alt-f", run: formatDocument, preventDefault: true} +] diff --git a/packages/lsp-client/src/hover.ts b/packages/lsp-client/src/hover.ts new file mode 100644 index 000000000..516a2059d --- /dev/null +++ b/packages/lsp-client/src/hover.ts @@ -0,0 +1,94 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {EditorView, Tooltip, hoverTooltip} from "@codemirror/view" +import {Extension} from "@codemirror/state" +import {language as languageFacet, highlightingFor} from "@codemirror/language" +import {highlightCode} from "@lezer/highlight" +import {fromPosition} from "./pos" +import {escHTML} from "./text" +import {LSPPlugin} from "./plugin" + +/// Create an extension that queries the language server for hover +/// tooltips when the user hovers over the code with their pointer, +/// and displays a tooltip when the server provides one. +let defaultHoverTooltips: Extension | null = null + +export function hoverTooltips(config: {hoverTime?: number} = {}): Extension { + if (config.hoverTime == null && defaultHoverTooltips) return defaultHoverTooltips + const extension = hoverTooltip(lspTooltipSource, { + hideOn: tr => tr.docChanged, + hoverTime: config.hoverTime + }) + if (config.hoverTime == null) defaultHoverTooltips = extension + return extension +} + +function hoverRequest(plugin: LSPPlugin, pos: number) { + if (plugin.client.hasCapability("hoverProvider") === false) return Promise.resolve(null) + plugin.client.sync() + return plugin.client.request("textDocument/hover", { + position: plugin.toPosition(pos), + textDocument: {uri: plugin.uri}, + }) +} + +function lspTooltipSource(view: EditorView, pos: number): Promise { + const plugins = LSPPlugin.getAll(view, "hover") + .filter(plugin => plugin.client.hasCapability("hoverProvider") !== false) + if (!plugins.length) return Promise.resolve(null) + return Promise.allSettled(plugins.map(plugin => hoverRequest(plugin, pos))).then(settled => { + const results: {plugin: LSPPlugin, result: lsp.Hover}[] = [] + for (let i = 0; i < settled.length; i++) { + const item = settled[i] + if (item.status == "fulfilled" && item.value) results.push({plugin: plugins[i], result: item.value}) + else if (item.status == "rejected") console.warn("[lsp] Hover provider failed", item.reason) + } + if (!results.length) return null + let from = pos, to = pos + for (const {result} of results) if (result.range) { + from = Math.min(from, fromPosition(view.state.doc, result.range.start)) + to = Math.max(to, fromPosition(view.state.doc, result.range.end)) + } + return { + pos: from, + end: to, + create() { + let elt = document.createElement("div") + elt.className = "cm-lsp-hover-tooltip cm-lsp-documentation" + for (let i = 0; i < results.length; i++) { + if (i) elt.appendChild(document.createElement("hr")) + const section = elt.appendChild(document.createElement("div")) + section.innerHTML = renderTooltipContent(results[i].plugin, results[i].result.contents) + } + return {dom: elt} + }, + above: true + } + }) +} + +function renderTooltipContent( + plugin: LSPPlugin, + value: string | lsp.MarkupContent | lsp.MarkedString | lsp.MarkedString[] +) { + if (Array.isArray(value)) return value.map(m => renderCode(plugin, m)).join("
") + if (typeof value == "string" || typeof value == "object" && "language" in value) return renderCode(plugin, value) + return plugin.docToHTML(value) +} + +function renderCode(plugin: LSPPlugin, code: lsp.MarkedString) { + if (typeof code == "string") return plugin.docToHTML(code, "markdown") + let {language, value} = code + let lang = plugin.client.config.highlightLanguage && plugin.client.config.highlightLanguage(language || "") + if (!lang) { + let viewLang = plugin.view.state.facet(languageFacet) + if (viewLang && (!language || viewLang.name == language)) lang = viewLang + } + if (!lang) return escHTML(value) + let result = "" + highlightCode(value, lang.parser.parse(value), {style: tags => highlightingFor(plugin.view.state, tags)}, (text, cls) => { + result += cls ? `${escHTML(text)}` : escHTML(text) + }, () => { + result += "
" + }) + return result +} diff --git a/packages/lsp-client/src/index.ts b/packages/lsp-client/src/index.ts new file mode 100644 index 000000000..edaadd48f --- /dev/null +++ b/packages/lsp-client/src/index.ts @@ -0,0 +1,69 @@ +export {LSPClient, WorkspaceMapping} from "./client" +export type {Transport, LSPClientConfig, LSPClientExtension} from "./client" +export {LSPPlugin} from "./plugin" +export type {LSPFeature, LSPPluginOptions} from "./plugin" +export {Workspace} from "./workspace" +export type {WorkspaceFile} from "./workspace" +export {serverCompletion, serverCompletionSource} from "./completion" +export {hoverTooltips} from "./hover" +export {formatDocument, formatKeymap} from "./formatting" +export {renameSymbol, renameKeymap} from "./rename" +export {signatureHelp, nextSignature, prevSignature, showSignatureHelp, signatureKeymap} from "./signature" +export {jumpToDefinition, jumpToDeclaration, jumpToTypeDefinition, jumpToImplementation, jumpToDefinitionKeymap} from "./definition" +export {findReferences, closeReferencePanel, findReferencesKeymap} from "./references" +export {serverDiagnostics} from "./diagnostics" + +import type {Extension} from "@codemirror/state" +import {keymap} from "@codemirror/view" +import {LSPClient} from "./client" +import type {LSPClientExtension} from "./client" +import {LSPPlugin} from "./plugin" +import {serverCompletion} from "./completion" +import {hoverTooltips} from "./hover" +import {formatKeymap} from "./formatting" +import {renameKeymap} from "./rename" +import {signatureHelp} from "./signature" +import {jumpToDefinitionKeymap} from "./definition" +import {findReferencesKeymap} from "./references" +import {serverDiagnostics} from "./diagnostics" + +const languageServerKeymap = keymap.of([ + ...formatKeymap, + ...renameKeymap, + ...jumpToDefinitionKeymap, + ...findReferencesKeymap +]) + +/// Returns an extension that enables the [LSP +/// plugin](#lsp-client.LSPPlugin) as well as LSP based +/// autocompletion, hover tooltips, and signature help, along with the +/// keymaps for reformatting, renaming symbols, jumping to definition, +/// and finding references. +/// +/// This function is deprecated. Prefer to directly use +/// [`LSPPlugin.create`](#lsp-client.LSPPlugin^create) and either add +/// the extensions you need directly, or configure them in the client +/// via [`languageServerExtensions`](#lsp-client.languageServerExtensions). +export function languageServerSupport(client: LSPClient, uri: string, languageID?: string): Extension { + return [ + LSPPlugin.create(client, uri, languageID), + serverCompletion(), + hoverTooltips(), + languageServerKeymap, + signatureHelp(), + ] +} + +/// This function bundles all the extensions defined in this package, +/// in a way that can be passed to the +/// [`extensions`](#lsp-client.LSPClientConfig.extensions) option to +/// `LSPClient`. +export function languageServerExtensions(): readonly (Extension | LSPClientExtension)[] { + return [ + serverCompletion(), + hoverTooltips(), + languageServerKeymap, + signatureHelp(), + serverDiagnostics() + ] +} diff --git a/packages/lsp-client/src/plugin.ts b/packages/lsp-client/src/plugin.ts new file mode 100644 index 000000000..152a02bbf --- /dev/null +++ b/packages/lsp-client/src/plugin.ts @@ -0,0 +1,270 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {EditorView, ViewPlugin, ViewUpdate, showDialog} from "@codemirror/view" +import {ChangeSet, Text, Extension, Facet} from "@codemirror/state" +import {language} from "@codemirror/language" +import {type LSPClient} from "./client" +import {docToHTML, withContext} from "./text" +import {toPosition, fromPosition} from "./pos" + +/// Features that can be independently enabled or disabled for an editor +/// binding. This lets a supplemental server contribute completions and +/// diagnostics without becoming the formatter or rename provider. +export type LSPFeature = + | "completion" + | "hover" + | "signatureHelp" + | "diagnostics" + | "formatting" + | "rename" + | "definition" + | "declaration" + | "typeDefinition" + | "implementation" + | "references" + | "codeAction" + | "documentSymbol" + | "inlayHint" + | "documentColor" + +/// Options controlling a client's role when several clients are attached to +/// the same editor. +export type LSPPluginOptions = { + /// Higher-priority clients are considered first for single-owner features. + priority?: number + /// Features default to enabled. Set a feature to false to exclude this + /// client from that feature's provider set. + features?: Partial> +} + +type LSPPluginSpec = { + client: LSPClient + uri: string + languageID?: string + options?: LSPPluginOptions +} + +type ChangeEntry = { + revision: number + changes: ChangeSet +} + +const lspBindings = Facet.define({ + combine: values => values +}) + +class LSPViewPlugin { + private revision = 0 + private changes: ChangeEntry[] = [] + private plugins: LSPPlugin[] = [] + + constructor(readonly view: EditorView) { + this.configure(view.state.facet(lspBindings)) + } + + update(update: ViewUpdate) { + // Record each editor transaction once, regardless of how many language + // servers are attached. Per-client ChangeSets are composed lazily at sync. + if (update.docChanged) { + this.revision++ + this.changes.push({revision: this.revision, changes: update.changes}) + } + const before = update.startState.facet(lspBindings) + const after = update.state.facet(lspBindings) + if (before !== after) this.configure(after) + } + + destroy() { + for (const plugin of this.plugins) plugin.destroy() + this.plugins = [] + this.changes = [] + } + + get(client?: LSPClient): LSPPlugin | null { + if (!client) return this.plugins[0] || null + return this.plugins.find(plugin => plugin.client === client) || null + } + + getAll(feature?: LSPFeature): readonly LSPPlugin[] { + return feature + ? this.plugins.filter(plugin => plugin.featureEnabled(feature)) + : this.plugins + } + + pendingChanges(plugin: LSPPlugin): ChangeSet { + let result = ChangeSet.empty(plugin.syncedDoc.length) + for (const entry of this.changes) { + if (entry.revision > plugin.syncedRevision) + result = result.compose(entry.changes) + } + return result + } + + clear(plugin: LSPPlugin) { + plugin.syncedDoc = this.view.state.doc + plugin.syncedRevision = this.revision + this.pruneChanges() + } + + private configure(specs: readonly LSPPluginSpec[]) { + const unique: LSPPluginSpec[] = [] + for (const spec of specs) { + if (!unique.some(other => other.client === spec.client)) unique.push(spec) + } + + const remaining = new Set(this.plugins) + const next: {plugin: LSPPlugin, order: number}[] = [] + for (let order = 0; order < unique.length; order++) { + const spec = unique[order] + let plugin = this.plugins.find(candidate => + candidate.client === spec.client && + candidate.uri === spec.uri && + candidate.languageID === spec.languageID) + if (plugin) { + remaining.delete(plugin) + plugin.setOptions(spec.options) + } else { + plugin = new LSPPlugin(this.view, spec, this, this.revision) + } + next.push({plugin, order}) + } + for (const plugin of remaining) plugin.destroy() + next.sort((a, b) => b.plugin.priority - a.plugin.priority || a.order - b.order) + this.plugins = next.map(item => item.plugin) + this.pruneChanges() + } + + private pruneChanges() { + if (!this.plugins.length) { + this.changes = [] + return + } + let earliest = this.plugins[0].syncedRevision + for (let i = 1; i < this.plugins.length; i++) + earliest = Math.min(earliest, this.plugins[i].syncedRevision) + let drop = 0 + while (drop < this.changes.length && this.changes[drop].revision <= earliest) drop++ + if (drop) this.changes.splice(0, drop) + } +} + +/// A binding between one editor and one language server client. +export class LSPPlugin { + /// The client connection. + client: LSPClient + /// The URI of this file. + uri: string + /// The language identifier sent to this client. + languageID: string + /// The version of the document that was synchronized to the server. + syncedDoc: Text + /// @internal + syncedRevision: number + private options: LSPPluginOptions + + /// @internal + constructor( + readonly view: EditorView, + {client, uri, languageID, options}: LSPPluginSpec, + private owner: LSPViewPlugin, + revision: number + ) { + this.client = client + this.uri = uri + if (!languageID) { + const lang = view.state.facet(language) + languageID = lang ? lang.name : "" + } + this.languageID = languageID + this.options = options || {} + this.syncedDoc = view.state.doc + this.syncedRevision = revision + client.workspace.openFile(uri, languageID, view) + } + + /// Priority used when a feature needs a single owning client. + get priority() { return this.options.priority ?? 0 } + + /// Whether this binding is eligible to provide a feature. + featureEnabled(feature: LSPFeature) { + return this.options.features?.[feature] !== false + } + + /// @internal + setOptions(options?: LSPPluginOptions) { + this.options = options || {} + } + + /// Render a doc string from the server to HTML. + docToHTML(value: string | lsp.MarkupContent, defaultKind: lsp.MarkupKind = "plaintext") { + const html = withContext(this.view, this.client.config.highlightLanguage, () => docToHTML(value, defaultKind)) + return this.client.config.sanitizeHTML ? this.client.config.sanitizeHTML(html) : html + } + + /// Convert a document offset into an LSP position. + toPosition(pos: number, doc: Text = this.view.state.doc) { + return toPosition(doc, pos) + } + + /// Convert an LSP position into a document offset. + fromPosition(pos: lsp.Position, doc: Text = this.view.state.doc) { + return fromPosition(doc, pos) + } + + /// Display an error in this plugin's editor. + reportError(message: string, err: any) { + showDialog(this.view, { + label: this.view.state.phrase(message) + ": " + (err.message || err), + class: "cm-lsp-message cm-lsp-message-error", + top: true + }) + } + + /// Changes since this client last synchronized. They are composed lazily so + /// typing cost does not scale with the number of attached clients. + get unsyncedChanges(): ChangeSet { + return this.owner.pendingChanges(this) + } + + /// Mark this client's current pending changes as synchronized. + clear() { + this.owner.clear(this) + } + + /// @internal + destroy() { + this.client.workspace.closeFile(this.uri, this.view) + } + + /// Get the primary plugin, or the plugin for a specific client. + static get(view: EditorView, client?: LSPClient) { + return view.plugin(lspPlugin)?.get(client) || null + } + + /// Get all plugins, ordered by descending priority. + static getAll(view: EditorView, feature?: LSPFeature) { + return view.plugin(lspPlugin)?.getAll(feature) || [] + } + + /// Get the first plugin eligible for a feature. + static getForFeature(view: EditorView, feature: LSPFeature) { + return this.getAll(view, feature)[0] || null + } + + /// Synchronize all attached clients. Each client independently decides + /// whether it has pending document changes. + static syncAll(view: EditorView) { + for (const plugin of this.getAll(view)) plugin.client.sync() + } + + /// Deprecated. Use `LSPClient.plugin` instead. + static create(client: LSPClient, fileURI: string, languageID?: string, options?: LSPPluginOptions): Extension { + return client.plugin(fileURI, languageID, options) + } +} + +export const lspPlugin = ViewPlugin.fromClass(LSPViewPlugin) + +/// @internal +export function lspPluginBinding(spec: LSPPluginSpec): Extension { + return [lspBindings.of(spec), lspPlugin] +} diff --git a/packages/lsp-client/src/pos.ts b/packages/lsp-client/src/pos.ts new file mode 100644 index 000000000..f7f877d47 --- /dev/null +++ b/packages/lsp-client/src/pos.ts @@ -0,0 +1,20 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {Text} from "@codemirror/state" + +export function toPosition(doc: Text, pos: number): lsp.Position { + let line = doc.lineAt(pos) + return {line: line.number - 1, character: pos - line.from} +} + +export function fromPosition(doc: Text, pos: lsp.Position): number { + let line = doc.line(pos.line + 1) + return line.from + pos.character +} + +export function fromPositionChecked(doc: Text, pos: lsp.Position): number | null { + if (pos.line < 0 || pos.line >= doc.lines) return null + let line = doc.line(pos.line + 1) + if (pos.character < 0 || pos.character > line.length) return null + return line.from + pos.character +} + diff --git a/packages/lsp-client/src/references.ts b/packages/lsp-client/src/references.ts new file mode 100644 index 000000000..83b7b168d --- /dev/null +++ b/packages/lsp-client/src/references.ts @@ -0,0 +1,200 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {Command, KeyBinding, showPanel, PanelConstructor, EditorView} from "@codemirror/view" +import {StateField, StateEffect} from "@codemirror/state" +import {LSPPlugin} from "./plugin" +import {WorkspaceFile} from "./workspace" +import {WorkspaceMapping} from "./client" +import type {LSPClient} from "./client" + +function getReferences(plugin: LSPPlugin, pos: number) { + return plugin.client.request("textDocument/references", { + textDocument: {uri: plugin.uri}, + position: plugin.toPosition(pos), + context: {includeDeclaration: true} + }) +} + +type ReferenceLocation = {file: WorkspaceFile, range: lsp.Range} + +/// Ask the server to locate all references to the symbol at the +/// cursor. When the server can provide such references, show them as +/// a list in a panel. +export const findReferences: Command = view => { + const plugin = LSPPlugin.getAll(view, "references") + .find(plugin => plugin.client.hasCapability("referencesProvider") !== false) + if (!plugin || plugin.client.hasCapability("referencesProvider") === false) return false + plugin.client.sync() + let mapping = plugin.client.workspaceMapping(), passedMapping = false + getReferences(plugin, view.state.selection.main.head).then(response => { + if (!response) return + return Promise.all(response.map(loc => plugin.client.workspace.requestFile(loc.uri).then(file => { + return file ? {file, range: loc.range} : null + }))).then(resolved => { + let locs = resolved.filter(l => l) as ReferenceLocation[] + if (locs.length) { + displayReferences(plugin.view, locs, mapping, plugin.client) + passedMapping = true + } + }) + }, err => plugin.reportError("Finding references failed", err)).finally(() => { + if (!passedMapping) mapping.destroy() + }) + return true +} + +/// Close the reference panel, if it is open. +export const closeReferencePanel: Command = view => { + if (!view.state.field(referencePanel, false)) return false + view.dispatch({effects: setReferencePanel.of(null)}) + return true +} + +const referencePanel = StateField.define({ + create() { return null }, + + update(panel, tr) { + for (let e of tr.effects) if (e.is(setReferencePanel)) return e.value + return panel + }, + + provide: f => showPanel.from(f) +}) + +const setReferencePanel = StateEffect.define() + +function displayReferences(view: EditorView, locs: readonly ReferenceLocation[], mapping: WorkspaceMapping, client: LSPClient) { + let panel = createReferencePanel(locs, mapping, client) + let effect = view.state.field(referencePanel, false) === undefined + ? StateEffect.appendConfig.of(referencePanel.init(() => panel)) + : setReferencePanel.of(panel) + view.dispatch({effects: effect}) +} + +function createReferencePanel(locs: readonly ReferenceLocation[], mapping: WorkspaceMapping, client: LSPClient): PanelConstructor { + let created = false + // Make sure that if this panel isn't used, the mapping still gets destroyed + setTimeout(() => {if (!created) mapping.destroy()}, 500) + + return view => { + created = true + let prefixLen = findCommonPrefix(locs.map(l => l.file.uri)) + let panel = document.createElement("div"), curFile = null + panel.className = "cm-lsp-reference-panel" + panel.tabIndex = 0 + panel.role = "listbox" + panel.setAttribute("aria-label", view.state.phrase("Reference list")) + let options: HTMLElement[] = [] + for (let {file, range} of locs) { + let fileName = file.uri.slice(prefixLen) + if (fileName != curFile) { + curFile = fileName + let header = panel.appendChild(document.createElement("div")) + header.className = "cm-lsp-reference-file" + header.textContent = fileName + } + let entry = panel.appendChild(document.createElement("div")) + entry.className = "cm-lsp-reference" + entry.role = "option" + let from = mapping.mapPosition(file.uri, range.start, 1), to = mapping.mapPosition(file.uri, range.end, -1) + let view = file.getView(), line = (view ? view.state.doc : file.doc).lineAt(from) + let lineNumber = entry.appendChild(document.createElement("span")) + lineNumber.className = "cm-lsp-reference-line" + lineNumber.textContent = (line.number + ": ").padStart(5, " ") + let textBefore = line.text.slice(Math.max(0, from - line.from - 50), from - line.from) + if (textBefore) entry.appendChild(document.createTextNode(textBefore)) + entry.appendChild(document.createElement("strong")).textContent = line.text.slice(from - line.from, to - line.from) + let textAfter = line.text.slice(to - line.from, Math.min(line.length, 100 - textBefore.length)) + if (textAfter) entry.appendChild(document.createTextNode(textAfter)) + if (!options.length) entry.setAttribute("aria-selected", "true") + options.push(entry) + } + + function curSelection() { + for (let i = 0; i < options.length; i++) { + if (options[i].hasAttribute("aria-selected")) return i + } + return 0 + } + function setSelection(index: number) { + for (let i = 0; i < options.length; i++) { + if (i == index) options[i].setAttribute("aria-selected", "true") + else options[i].removeAttribute("aria-selected") + } + } + function showReference(index: number) { + let {file, range} = locs[index] + let plugin = LSPPlugin.get(view, client) + if (!plugin) return + Promise.resolve(file.uri == plugin.uri ? view : plugin.client.workspace.displayFile(file.uri)).then(view => { + if (!view) return + let pos = mapping.mapPosition(file.uri, range.start, 1) + view.focus() + view.dispatch({ + selection: {anchor: pos}, + scrollIntoView: true + }) + }) + } + + panel.addEventListener("keydown", event => { + if (event.keyCode == 27) { // Escape + closeReferencePanel(view) + view.focus() + } else if (event.keyCode == 38 || event.keyCode == 33) { // ArrowUp, PageUp + setSelection((curSelection() - 1 + locs.length) % locs.length) + } else if (event.keyCode == 40 || event.keyCode == 34) { // ArrowDown, PageDown + setSelection((curSelection() + 1) % locs.length) + } else if (event.keyCode == 36) { // Home + setSelection(0) + } else if (event.keyCode == 35) { // End + setSelection(options.length - 1) + } else if (event.keyCode == 13 || event.keyCode == 10) { // Enter, Space + showReference(curSelection()) + } else { + return + } + event.preventDefault() + }) + panel.addEventListener("click", event => { + for (let i = 0; i < options.length; i++) { + if (options[i].contains(event.target as HTMLElement)) { + setSelection(i) + showReference(i) + event.preventDefault() + } + } + }) + let dom = document.createElement("div") + dom.appendChild(panel) + let close = dom.appendChild(document.createElement("button")) + close.className = "cm-dialog-close" + close.textContent = "×" + close.addEventListener("click", () => closeReferencePanel(view)) + close.setAttribute("aria-label", view.state.phrase("close")) + + return { + dom, + destroy: () => mapping.destroy(), + mount: () => panel.focus(), + } + } +} + +function findCommonPrefix(uris: string[]) { + let first = uris[0], prefix = first.length + for (let i = 1; i < uris.length; i++) { + let uri = uris[i], j = 0 + for (let e = Math.min(prefix, uri.length); j < e && first[j] == uri[j]; j++) {} + prefix = j + } + while (prefix && first[prefix - 1] != "/") prefix-- + return prefix +} + +/// Binds Shift-F12 to [`findReferences`](#lsp-client.findReferences) +/// and Escape to +/// [`closeReferencePanel`](#lsp-client.closeReferencePanel). +export const findReferencesKeymap: readonly KeyBinding[] = [ + {key: "Shift-F12", run: findReferences, preventDefault: true}, + {key: "Escape", run: closeReferencePanel}, +] diff --git a/packages/lsp-client/src/rename.ts b/packages/lsp-client/src/rename.ts new file mode 100644 index 000000000..28381fca2 --- /dev/null +++ b/packages/lsp-client/src/rename.ts @@ -0,0 +1,77 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {EditorView, Command, KeyBinding, showDialog, getDialog} from "@codemirror/view" +import {LSPPlugin} from "./plugin" + +function getRename(plugin: LSPPlugin, pos: number, newName: string) { + return plugin.client.request("textDocument/rename", { + newName, + position: plugin.toPosition(pos), + textDocument: {uri: plugin.uri}, + }) +} + +/// This command will, if the cursor is over a word, prompt the user +/// for a new name for that symbol, and ask the language server to +/// perform a rename of that symbol. +/// +/// Note that this may affect files other than the one loaded into +/// this view. See the +/// [`Workspace.updateFile`](#lsp-client.Workspace.updateFile) +/// method. +export const renameSymbol: Command = view => { + let wordRange = view.state.wordAt(view.state.selection.main.head) + let plugin = LSPPlugin.getAll(view, "rename") + .find(plugin => plugin.client.hasCapability("renameProvider") !== false) + if (!wordRange || !plugin || plugin.client.hasCapability("renameProvider") === false) return false + const word = view.state.sliceDoc(wordRange.from, wordRange.to) + let panel = getDialog(view, "cm-lsp-rename-panel") + if (panel) { + let input = panel.dom.querySelector("[name=name]") as HTMLInputElement + input.value = word + input.select() + } else { + let {close, result} = showDialog(view, { + label: view.state.phrase("New name"), + input: {name: "name", value: word}, + focus: true, + submitLabel: view.state.phrase("rename"), + class: "cm-lsp-rename-panel", + }) + result.then(form => { + view.dispatch({effects: close}) + if (form) doRename(view, (form.elements.namedItem("name") as HTMLInputElement).value) + }) + } + return true +} + +function doRename(view: EditorView, newName: string) { + const plugin = LSPPlugin.getAll(view, "rename") + .find(plugin => plugin.client.hasCapability("renameProvider") !== false) + const word = view.state.wordAt(view.state.selection.main.head) + if (!plugin || !word) return false + + plugin.client.sync() + plugin.client.withMapping(mapping => getRename(plugin, word.from, newName).then(response => { + if (!response) return + uris: for (let uri in response.changes) { + let lspChanges = response.changes[uri], file = plugin.client.workspace.getFile(uri) + if (!lspChanges.length || !file) continue + plugin.client.workspace.updateFile(uri, { + changes: lspChanges.map(change => ({ + from: mapping.mapPosition(uri, change.range.start), + to: mapping.mapPosition(uri, change.range.end), + insert: change.newText + })), + userEvent: "rename" + }) + } + }, err => { + plugin.reportError("Rename request failed", err) + })) +} + +/// A keymap that binds F2 to [`renameSymbol`](#lsp-client.renameSymbol). +export const renameKeymap: readonly KeyBinding[] = [ + {key: "F2", run: renameSymbol, preventDefault: true} +] diff --git a/packages/lsp-client/src/signature.ts b/packages/lsp-client/src/signature.ts new file mode 100644 index 000000000..a0b07438c --- /dev/null +++ b/packages/lsp-client/src/signature.ts @@ -0,0 +1,274 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {StateField, StateEffect, Prec, Extension} from "@codemirror/state" +import {EditorView, ViewPlugin, ViewUpdate, keymap, KeyBinding, + Tooltip, showTooltip, Command} from "@codemirror/view" +import {LSPPlugin} from "./plugin" +import type {LSPClient} from "./client" + +function getSignatureHelp(plugin: LSPPlugin, pos: number, context: lsp.SignatureHelpContext) { + if (plugin.client.hasCapability("signatureHelpProvider") === false) return Promise.resolve(null) + plugin.client.sync() + return plugin.client.request("textDocument/signatureHelp", { + context, + position: plugin.toPosition(pos), + textDocument: {uri: plugin.uri}, + }) +} + +const signaturePlugin = ViewPlugin.fromClass(class { + activeRequest: {pos: number, drop: boolean} | null = null + delayedRequest: ReturnType | null = null + + update(update: ViewUpdate) { + if (this.activeRequest) { + if (update.selectionSet) { + this.activeRequest.drop = true + this.activeRequest = null + } else if (update.docChanged) { + this.activeRequest.pos = update.changes.mapPos(this.activeRequest.pos) + } + } + + const plugin = LSPPlugin.getAll(update.view, "signatureHelp") + .find(plugin => plugin.client.hasCapability("signatureHelpProvider") !== false) + if (!plugin) return + const sigState = update.view.state.field(signatureState) + let triggerCharacter = "" + if (update.docChanged && update.transactions.some(tr => tr.isUserEvent("input.type"))) { + const serverConf = plugin.client.serverCapabilities?.signatureHelpProvider + const triggers = (serverConf?.triggerCharacters || []).concat(sigState && serverConf?.retriggerCharacters || []) + if (triggers) { + update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + let ins = inserted.toString() + if (ins) for (let ch of triggers) { + if (ins.indexOf(ch) > -1) triggerCharacter = ch + } + }) + } + } + + if (triggerCharacter) { + this.startRequest(plugin, { + triggerKind: 2 /* TriggerCharacter */, + isRetrigger: !!sigState, + triggerCharacter, + activeSignatureHelp: sigState ? sigState.data : undefined + }) + } else if (sigState && update.selectionSet) { + if (this.delayedRequest != null) clearTimeout(this.delayedRequest) + this.delayedRequest = setTimeout(() => { + this.startRequest(plugin, { + triggerKind: 3 /* ContentChange */, + isRetrigger: true, + activeSignatureHelp: sigState.data, + }) + }, 250) + } + } + + startRequest(plugin: LSPPlugin, context: lsp.SignatureHelpContext) { + if (this.delayedRequest != null) clearTimeout(this.delayedRequest) + let {view} = plugin, pos = view.state.selection.main.head + if (this.activeRequest) this.activeRequest.drop = true + let req = this.activeRequest = {pos, drop: false} + getSignatureHelp(plugin, pos, context).then(result => { + if (req.drop) return + if (result && result.signatures.length) { + let cur = view.state.field(signatureState) + let same = cur && sameSignatures(cur.data, result) + let active = same && context.triggerKind == 3 ? cur!.active : result.activeSignature ?? 0 + // Don't update at all if nothing changed + if (same && sameActiveParam(cur!.data, result, active)) return + view.dispatch({effects: signatureEffect.of({ + data: result, + active, + pos: same ? cur!.tooltip.pos : req.pos, + client: plugin.client + })}) + } else if (view.state.field(signatureState)) { + view.dispatch({effects: signatureEffect.of(null)}) + } + }, context.triggerKind == 1 /* Invoked */ ? err => plugin.reportError("Signature request failed", err) : undefined) + } + + destroy() { + if (this.delayedRequest != null) clearTimeout(this.delayedRequest) + if (this.activeRequest) this.activeRequest.drop = true + } +}) + +function sameSignatures(a: lsp.SignatureHelp, b: lsp.SignatureHelp) { + if (a.signatures.length != b.signatures.length) return false + return a.signatures.every((s, i) => s.label == b.signatures[i].label) +} + +function sameActiveParam(a: lsp.SignatureHelp, b: lsp.SignatureHelp, active: number) { + return (a.signatures[active].activeParameter ?? a.activeParameter) == + (b.signatures[active].activeParameter ?? b.activeParameter) +} + +class SignatureState { + constructor( + readonly data: lsp.SignatureHelp, + readonly active: number, + readonly tooltip: Tooltip, + readonly client: LSPClient + ) {} +} + +const signatureState = StateField.define({ + create() { return null }, + update(sig, tr) { + for (let e of tr.effects) if (e.is(signatureEffect)) { + if (e.value) { + return new SignatureState(e.value.data, e.value.active, + signatureTooltip(e.value.data, e.value.active, e.value.pos, e.value.client), e.value.client) + } else { + return null + } + } + if (sig && tr.docChanged) + return new SignatureState(sig.data, sig.active, + {...sig.tooltip, pos: tr.changes.mapPos(sig.tooltip.pos)}, sig.client) + return sig + }, + provide: f => showTooltip.from(f, sig => sig && sig.tooltip) +}) + +const signatureEffect = StateEffect.define<{ + data: lsp.SignatureHelp, + active: number, + pos: number, + client: LSPClient +} | null>() + +function signatureTooltip(data: lsp.SignatureHelp, active: number, pos: number, client: LSPClient): Tooltip { + return { + pos, + above: true, + create: view => drawSignatureTooltip(view, data, active, client) + } +} + +function drawSignatureTooltip(view: EditorView, data: lsp.SignatureHelp, active: number, client: LSPClient) { + let dom = document.createElement("div") + dom.className = "cm-lsp-signature-tooltip" + if (data.signatures.length > 1) { + dom.classList.add("cm-lsp-signature-multiple") + let num = dom.appendChild(document.createElement("div")) + num.className = "cm-lsp-signature-num" + num.textContent = `${active + 1}/${data.signatures.length}` + } + + let signature = data.signatures[active] + let sig = dom.appendChild(document.createElement("div")) + sig.className = "cm-lsp-signature" + let activeFrom = 0, activeTo = 0 + let activeN = signature.activeParameter ?? data.activeParameter + let activeParam = activeN != null && signature.parameters ? signature.parameters[activeN] : null + if (activeParam && Array.isArray(activeParam.label)) { + ;[activeFrom, activeTo] = activeParam.label + } else if (activeParam) { + let found = signature.label.indexOf(activeParam.label as string) + if (found > -1) { + activeFrom = found + activeTo = found + activeParam.label.length + } + } + if (activeTo) { + sig.appendChild(document.createTextNode(signature.label.slice(0, activeFrom))) + let activeElt = sig.appendChild(document.createElement("span")) + activeElt.className = "cm-lsp-active-parameter" + activeElt.textContent = signature.label.slice(activeFrom, activeTo) + sig.appendChild(document.createTextNode(signature.label.slice(activeTo))) + } else { + sig.textContent = signature.label + } + if (signature.documentation) { + let plugin = LSPPlugin.get(view, client) + if (plugin) { + let docs = dom.appendChild(document.createElement("div")) + docs.className = "cm-lsp-signature-documentation cm-lsp-documentation" + docs.innerHTML = plugin.docToHTML(signature.documentation) + } + } + return {dom} +} + +/// Explicitly prompt the server to provide signature help at the +/// cursor. +export const showSignatureHelp: Command = view => { + let plugin = view.plugin(signaturePlugin) + if (!plugin) { + view.dispatch({effects: StateEffect.appendConfig.of([signatureState, signaturePlugin])}) + plugin = view.plugin(signaturePlugin) + } + let field = view.state.field(signatureState) + if (!plugin || field === undefined) return false + let lspPlugin = LSPPlugin.getAll(view, "signatureHelp") + .find(plugin => plugin.client.hasCapability("signatureHelpProvider") !== false) + if (!lspPlugin) return false + plugin.startRequest(lspPlugin, { + triggerKind: 1 /* Invoked */, + activeSignatureHelp: field ? field.data : undefined, + isRetrigger: !!field + }) + return true +} + +/// If there is an active signature tooltip with multiple signatures, +/// move to the next one. +export const nextSignature: Command = view => { + let field = view.state.field(signatureState) + if (!field) return false + if (field.active < field.data.signatures.length - 1) + view.dispatch({effects: signatureEffect.of({ + data: field.data, active: field.active + 1, pos: field.tooltip.pos, client: field.client + })}) + return true +} + +/// If there is an active signature tooltip with multiple signatures, +/// move to the previous signature. +export const prevSignature: Command = view => { + let field = view.state.field(signatureState) + if (!field) return false + if (field.active > 0) + view.dispatch({effects: signatureEffect.of({ + data: field.data, active: field.active - 1, pos: field.tooltip.pos, client: field.client + })}) + return true +} + +/// A keymap that binds +/// +/// - Ctrl-Shift-Space (Cmd-Shift-Space on macOS) to +/// [`showSignatureHelp`](#lsp-client.showSignatureHelp) +/// +/// - Ctrl-Shift-ArrowUp (Cmd-Shift-ArrowUp on macOS) to +/// [`prevSignature`](#lsp-client.prevSignature) +/// +/// - Ctrl-Shift-ArrowDown (Cmd-Shift-ArrowDown on macOS) to +/// [`nextSignature`](#lsp-client.nextSignature) +/// +/// Note that these keys are automatically bound by +/// [`signatureHelp`](#lsp-client.signatureHelp) unless you pass it +/// `keymap: false`. +export const signatureKeymap: readonly KeyBinding[] = [ + {key: "Mod-Shift-Space", run: showSignatureHelp}, + {key: "Mod-Shift-ArrowUp", run: prevSignature}, + {key: "Mod-Shift-ArrowDown", run: nextSignature}, +] + +/// Returns an extension that enables signature help. Will bind the +/// keys in [`signatureKeymap`](#lsp-client.signatureKeymap) unless +/// `keymap` is set to `false`. +const signatureHelpWithoutKeymap: Extension = [signatureState, signaturePlugin] +const signatureHelpWithKeymap: Extension = [ + signatureHelpWithoutKeymap, + Prec.high(keymap.of(signatureKeymap)) +] + +export function signatureHelp(config: {keymap?: boolean} = {}): Extension { + return config.keymap === false ? signatureHelpWithoutKeymap : signatureHelpWithKeymap +} diff --git a/packages/lsp-client/src/text.ts b/packages/lsp-client/src/text.ts new file mode 100644 index 000000000..59773d08e --- /dev/null +++ b/packages/lsp-client/src/text.ts @@ -0,0 +1,56 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {Marked} from "marked" +import {EditorView} from "@codemirror/view" +import {Language, language as languageFacet, highlightingFor} from "@codemirror/language" +import {highlightCode, Highlighter} from "@lezer/highlight" + +let context: {view: EditorView, language: ((name: string) => Language | null) | undefined} | null = null + +export function withContext(view: EditorView, language: ((name: string) => Language | null) | undefined, f: () => T): T { + let prev = context + try { + context = {view, language} + return f() + } finally { + context = prev + } +} + +const marked = new Marked({ + walkTokens(token) { + if (!context || token.type != "code") return + + let lang = context.language && context.language(token.lang) + if (!lang) { + let viewLang = context.view.state.facet(languageFacet) + if (viewLang && viewLang.name == token.lang) lang = viewLang + } + if (!lang) return + let highlighter: Highlighter = {style: tags => highlightingFor(context!.view.state, tags)} + let result = "" + highlightCode(token.text, lang.parser.parse(token.text), highlighter, (text, cls) => { + result += cls ? `${escHTML(text)}` : escHTML(text) + }, () => { + result += "
" + }) + token.escaped = true + token.text = result + } +}) + +export function escHTML(text: string) { + return text.replace(/[\n<&]/g, ch => ch == "\n" ? "
" : ch == "<" ? "<" : "&") +} + +export function docToHTML(value: string | lsp.MarkupContent, defaultKind: lsp.MarkupKind) { + let kind = defaultKind, text = value + if (typeof text != "string") { + kind = text.kind + text = text.value + } + if (kind == "plaintext") { + return escHTML(text) + } else { + return marked.parse(text, {async: false, }) + } +} diff --git a/packages/lsp-client/src/theme.ts b/packages/lsp-client/src/theme.ts new file mode 100644 index 000000000..1c0c45560 --- /dev/null +++ b/packages/lsp-client/src/theme.ts @@ -0,0 +1,79 @@ +import {EditorView} from "@codemirror/view" + +export const lspTheme = EditorView.baseTheme({ + ".cm-lsp-documentation": { + padding: "0 7px", + "& p, & pre": { + margin: "2px 0" + } + }, + + ".cm-lsp-signature-tooltip": { + padding: "2px 6px", + borderRadius: "2.5px", + position: "relative", + maxWidth: "30em", + maxHeight: "10em", + overflowY: "scroll", + "& .cm-lsp-documentation": { + padding: "0", + fontSize: "80%", + }, + "& .cm-lsp-signature-num": { + fontFamily: "monospace", + position: "absolute", + left: "2px", top: "4px", + fontSize: "70%", + lineHeight: "1.3" + }, + "& .cm-lsp-signature": { + fontFamily: "monospace", + textIndent: "1em hanging", + }, + "& .cm-lsp-active-parameter": { + fontWeight: "bold" + }, + }, + ".cm-lsp-signature-multiple": { + paddingLeft: "1.5em" + }, + + ".cm-panel.cm-lsp-rename-panel": { + padding: "2px 6px 4px", + position: "relative", + "& label": { fontSize: "80%" }, + "& [name=close]": { + position: "absolute", + top: "0", bottom: "0", + right: "4px", + backgroundColor: "inherit", + border: "none", + font: "inherit", + padding: "0" + } + }, + + ".cm-lsp-message button[type=submit]": { + display: "block" + }, + + ".cm-lsp-reference-panel": { + fontFamily: "monospace", + whiteSpace: "pre", + padding: "3px 6px", + maxHeight: "120px", + overflow: "auto", + "& .cm-lsp-reference-file": { + fontWeight: "bold", + }, + "& .cm-lsp-reference": { + cursor: "pointer", + "&[aria-selected]": { + backgroundColor: "#0077ee44" + }, + }, + "& .cm-lsp-reference-line": { + opacity: "0.7", + }, + }, +}) diff --git a/packages/lsp-client/src/workspace.ts b/packages/lsp-client/src/workspace.ts new file mode 100644 index 000000000..dd0c3b203 --- /dev/null +++ b/packages/lsp-client/src/workspace.ts @@ -0,0 +1,161 @@ +import {Text, ChangeSet, TransactionSpec} from "@codemirror/state" +import {EditorView} from "@codemirror/view" +import {LSPClient} from "./client" +import {LSPPlugin} from "./plugin" + +/// A file that is open in a workspace. +export interface WorkspaceFile { + /// The file's unique URI. + uri: string + /// The LSP language ID for the file's content. + languageId: string + /// The current version of the file. + version: number + /// The document corresponding to `this.version`. Will not reflect + /// changes made after that version was synchronized. Will be + /// updated, along with `version`, by + /// [`syncFiles`](#lsp-client.Workspace.syncFiles). + doc: Text + /// Get an active editor view for this file, if there is one. For + /// workspaces that support multiple views on a file, `main` + /// indicates a preferred view. + getView(main?: EditorView): EditorView | null +} + +interface WorkspaceFileUpdate { + file: WorkspaceFile + prevDoc: Text + changes: ChangeSet +} + +/// Implementing your own workspace class can provide more control +/// over the way files are loaded and managed when interacting with +/// the language server. See +/// [`LSPClientConfig.workspace`](#lsp-client.LSPClientConfig.workspace). +export abstract class Workspace { + /// The files currently open in the workspace. + abstract files: WorkspaceFile[] + + /// The constructor, as called by the client when creating a + /// workspace. + constructor( + /// The LSP client associated with this workspace. + readonly client: LSPClient + ) {} + + /// Find the open file with the given URI, if it exists. The default + /// implementation just looks it up in `this.files`. + getFile(uri: string) : WorkspaceFile | null { + return this.files.find(f => f.uri == uri) || null + } + + /// Check all open files for changes (usually from editors, but they + /// may also come from other sources). When a file is changed, + /// return a record that describes the changes, and update the file's + /// [`version`](#lsp-client.WorkspaceFile.version) and + /// [`doc`](#lsp-client.WorkspaceFile.doc) properties to reflect the + /// new version. + abstract syncFiles(): readonly WorkspaceFileUpdate[] + + /// Called to request that the workspace open a file. The default + /// implementation simply returns the file if it is open, null + /// otherwise. + requestFile(uri: string): Promise { + return Promise.resolve(this.getFile(uri)) + } + + /// Called when an editor is created for a file. The implementation + /// should track the file in + /// [`this.files`](#lsp-client.Workspace.files) and, if it wasn't + /// open already, call + /// [`LSPClient.didOpen`](#lsp-client.LSPClient.didOpen). + abstract openFile(uri: string, languageId: string, view: EditorView): void + + /// Called when an editor holding this file is destroyed or + /// reconfigured to no longer hold it. The implementation should + /// track this and, when it closes the file, make sure to call + /// [`LSPClient.didClose`](#lsp-client.LSPClient.didClose). + abstract closeFile(uri: string, view: EditorView): void + + /// Called when the client for this workspace is connected. The + /// default implementation calls + /// [`LSPClient.didOpen`](#lsp-client.LSPClient.didOpen) on all open + /// files. + connected(): void { + for (let file of this.files) this.client.didOpen(file) + } + + /// Called when the client for this workspace is disconnected. The + /// default implementation does nothing. + disconnected(): void {} + + /// Called when a server-initiated change to a file is applied. The + /// default implementation simply dispatches the update to the + /// file's view, if the file is open and has a view. + updateFile(uri: string, update: TransactionSpec): void { + let file = this.getFile(uri) + if (file) file.getView()?.dispatch(update) + } + + /// When the client needs to put a file other than the one loaded in + /// the current editor in front of the user, for example in + /// [`jumpToDefinition`](#lsp-client.jumpToDefinition), it will call + /// this function. It should make sure to create or find an editor + /// with the file and make it visible to the user, or return null if + /// this isn't possible. + displayFile(uri: string): Promise { + let file = this.getFile(uri) + return Promise.resolve(file ? file.getView() : null) + } +} + +class DefaultWorkspaceFile implements WorkspaceFile { + constructor(readonly uri: string, + readonly languageId: string, + public version: number, + public doc: Text, + readonly view: EditorView) {} + + getView() { return this.view } +} + +export class DefaultWorkspace extends Workspace { + files: DefaultWorkspaceFile[] = [] + private fileVersions: {[uri: string]: number} = Object.create(null) + + nextFileVersion(uri: string) { + return this.fileVersions[uri] = (this.fileVersions[uri] ?? -1) + 1 + } + + syncFiles() { + let result: WorkspaceFileUpdate[] = [] + for (let file of this.files) { + let plugin = LSPPlugin.get(file.view, this.client) + if (!plugin) continue + let changes = plugin.unsyncedChanges + if (!changes.empty) { + result.push({changes, file, prevDoc: file.doc}) + file.doc = file.view.state.doc + file.version = this.nextFileVersion(file.uri) + plugin.clear() + } + } + return result + } + + openFile(uri: string, languageId: string, view: EditorView) { + if (this.getFile(uri)) + throw new Error("Default workspace implementation doesn't support multiple views on the same file") + let file = new DefaultWorkspaceFile(uri, languageId, this.nextFileVersion(uri), view.state.doc, view) + this.files.push(file) + this.client.didOpen(file) + } + + closeFile(uri: string) { + let file = this.getFile(uri) + if (file) { + this.files = this.files.filter(f => f != file) + this.client.didClose(uri) + } + } +} diff --git a/packages/lsp-client/test/server.ts b/packages/lsp-client/test/server.ts new file mode 100644 index 000000000..4845fe9da --- /dev/null +++ b/packages/lsp-client/test/server.ts @@ -0,0 +1,207 @@ +import type * as lsp from "vscode-languageserver-protocol" +import {Transport} from "@codemirror/lsp-client" + +const serverCapabilities: lsp.ServerCapabilities = { + textDocumentSync: {openClose: true, change: 2}, + renameProvider: true, + documentFormattingProvider: true, + completionProvider: {triggerCharacters: [","]}, + hoverProvider: true, + signatureHelpProvider: {}, +} + +const requestHandlers: {[method: string]: (params: any, server: DummyServer) => any} = { + "initialize": (params: lsp.InitializeParams, server): lsp.InitializeResult => { + return {capabilities: serverCapabilities, serverInfo: {name: "Dummy server"}} + }, + + "textDocument/rename": (params: lsp.RenameParams, server): lsp.WorkspaceEdit | null => { + let file = server.getFile(params.textDocument.uri) + if (!file) throw new ServerError(-32602, "File not open") + let pos = resolvePosition(file.text, params.position) + let from = pos, to = pos + while (from && /\w/.test(file.text[from - 1])) from-- + while (to < file.text.length && /\w/.test(file.text[to])) to++ + if (from == to) return null + let word = file.text.slice(from, to), changes: {[uri: string]: lsp.TextEdit[]} = {} + for (let file of server.files) { + let found: lsp.TextEdit[] = [], pos = 0, next + while ((next = file.text.indexOf(word, pos)) > -1) { + pos = next + word.length + found.push({ + newText: params.newName, + range: {start: toPosition(file.text, next), end: toPosition(file.text, pos)} + }) + } + if (found.length) changes[file.uri] = found + } + return {changes} + }, + + "textDocument/formatting": (params: lsp.DocumentFormattingParams, server): lsp.TextEdit[] | null => { + let file = server.getFile(params.textDocument.uri) + if (!file) throw new ServerError(-32602, "File not open") + let end = toPosition(file.text, file.text.length) + return [{newText: "\n// formatted!", range: {start: end, end}}] + }, + + "textDocument/completion": (params: lsp.CompletionParams, server): lsp.CompletionList | null => { + let before = {line: params.position.line, character: params.position.character - 1} + return { + isIncomplete: true, + itemDefaults: { + editRange: {start: before, end: params.position} + }, + items: [ + {label: "one", kind: 14, commitCharacters: ["."], textEdit: {newText: "one!", range: {start: before, end: params.position}}}, + {label: "ookay", kind: 7, documentation: "`code` stuff", insertText: "okay"}, + {label: "fn", kind: 3, insertText: "fn(${1:\\$arg})$0", insertTextFormat: 2}, + {label: "Bar", kind: 7, insertText: "Bar", additionalTextEdits: [ + {range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, newText: "use Bar;\n"}, + ]}, + {label: "Zap", kind: 7, insertText: "Zap", additionalTextEdits: [ + {range: {start: {line: 2, character: 0}, end: {line: 2, character: 0}}, newText: "MID\n"}, + ]}, + ] + } + }, + + "textDocument/hover": (params: lsp.HoverParams, server): lsp.Hover | null => { + return { + range: {start: params.position, end: params.position}, + contents: {language: "javascript", value: "'hover'"} + } + }, + + "textDocument/signatureHelp": (params: lsp.SignatureHelpParams, server): lsp.SignatureHelp | null => { + return { + signatures: [{ + label: "(a, b) => c", + activeParameter: 1, + parameters: [{label: [1, 2]}, {label: [4, 5]}] + }, { + label: "(x, y) => c", + activeParameter: 1, + parameters: [{label: [1, 2]}, {label: [4, 5]}] + }], + activeSignature: 0, + } + }, + + "custom/sendNotification": (params: {method: string, params: any}, server) => { + server.broadcast({jsonrpc: "2.0", method: params.method, params: params.params}) + }, +} + +const notificationHandlers: {[method: string]: (params: any, server: DummyServer) => void} = { + "initialized": (params: lsp.InitializedParams, server) => { + server.initialized = true + }, + "textDocument/didOpen": (params: lsp.DidOpenTextDocumentParams, server) => { + let {uri, text, languageId} = params.textDocument + if (!server.getFile(params.textDocument.uri)) + server.files.push(new OpenFile(uri, languageId, text)) + }, + "textDocument/didClose": (params: lsp.DidCloseTextDocumentParams, server) => { + server.files = server.files.filter(f => f.uri != params.textDocument.uri) + }, + "textDocument/didChange": (params: lsp.DidChangeTextDocumentParams, server) => { + let file = server.getFile(params.textDocument.uri) + if (file) for (let ch of params.contentChanges) { + if ("range" in ch) + file.text = file.text.slice(0, resolvePosition(file.text, ch.range.start)) + ch.text + + file.text.slice(resolvePosition(file.text, ch.range.end)) + else + file.text = ch.text + } + } +} + +function resolvePosition(text: string, pos: lsp.Position) { + let line = 0, off = 0 + while (line < pos.line) { + let next = text.indexOf("\n", off) + if (!next) throw new RangeError("Position out of bounds") + off = next + 1 + line++ + } + off += pos.character + if (off > text.length) throw new RangeError("Position out of bounds") + return off +} + +function toPosition(text: string, pos: number): lsp.Position { + for (let off = 0, line = 0;;) { + let next = text.indexOf("\n", off) + if (next < 0 || next >= pos) return {line, character: pos - off} + off = next + 1 + line++ + } +} + +class ServerError extends Error { + constructor(readonly code: number, message: string) { super(message) } +} + +class OpenFile { + constructor(readonly uri: string, readonly languageId: string, public text: string) {} +} + +export class DummyServer implements Transport { + initialized = false + subscribers: ((msg: string) => void)[] = [] + files: OpenFile[] = [] + + constructor(readonly config: { + delay?: {[method: string]: number}, + brokenPipe?: () => boolean + } = {}) { + } + + subscribe(listener: (msg: string) => void) { + this.subscribers.push(listener) + } + + unsubscribe(listener: (msg: string) => void) { + this.subscribers = this.subscribers.filter(l => l != listener) + } + + send(message: string) { + if (this.config.brokenPipe?.()) throw new Error("Broken Pipe") + const msg = JSON.parse(message) as lsp.RequestMessage | lsp.NotificationMessage + if ("id" in msg) { + this.handleRequest(msg.method, msg.params).then(result => { + this.broadcast({jsonrpc: "2.0", id: msg.id, result}) + }, e => { + let error = e instanceof ServerError ? {code: e.code, message: e.message} + : {code: -32603 /* InternalError */, message: String(e)} + this.broadcast({jsonrpc: "2.0", id: msg.id, error}) + }) + } else { + this.handleNotification(msg.method, msg.params) + } + } + + broadcast(message: any) { + for (let sub of this.subscribers) sub(JSON.stringify(message)) + } + + handleRequest(method: string, params: Params): Promise { + return new Promise(resolve => { + if (!this.initialized && method != "initialize") + throw new ServerError(-32002 /* ServerNotInitialized */, "Not initialized") + let handler = requestHandlers[method] + if (!handler) throw new ServerError(-32601 /* MethodNotFound */, "Method not found") + let result = handler(params, this), delay = this.config.delay?.[method] + if (delay) setTimeout(() => resolve(result), delay) + else queueMicrotask(() => resolve(result)) + }) + } + + handleNotification(method: string, params: Params) { + let handler = notificationHandlers[method] + if (handler) handler(params, this) + } + + getFile(uri: string) { return this.files.find(f => f.uri == uri) } +} diff --git a/packages/lsp-client/test/webtest-client.ts b/packages/lsp-client/test/webtest-client.ts new file mode 100644 index 000000000..3ebb4deba --- /dev/null +++ b/packages/lsp-client/test/webtest-client.ts @@ -0,0 +1,373 @@ +import type * as lsp from "vscode-languageserver-protocol" +import ist from "ist" +import {LSPClientConfig, LSPClient, LSPPlugin, renameSymbol, + formatDocument, serverCompletion, hoverTooltips, + showSignatureHelp, nextSignature} from "@codemirror/lsp-client" +import {EditorView, EditorViewConfig} from "@codemirror/view" +import {javascript} from "@codemirror/lang-javascript" +import {syntaxHighlighting} from "@codemirror/language" +import {startCompletion, currentCompletions, acceptCompletion, autocompletion, moveCompletionSelection} from "@codemirror/autocomplete" +import {classHighlighter} from "@lezer/highlight" +import {DummyServer} from "./server.js" + +function setup(conf: {client?: LSPClientConfig, server?: ConstructorParameters[0]} = {}) { + let server = new DummyServer(conf.server) + let client = new LSPClient(conf.client) + client.connect(server) + return {server, client} +} + +const URI = "file:///home/holly/src/test.js" + +function ed(client: LSPClient, conf: EditorViewConfig, uri = URI) { + return new EditorView({...conf, extensions: [conf.extensions || [], LSPPlugin.create(client, uri, "javascript")]}) +} + +function sync(cm: EditorView) { + LSPPlugin.get(cm)!.client.sync() +} + +function wait(ms: number = 2) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function place(cm: EditorView) { + let ws = document.querySelector("#workspace")! + while (ws.firstChild) ws.firstChild.remove() + ws.appendChild(cm.dom) + setTimeout(() => cm.destroy(), 1000) + return cm +} + +describe("LSPClient", () => { + it("can connect to a server", () => { + let {client} = setup() + return client.initializing + }) + + it("can open a file", async () => { + let {client, server} = setup() + ed(client, {doc: "stitchwort"}) + await wait() + ist(server.getFile(URI)!.text, "stitchwort") + }) + + it("can update a file", async () => { + let {client, server} = setup() + let cm = ed(client, {doc: "goldenrod"}) + cm.dispatch({changes: {from: 1, insert: "-"}}) + sync(cm) + await wait() + ist(server.getFile(URI)!.text, cm.state.sliceDoc()) + }) + + it("can update a file with multiple changes", async () => { + let {client, server} = setup() + let cm = ed(client, {doc: "hawkweed\n".repeat(1000)}) + await wait() + cm.dispatch({changes: [{from: 0, insert: "<"}, {from: cm.state.doc.length, insert: ">"}]}) + sync(cm) + await wait() + ist(server.getFile(URI)!.text, cm.state.sliceDoc()) + }) + + it("can close a file", async () => { + let {client, server} = setup() + let cm = ed(client, {doc: "cowleek"}) + await wait() + cm.destroy() + await wait() + ist(!server.getFile(URI)) + }) + + it("can open multiple files", async () => { + let {client, server} = setup() + let cm1 = ed(client, {doc: "elder"}), cm2 = ed(client, {doc: "alfalfa"}, "file:///x") + cm1.dispatch({changes: {from: 5, insert: "?"}}) + cm2.dispatch({changes: {from: 7, insert: "!"}}) + sync(cm1) + await wait() + ist(server.getFile(URI)!.text, "elder?") + ist(server.getFile("file:///x")!.text, "alfalfa!") + }) + + it("can provide mapping", async () => { + let {client} = setup() + let cm = ed(client, {doc: "1\n2\n3"}) + await client.withMapping(async mapping => { + let req = client.request("textDocument/formatting", { + textDocument: {uri: URI}, + options: {tabSize: 2, insertSpaces: true} + }) + cm.dispatch({changes: {from: 1, to: 3}}) + let response = await req + ist(response![0].range.start.line, 2) + ist(mapping.mapPos(URI, 5), 3) + }) + }) + + it("can provide mapping across syncs", async () => { + let {client} = setup({server: {delay: {"textDocument/formatting": 10}}}) + let cm = ed(client, {doc: "1\n2\n3"}) + await client.withMapping(async mapping => { + let req = client.request("textDocument/formatting", { + textDocument: {uri: URI}, + options: {tabSize: 2, insertSpaces: true} + }) + cm.dispatch({changes: {from: 1, to: 3}}) + sync(cm) + cm.dispatch({changes: {from: 0, insert: "#"}}) + let response = await req + ist(response![0].range.start.line, 2) + ist(mapping.mapPos(URI, 5), 4) + }) + }) + + it("reports invalid methods", async () => { + let {client} = setup() + try { + await client.request("none/such", {}) + ist(false) + } catch (e: any) { + ist(e.code, -32601) + } + }) + + it("can receive custom notifications", async () => { + let received: any = null + let {client} = setup({client: {notificationHandlers: { + "custom/notification": (client, params) => received = params + }}}) + client.request("custom/sendNotification", {method: "custom/notification", params: {verify: true}}) + await wait() + ist(received.verify) + }) + + it("can report unknown notifications", async () => { + let received: any = null + let {client} = setup({client: {unhandledNotification: (client, method, params) => received = params}}) + client.request("custom/sendNotification", {method: "custom/notification", params: {verify: true}}) + await wait() + ist(received.verify) + }) + + it("can display messages in the editor", async () => { + let {client} = setup() + let cm = ed(client, {}) + client.request("custom/sendNotification", {method: "window/showMessage", params: {type: 2 /* Warning */, message: "WARNING"}}) + await wait() + let dialog = cm.dom.querySelector(".cm-lsp-message-warning")! + ist(dialog) + ist(dialog.innerHTML.indexOf("WARNING"), "-1", ">") + }) + + it("routes exceptions from Transport.send to the request promise", async () => { + let broken = false + let {client} = setup({server: {brokenPipe: () => broken}}) + await client.initializing + broken = true + let req = client.request("test", {}) + try { + await req + ist(false) + } catch (e: any) { + ist(e.message, "Broken Pipe") + } + }) + + describe("LSPPlugin", () => { + it("can render doc strings", () => { + let {client} = setup({client: {sanitizeHTML: s => s.replace(/x/g, "y")}}) + let cm = ed(client, {}) + ist(LSPPlugin.get(cm)!.docToHTML({kind: "markdown", value: "# xx"}), "

yy

\n") + }) + + it("can render doc strings with highlighting", () => { + let {client} = setup() + let cm = ed(client, {extensions: [ + javascript(), + syntaxHighlighting(classHighlighter) + ]}) + ist(LSPPlugin.get(cm)!.docToHTML({kind: "markdown", value: "```javascript\nreturn\n```"}), + '
return\n
\n') + }) + + it("can convert to LSP positions", () => { + let {client} = setup() + let cm = ed(client, {doc: "one\ntwo\nthree"}) + let pos = LSPPlugin.get(cm)!.toPosition(6) + ist(pos.line, 1) + ist(pos.character, 2) + }) + + it("can convert from positions", () => { + let {client} = setup() + let cm = ed(client, {doc: "one\ntwo\nthree"}) + ist(LSPPlugin.get(cm)!.fromPosition({line: 0, character: 3}), 3) + ist(LSPPlugin.get(cm)!.fromPosition({line: 2, character: 1}), 9) + }) + + it("can display errors", () => { + let {client} = setup() + let cm = ed(client, {}) + LSPPlugin.get(cm)!.reportError("E", "Oh no") + ist(cm.dom.querySelector(".cm-lsp-message-error")!.innerHTML.indexOf("Oh no"), -1, ">") + }) + }) + + describe("renameSymbol", () => { + it("can run a rename", async () => { + let {client} = setup() + let cm = place(ed(client, {doc: "let foo = 1; console.log(foo)", selection: {anchor: 4}})) + let cm2 = ed(client, {doc: "foo?"}, "file:///2") + await wait() + ist(renameSymbol(cm), true) + let form = cm.dom.querySelector(".cm-panel form") as HTMLFormElement + form.querySelector("input")!.value = "bar" + form.requestSubmit() + await wait() + ist(cm.state.sliceDoc(), "let bar = 1; console.log(bar)") + ist(cm2.state.sliceDoc(), "bar?") + cm.destroy() + }) + + it("can handle changes during the request", async () => { + let {client} = setup({server: {delay: {"textDocument/rename": 5}}}) + let cm = place(ed(client, {doc: "let foo = 1; console.log(foo)", selection: {anchor: 4}})) + await wait() + ist(renameSymbol(cm), true) + let form = cm.dom.querySelector(".cm-panel form") as HTMLFormElement + form.querySelector("input")!.value = "bar" + form.requestSubmit() + await wait() + cm.dispatch({changes: {from: 0, insert: " "}}) + await wait(10) + ist(cm.state.sliceDoc(), " let bar = 1; console.log(bar)") + cm.destroy() + }) + }) + + describe("formatDocument", () => { + it("can make format requests", async () => { + let {client} = setup() + let cm = ed(client, {doc: "hawthorn"}) + formatDocument(cm) + await wait() + ist(cm.state.sliceDoc(), "hawthorn\n// formatted!") + }) + }) + + describe("completion", () => { + it("can get completions from the server", async () => { + let {client} = setup() + let cm = ed(client, {doc: "..o", selection: {anchor: 3}, extensions: [ + serverCompletion(), + autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) + ]}) + startCompletion(cm) + await wait(60) + let cs = currentCompletions(cm.state) + ist(cs.length, 2) + ist(cs[0].label, "one") + ist(cs[1].label, "ookay") + acceptCompletion(cm) + ist(cm.state.sliceDoc(), "..one!") + cm.dispatch({changes: {from: 6, insert: "\no"}, userEvent: "input.type", selection: {anchor: 8}}) + await wait(20) + ist(currentCompletions(cm.state).length, 2) + moveCompletionSelection(true)(cm) + await wait() + ist(cm.dom.querySelector(".cm-completionInfo")) + acceptCompletion(cm) + ist(cm.state.sliceDoc(), "..one!\nokay") + }) + + it("expands snippet completions and unescapes LSP escapes", async () => { + let {client} = setup() + let cm = ed(client, {doc: "..f", selection: {anchor: 3}, extensions: [ + serverCompletion(), + autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) + ]}) + startCompletion(cm) + await wait(60) + let cs = currentCompletions(cm.state) + ist(cs.length, 1) + ist(cs[0].label, "fn") + acceptCompletion(cm) + // `\$arg` must come through as a literal `$arg`, not the raw `\$arg`. + ist(cm.state.sliceDoc(), "..fn($arg)") + }) + + it("applies additionalTextEdits (e.g. an auto-import)", async () => { + let {client} = setup() + let cm = ed(client, {doc: "..b", selection: {anchor: 3}, extensions: [ + serverCompletion(), + autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) + ]}) + startCompletion(cm) + await wait(60) + let cs = currentCompletions(cm.state) + ist(cs.length, 1) + ist(cs[0].label, "Bar") + acceptCompletion(cm) + // The completion inserts "Bar" *and* the item's additionalTextEdits add + // the `use Bar;` line at the top. + ist(cm.state.sliceDoc(), "use Bar;\n..Bar") + }) + + it("maps additionalTextEdit positions through changes that arrive while the completion is active", async () => { + let {client} = setup() + // Server's "Zap" completion ships an additionalTextEdit at line 2, + // char 0 — i.e. the start of "L2" in this document. + let cm = ed(client, {doc: "L0\nL1\nL2\n..z", selection: {anchor: 12}, extensions: [ + serverCompletion(), + autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) + ]}) + startCompletion(cm) + await wait(60) + let cs = currentCompletions(cm.state) + ist(cs.length, 1) + ist(cs[0].label, "Zap") + // Insert a line at the very top. This is far from the cursor, so CM + // keeps the completion alive — but the LSP edit's `line: 2` now refers + // to a different line in the new document. + cm.dispatch({changes: {from: 0, insert: "HEADER\n"}}) + acceptCompletion(cm) + // The "MID\n" insertion should land before the *original* L2 line, + // which after the HEADER insertion is the third line. + ist(cm.state.sliceDoc(), "HEADER\nL0\nL1\nMID\nL2\n..Zap") + }) + }) + + describe("hoverTooltips", () => { + it("can retrieve hover info", async () => { + let {client} = setup() + let cm = place(ed(client, {doc: "speedwell", extensions: [ + hoverTooltips({hoverTime: 10}), + javascript(), + syntaxHighlighting(classHighlighter) + ]})) + let pos = cm.coordsAtPos(1)!, x = pos.left, y = pos.top + 5 + cm.contentDOM.firstChild!.dispatchEvent(new MouseEvent("mousemove", { + screenX: x, screenY: y, + clientX: x, clientY: y, + bubbles: true + })) + await wait(15) + ist(cm.dom.querySelector(".cm-tooltip .tok-string")) + cm.destroy() + }) + }) + + describe("signatureHelp", () => { + it("can display a signature", async () => { + let {client} = setup() + let cm = ed(client, {doc: "bugloss"}) + showSignatureHelp(cm) + await wait() + ist(cm.dom.querySelector(".cm-lsp-active-parameter")!.innerHTML, "b") + nextSignature(cm) + ist(cm.dom.querySelector(".cm-lsp-active-parameter")!.innerHTML, "y") + }) + }) +}) 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..335b446d9 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; @@ -105,17 +112,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 +175,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 +195,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 +215,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 +255,18 @@ function applyDiagnostics( } const view = file.getView(); if (!view) return false; - const plugin = LSPPlugin.get(view) as LSPPluginAPI | null; + const plugin = LSPPlugin.get(view, client) as LSPPluginAPI | null; if (!plugin) return false; const diagnostics = collectLspDiagnostics(plugin, rawDiagnostics); - const current = view.state.field(lspPublishedDiagnostics, false) ?? []; + 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; @@ -370,26 +412,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)) { + plugin.client.sync(); + schedulePullDiagnostics(plugin.client, plugin.uri, 0); + } } destroy(): void { @@ -424,35 +473,13 @@ 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) { + if (LSPPlugin.get(view, client)) diagnostics.push(...items); + } + return diagnostics; } export function lspDiagnosticsClientExtension(): { @@ -552,16 +579,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..d1f3179eb 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, @@ -368,22 +369,51 @@ 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) => + plugin.client.hasCapability?.("hoverProvider") !== false, + ); + 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, @@ -461,6 +491,7 @@ class SignatureState { readonly data: LspSignatureHelp, readonly active: number, readonly tooltip: Tooltip, + readonly client: LSPClient, ) {} } @@ -468,17 +499,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 +530,9 @@ const signatureState = StateField.define({ effect.value.data, effect.value.active, effect.value.pos, + effect.value.client, ), + effect.value.client, ); } return null; @@ -505,10 +540,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 +561,7 @@ function drawSignatureTooltip( view: EditorView, data: LspSignatureHelp, active: number, + client: LSPClient, ) { const dom = document.createElement("div"); dom.className = "cm-lsp-signature-tooltip"; @@ -570,7 +611,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 +643,10 @@ const signaturePlugin = ViewPlugin.fromClass( } } - const plugin = LSPPlugin.get(update.view); + const plugin = LSPPlugin.getAll(update.view, "signatureHelp").find( + (candidate) => + candidate.client.hasCapability?.("signatureHelpProvider") !== false, + ); if (!plugin) return; const sigState = update.view.state.field(signatureState); @@ -700,6 +744,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 +805,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) => + candidate.client.hasCapability?.("signatureHelpProvider") !== false, + ); if (!lspPlugin) return false; plugin.startRequest(lspPlugin, { @@ -780,6 +828,7 @@ export const nextSignature: Command = (view) => { data: field.data, active: field.active + 1, pos: field.tooltip.pos, + client: field.client, }), }); } @@ -795,6 +844,7 @@ export const prevSignature: Command = (view) => { data: field.data, active: field.active - 1, pos: field.tooltip.pos, + client: field.client, }), }); } @@ -807,7 +857,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..04b5d78fa --- /dev/null +++ b/tests/unit/lspMultiClient.test.js @@ -0,0 +1,259 @@ +// @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 {afterEach, describe, expect, it, vi} from "vitest"; + +const views = []; + +afterEach(() => { + while (views.length) views.pop().destroy(); + document.body.replaceChildren(); + 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"]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 98c8a285d..26dd0ac13 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ "jsx": "preserve", "types": ["node"] }, - "include": ["src/**/*"], + "include": ["src/**/*", "packages/lsp-client/src/**/*"], "exclude": [ "node_modules", "platforms", From c4a7f57073ad866109d94b87043d1a53403002a9 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:22:56 +0530 Subject: [PATCH 2/5] fix: spell --- _typos.toml | 1 + packages/lsp-client/src/client.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/packages/lsp-client/src/client.ts b/packages/lsp-client/src/client.ts index 0892bba03..bee1c2eff 100644 --- a/packages/lsp-client/src/client.ts +++ b/packages/lsp-client/src/client.ts @@ -387,7 +387,7 @@ export class LSPClient { /// The caller is responsible for /// [synchronizing](#lsp-client.LSPClient.sync) state before the /// request and correctly handling state drift caused by local - /// changes that happend during the request. + /// changes that happened during the request. request(method: string, params: Params): Promise { if (!this.transport) return Promise.reject(new Error("Client not connected")) return this.initializing.then(() => this.requestInner(method, params).promise) From fe5784ae0f76fd711d7906b4066216080fde0585 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:28:36 +0530 Subject: [PATCH 3/5] fix(lsp): honor diagnostics provider opt-out --- src/cm/lsp/diagnostics.ts | 29 ++++++++++--- tests/unit/lspMultiClient.test.js | 69 +++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/cm/lsp/diagnostics.ts b/src/cm/lsp/diagnostics.ts index 335b446d9..906c50516 100644 --- a/src/cm/lsp/diagnostics.ts +++ b/src/cm/lsp/diagnostics.ts @@ -61,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 { @@ -255,10 +262,13 @@ function applyDiagnostics( } const view = file.getView(); if (!view) return false; - const plugin = LSPPlugin.get(view, client) 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 diagnostics = collectLspDiagnostics( + plugin as unknown as LSPPluginAPI, + rawDiagnostics, + ); const current = view.state.field(lspPublishedDiagnostics, false)?.get(client) ?? []; if (sameDiagnostics(current, diagnostics)) { @@ -277,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; @@ -354,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 @@ -435,7 +451,7 @@ export function lspDiagnosticsAutoSyncExtension(): Extension { flush(): void { this.pending = null; - for (const plugin of LSPPlugin.getAll(this.view)) { + for (const plugin of LSPPlugin.getAll(this.view, "diagnostics")) { plugin.client.sync(); schedulePullDiagnostics(plugin.client, plugin.uri, 0); } @@ -477,7 +493,8 @@ function lspLinterSource(view: EditorView): Diagnostic[] { const stored = view.state.field(lspPublishedDiagnostics); const diagnostics: Diagnostic[] = []; for (const [client, items] of stored) { - if (LSPPlugin.get(view, client)) diagnostics.push(...items); + const plugin = LSPPlugin.get(view, client); + if (plugin?.featureEnabled("diagnostics")) diagnostics.push(...items); } return diagnostics; } diff --git a/tests/unit/lspMultiClient.test.js b/tests/unit/lspMultiClient.test.js index 04b5d78fa..d118a3134 100644 --- a/tests/unit/lspMultiClient.test.js +++ b/tests/unit/lspMultiClient.test.js @@ -11,6 +11,13 @@ import { 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 = []; @@ -18,6 +25,7 @@ const views = []; afterEach(() => { while (views.length) views.pop().destroy(); document.body.replaceChildren(); + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -256,4 +264,65 @@ describe("multiple LSP clients on one editor", () => { 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([]); + }); }); From 19edac718a32268cc2a6584c19415d580beba209 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:33:36 +0530 Subject: [PATCH 4/5] refactor(lsp): consume standalone multi-server client --- bun.lock | 4 +- package-lock.json | 33 +- package.json | 6 +- packages/lsp-client/CHANGELOG.md | 91 ---- packages/lsp-client/LICENSE | 21 - packages/lsp-client/README.md | 95 ---- packages/lsp-client/UPSTREAM.md | 25 - packages/lsp-client/package.json | 31 -- packages/lsp-client/src/README.md | 67 --- packages/lsp-client/src/client.ts | 512 --------------------- packages/lsp-client/src/completion.ts | 287 ------------ packages/lsp-client/src/definition.ts | 85 ---- packages/lsp-client/src/diagnostics.ts | 60 --- packages/lsp-client/src/formatting.ts | 53 --- packages/lsp-client/src/hover.ts | 94 ---- packages/lsp-client/src/index.ts | 69 --- packages/lsp-client/src/plugin.ts | 270 ----------- packages/lsp-client/src/pos.ts | 20 - packages/lsp-client/src/references.ts | 200 -------- packages/lsp-client/src/rename.ts | 77 ---- packages/lsp-client/src/signature.ts | 274 ----------- packages/lsp-client/src/text.ts | 56 --- packages/lsp-client/src/theme.ts | 79 ---- packages/lsp-client/src/workspace.ts | 161 ------- packages/lsp-client/test/server.ts | 207 --------- packages/lsp-client/test/webtest-client.ts | 373 --------------- src/cm/lsp/tooltipExtensions.ts | 22 +- tsconfig.json | 2 +- 28 files changed, 35 insertions(+), 3239 deletions(-) delete mode 100644 packages/lsp-client/CHANGELOG.md delete mode 100644 packages/lsp-client/LICENSE delete mode 100644 packages/lsp-client/README.md delete mode 100644 packages/lsp-client/UPSTREAM.md delete mode 100644 packages/lsp-client/package.json delete mode 100644 packages/lsp-client/src/README.md delete mode 100644 packages/lsp-client/src/client.ts delete mode 100644 packages/lsp-client/src/completion.ts delete mode 100644 packages/lsp-client/src/definition.ts delete mode 100644 packages/lsp-client/src/diagnostics.ts delete mode 100644 packages/lsp-client/src/formatting.ts delete mode 100644 packages/lsp-client/src/hover.ts delete mode 100644 packages/lsp-client/src/index.ts delete mode 100644 packages/lsp-client/src/plugin.ts delete mode 100644 packages/lsp-client/src/pos.ts delete mode 100644 packages/lsp-client/src/references.ts delete mode 100644 packages/lsp-client/src/rename.ts delete mode 100644 packages/lsp-client/src/signature.ts delete mode 100644 packages/lsp-client/src/text.ts delete mode 100644 packages/lsp-client/src/theme.ts delete mode 100644 packages/lsp-client/src/workspace.ts delete mode 100644 packages/lsp-client/test/server.ts delete mode 100644 packages/lsp-client/test/webtest-client.ts diff --git a/bun.lock b/bun.lock index 12941fb5f..d3866fb7c 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": "file:packages/lsp-client", + "@codemirror/lsp-client": "git+https://github.com/Acode-Foundation/codemirror-lsp-client.git#db6930c2b6db67e14978f08ae8f5aeb149d29468", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", @@ -426,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@file:packages/lsp-client", { "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" } }], + "@codemirror/lsp-client": ["@codemirror/lsp-client@git+https://github.com/Acode-Foundation/codemirror-lsp-client.git#db6930c2b6db67e14978f08ae8f5aeb149d29468", { "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" } }, "db6930c2b6db67e14978f08ae8f5aeb149d29468"], "@codemirror/search": ["@codemirror/search@6.7.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA=="], diff --git a/package-lock.json b/package-lock.json index eaa2514c9..2b9602ef7 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": "file:packages/lsp-client", + "@codemirror/lsp-client": "git+https://github.com/Acode-Foundation/codemirror-lsp-client.git#db6930c2b6db67e14978f08ae8f5aeb149d29468", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", @@ -3965,8 +3965,20 @@ } }, "node_modules/@codemirror/lsp-client": { - "resolved": "packages/lsp-client", - "link": true + "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", + "@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" + } }, "node_modules/@codemirror/search": { "version": "6.7.1", @@ -18632,21 +18644,6 @@ "node": ">= 6" } }, - "packages/lsp-client": { - "name": "@codemirror/lsp-client", - "version": "6.2.5-acode.1", - "license": "MIT", - "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" - } - }, "src/plugins/auth": { "name": "com.foxdebug.acode.rk.auth", "version": "1.0.0", diff --git a/package.json b/package.json index 87430c1c4..8710b5d94 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": {}, @@ -147,7 +149,7 @@ "@codemirror/language-data": "^6.5.2", "@codemirror/legacy-modes": "^6.5.3", "@codemirror/lint": "^6.9.7", - "@codemirror/lsp-client": "file:packages/lsp-client", + "@codemirror/lsp-client": "git+https://github.com/Acode-Foundation/codemirror-lsp-client.git#db6930c2b6db67e14978f08ae8f5aeb149d29468", "@codemirror/search": "^6.7.1", "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/packages/lsp-client/CHANGELOG.md b/packages/lsp-client/CHANGELOG.md deleted file mode 100644 index 95fdefb29..000000000 --- a/packages/lsp-client/CHANGELOG.md +++ /dev/null @@ -1,91 +0,0 @@ -## 6.2.5-acode.1 (2026-08-04) - -### New features - -Allow multiple LSP clients to bind to one editor, with per-client feature -selection, priority routing, independent document synchronization, and merged -completion and hover providers. - -## 6.2.5 (2026-06-09) - -### Bug fixes - -Strip backslash escapes in snippets in places where CodeMirror doesn't support them. - -Support the `extraTextEdits` field in completions. - -## 6.2.4 (2026-05-15) - -### Bug fixes - -Tell the server we prefer Markdown docs over plain text. - -Support completion-set-wide `insertTextFormat` options. - -Support separate filter and display names for completions. - -## 6.2.3 (2026-04-15) - -### Bug fixes - -Don't crash when trying to jump to a symbol that the server does not provide a location for. - -Fix the argument passed to cancel requests. - -## 6.2.2 (2026-03-02) - -### Bug fixes - -In completion snippets from the server, treat `$1` the same as `${1}`. - -## 6.2.1 (2025-11-20) - -### Bug fixes - -Support `sortText` properties on LSP completions. - -## 6.2.0 (2025-10-27) - -### New features - -`serverCompletion` now takes an option `validFor` that can be used to configure the regexp used in the result. - -## 6.1.2 (2025-09-10) - -### Bug fixes - -Fix an issue where snippet completions would display the snippet source as their label. - -## 6.1.1 (2025-09-02) - -### Bug fixes - -Properly declare the @codemirror/lint dependency. - -Make sure document changes are eagerly pushed to the server when `serverDiagnostics` is active. - -## 6.1.0 (2025-08-23) - -### New features - -`LSPClient` now accepts an array of extensions directly in its configuration. These can also add behavior (client capabilities and notification handlers) to the client itself. - -The new `serverDiagnostics` extension makes the client receive diagnostics from the server, and show them via the CodeMirror linter. - -The new `languageServerExtensions` function provides an extension bundle interface, that works for client extensions as well as editor extensions. - -`LSPClient` now has a `plugin` method for conveniently creating an editor extension. `LSPPlugin.create` is deprecated in favor of this method. - -## 6.0.1 (2025-08-05) - -### Bug fixes - -Fix a bug that prevented reuse of completions when adding to the completed identifier. - -Properly render plain strings included in arrays for a hover tooltip's `content` data as Markdown content. - -## 6.0.0 (2025-07-02) - -### Breaking changes - -First numbered release. diff --git a/packages/lsp-client/LICENSE b/packages/lsp-client/LICENSE deleted file mode 100644 index 75a4b6348..000000000 --- a/packages/lsp-client/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (C) 2025 by Marijn Haverbeke and others - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/packages/lsp-client/README.md b/packages/lsp-client/README.md deleted file mode 100644 index 5831c8b4a..000000000 --- a/packages/lsp-client/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# @codemirror/lsp-client [![NPM version](https://img.shields.io/npm/v/@codemirror/lsp-client.svg)](https://www.npmjs.org/package/@codemirror/lsp-client) - -[ [**WEBSITE**](https://codemirror.net/) | [**DOCS**](https://codemirror.net/docs/ref/#lsp-client) | [**ISSUES**](https://code.haverbeke.berlin/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/v6/) | [**CHANGELOG**](https://code.haverbeke.berlin/codemirror/lsp-client/src/branch/main/CHANGELOG.md) ] - -This package implements a language server protocol (LSP) client for -the [CodeMirror](https://codemirror.net/) code editor. - -The [project page](https://codemirror.net/) has more information, a -number of [examples](https://codemirror.net/examples/) and the -[documentation](https://codemirror.net/docs/ref/#lsp-client). - -This code is released under an -[MIT license](https://code.haverbeke.berlin/codemirror/lsp-client/tree/main/LICENSE). - -We aim to be an inclusive, welcoming community. To make that explicit, -we have a [code of -conduct](http://contributor-covenant.org/version/1/1/0/) that applies -to communication around the project. - -## Usage - -There are various ways to run a language server and connect it to a -web page. You can run it on the server and proxy it through a web -socket, or, if it is written in JavaScript or can be compiled to WASM, -run it directly in the client. The @codemirror/lsp-client package -talks to the server through a ([`Transport`](#lsp-client.Transport)) -object, which exposes a small interface for sending and receiving JSON -messages. - -Responsibility for how to actually talk to the server, how to connect -and to handle disconnects are left to the code that implements the -transport. - -## Multiple servers per editor (Acode fork) - -This in-tree Acode fork allows several independent clients to attach to the -same `EditorView`. For example, TypeScript can be the primary provider while -Tailwind CSS contributes completions, hover information, and diagnostics. - -```javascript -new EditorView({ - extensions: [ - typescriptClient.plugin(uri, "typescriptreact", {priority: 100}), - tailwindClient.plugin(uri, "typescriptreact", { - priority: 50, - features: {formatting: false, rename: false} - }) - ] -}) -``` - -Bindings are ordered by descending `priority`. Completion and hover results -are combined across eligible providers. Features that need one owner, such as -formatting, rename, navigation, and signature help, use the first capable -eligible binding. A feature is enabled unless its binding sets that feature to -`false`. - -Document edits are recorded once per editor. Each client keeps an independent -sync revision, and its pending `ChangeSet` is composed lazily only when that -client synchronizes. Thus, attaching idle supplemental clients does not add a -per-client loop to the typing path. - -This example uses a crude transport that doesn't handle errors at all. - - -```javascript -import {Transport, LSPClient, languageServerExtensions} from "@codemirror/lsp-client" -import {basicSetup, EditorView} from "codemirror" -import {typescriptLanguage} from "@codemirror/lang-javascript" - -function simpleWebSocketTransport(uri: string): Promise { - let handlers: ((value: string) => void)[] = [] - let sock = new WebSocket(uri) - sock.onmessage = e => { for (let h of handlers) h(e.data.toString()) } - return new Promise(resolve => { - sock.onopen = () => resolve({ - send(message: string) { sock.send(message) }, - subscribe(handler: (value: string) => void) { handlers.push(handler) }, - unsubscribe(handler: (value: string) => void) { handlers = handlers.filter(h => h != handler) } - }) - }) -} - -let transport = await simpleWebSocketTransport("ws://host:port") -let client = new LSPClient({extensions: languageServerExtensions()}).connect(transport) - -new EditorView({ - extensions: [ - basicSetup, - typescriptLanguage, - client.plugin("file:///some/file.ts"), - ], - parent: document.body -}) -``` diff --git a/packages/lsp-client/UPSTREAM.md b/packages/lsp-client/UPSTREAM.md deleted file mode 100644 index 396748243..000000000 --- a/packages/lsp-client/UPSTREAM.md +++ /dev/null @@ -1,25 +0,0 @@ -# Upstream - -- Repository: https://code.haverbeke.berlin/codemirror/lsp-client -- Imported package: `@codemirror/lsp-client@6.2.5` -- License: MIT; the original notice is preserved in `LICENSE` -- Imported on: 2026-08-04 - -This directory is an in-tree Acode fork. Its primary change is support for -attaching multiple independent LSP clients to one CodeMirror `EditorView`. - -Keep upstream imports and Acode changes in separate commits when practical. - -To sync a release: - -1. Download or clone the new upstream version into a temporary directory. -2. Diff its `src`, `test`, `README.md`, `CHANGELOG.md`, `LICENSE`, and - `package.json` against this directory. -3. Apply upstream-only changes first, keeping this package's local metadata and - the multi-client changes described in the Acode changelog. -4. Run Acode's typecheck, `tests/unit/lspMultiClient.test.js`, full unit suite, - and production build. - -The local implementation is deliberately confined to this package and Acode's -`src/cm/lsp` integration, so an upstream comparison does not require searching -generated bundles or patched files under `node_modules`. diff --git a/packages/lsp-client/package.json b/packages/lsp-client/package.json deleted file mode 100644 index a8781a8be..000000000 --- a/packages/lsp-client/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@codemirror/lsp-client", - "version": "6.2.5-acode.1", - "description": "Acode's vendored CodeMirror language server protocol client with multi-server support", - "private": true, - "keywords": [ - "codemirror", - "lsp" - ], - "license": "MIT", - "author": "Marijn Haverbeke ", - "type": "module", - "main": "src/index.ts", - "types": "src/index.ts", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "test": "vitest run" - }, - "dependencies": { - "marked": "^15.0.12", - "@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", - "vscode-languageserver-protocol": "^3.17.5" - } -} diff --git a/packages/lsp-client/src/README.md b/packages/lsp-client/src/README.md deleted file mode 100644 index e896d606a..000000000 --- a/packages/lsp-client/src/README.md +++ /dev/null @@ -1,67 +0,0 @@ -### Client - -@LSPClient - -@LSPClientConfig - -@LSPClientExtension - -@Transport - -@LSPPlugin - -@WorkspaceMapping - -### Workspaces - -@Workspace - -@WorkspaceFile - -### Extensions - -@languageServerExtensions - -@languageServerSupport - -@serverCompletion - -@serverCompletionSource - -@serverDiagnostics - -@hoverTooltips - -@formatDocument - -@formatKeymap - -@renameSymbol - -@renameKeymap - -@signatureHelp - -@showSignatureHelp - -@nextSignature - -@prevSignature - -@signatureKeymap - -@jumpToDefinition - -@jumpToDeclaration - -@jumpToTypeDefinition - -@jumpToImplementation - -@jumpToDefinitionKeymap - -@findReferences - -@closeReferencePanel - -@findReferencesKeymap diff --git a/packages/lsp-client/src/client.ts b/packages/lsp-client/src/client.ts deleted file mode 100644 index bee1c2eff..000000000 --- a/packages/lsp-client/src/client.ts +++ /dev/null @@ -1,512 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {showDialog} from "@codemirror/view" -import {ChangeSet, ChangeDesc, MapMode, Text, Extension} from "@codemirror/state" -import {Language} from "@codemirror/language" -import {LSPPlugin, LSPPluginOptions, lspPluginBinding} from "./plugin" -import {toPosition, fromPosition} from "./pos" -import {Workspace, WorkspaceFile, DefaultWorkspace} from "./workspace" -import {lspTheme} from "./theme" - -class Request { - declare resolve: (result: Result) => void - declare reject: (error: any) => void - promise: Promise - - constructor( - readonly id: number, - readonly params: any, - readonly timeout: ReturnType - ) { - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve - this.reject = reject - }) - } -} - -const clientCapabilities: lsp.ClientCapabilities = { - general: { - markdown: { - parser: "marked", - }, - }, - textDocument: { - completion: { - completionItem: { - snippetSupport: true, - documentationFormat: ["markdown", "plaintext"], - insertReplaceSupport: false, - }, - completionList: { - itemDefaults: ["commitCharacters", "editRange", "insertTextFormat"] - }, - completionItemKind: {valueSet: []}, - contextSupport: true, - }, - hover: { - contentFormat: ["markdown", "plaintext"] - }, - formatting: {}, - rename: {}, - signatureHelp: { - contextSupport: true, - signatureInformation: { - documentationFormat: ["markdown", "plaintext"], - parameterInformation: {labelOffsetSupport: true}, - activeParameterSupport: true, - }, - }, - definition: {}, - declaration: {}, - implementation: {}, - typeDefinition: {}, - references: {}, - diagnostic: {}, - }, - window: { - showMessage: {} - } -} - -/// A workspace mapping is used to track changes made to open -/// documents, so that positions returned by a request can be -/// interpreted in terms of the current, potentially changed document. -export class WorkspaceMapping { - /// @internal - mappings: Map = new Map - private startDocs: Map = new Map - - /// @internal - constructor(private client: LSPClient) { - for (let file of client.workspace.files) { - this.mappings.set(file.uri, ChangeSet.empty(file.doc.length)) - this.startDocs.set(file.uri, file.doc) - } - } - - /// @internal - addChanges(uri: string, changes: ChangeDesc) { - let known = this.mappings.get(uri) - if (known) this.mappings.set(uri, known.composeDesc(changes)) - } - - /// Get the changes made to the document with the given URI since - /// the mapping was created. Returns null for documents that aren't - /// open. - getMapping(uri: string) { - let known = this.mappings.get(uri) - if (!known) return null - let file = this.client.workspace.getFile(uri), view = file?.getView(), plugin = view && LSPPlugin.get(view, this.client) - return plugin ? known.composeDesc(plugin.unsyncedChanges) : known - } - - /// Map a position in the given file forward to the current document state. - mapPos(uri: string, pos: number, assoc?: number): number - mapPos(uri: string, pos: number, assoc: number, mode: MapMode): number | null - mapPos(uri: string, pos: number, assoc = -1, mode: MapMode = MapMode.Simple): number | null { - let changes = this.getMapping(uri) - return changes ? changes.mapPos(pos, assoc, mode) : pos - } - - /// Convert an LSP-style position referring to a document at the - /// time the mapping was created to an offset in the current document. - mapPosition(uri: string, pos: lsp.Position, assoc?: number): number - mapPosition(uri: string, pos: lsp.Position, assoc: number, mode: MapMode): number | null - mapPosition(uri: string, pos: lsp.Position, assoc = -1, mode: MapMode = MapMode.Simple): number | null { - let start = this.startDocs.get(uri) - if (!start) throw new Error("Cannot map from a file that's not in the workspace") - let off = fromPosition(start, pos) - let changes = this.getMapping(uri) - return changes ? changes.mapPos(off, assoc, mode) : off - } - - /// Disconnect this mapping from the client so that it will no - /// longer be notified of new changes. You must make sure to call - /// this on every mapping you create, except when you use - /// [`withMapping`](#lsp-client.LSPClient.withMapping), which will - /// automatically schedule a disconnect when the given promise - /// resolves or aborts. - destroy() { - this.client.activeMappings = this.client.activeMappings.filter(m => m != this) - } -} - -/// An object of this type should be used to wrap whatever transport -/// layer you use to talk to your language server. Messages should -/// contain only the JSON messages, no LSP headers. -export type Transport = { - /// Send a message to the server. Should throw if the connection is - /// broken somehow. - send(message: string): void - /// Register a handler for messages coming from the server. - subscribe(handler: (value: string) => void): void - /// Unregister a handler registered with `subscribe`. - unsubscribe(handler: (value: string) => void): void -} - -const defaultNotificationHandlers: {[method: string]: (client: LSPClient, params: any) => void} = { - "window/logMessage": (client, params: lsp.LogMessageParams) => { - if (params.type == 1) console.error("[lsp] " + params.message) - else if (params.type == 2) console.warn("[lsp] " + params.message) - }, - "window/showMessage": (client, params: lsp.ShowMessageParams) => { - if (params.type > 3 /* Info */) return - let view - for (let f of client.workspace.files) if (view = f.getView()) break - if (view) showDialog(view, { - label: params.message, - class: "cm-lsp-message cm-lsp-message-" + (params.type == 1 ? "error" : params.type == 2 ? "warning" : "info"), - top: true - }) - } -} - -/// Configuration options that can be passed to the LSP client. -export type LSPClientConfig = { - /// The project root URI passed to the server, when necessary. - rootUri?: string - /// An optional function to create a - /// [workspace](#lsp-client.Workspace) object for the client to use. - /// When not given, this will default to a simple workspace that - /// only opens files that have an active editor, and only allows one - /// editor per file. - workspace?: (client: LSPClient) => Workspace - /// The amount of milliseconds after which requests are - /// automatically timed out. Defaults to 3000. - timeout?: number - /// LSP servers can send Markdown code, which the client must render - /// and display as HTML. Markdown can contain arbitrary HTML and is - /// thus a potential channel for cross-site scripting attacks, if - /// someone is able to compromise your LSP server or your connection - /// to it. You can pass an HTML sanitizer here to strip out - /// suspicious HTML structure. - sanitizeHTML?: (html: string) => string - /// By default, the Markdown renderer will only be able to highlght - /// code embedded in the Markdown text when its language tag matches - /// the name of the language used by the editor. You can provide a - /// function here that returns a CodeMirror language object for a - /// given language tag to support more languages. - highlightLanguage?: (name: string) => Language | null - /// By default, the client will only handle the server notifications - /// `window/logMessage` (logging warnings and errors to the console) - /// and `window/showMessage`. You can pass additional handlers here. - /// They will be tried before the built-in handlers, and override - /// those when they return true. - notificationHandlers?: {[method: string]: (client: LSPClient, params: any) => boolean} - /// When no handler is found for a notification, it will be passed - /// to this function, if given. - unhandledNotification?: (client: LSPClient, method: string, params: any) => void - /// Provide a set of extensions, which may be plain CodeMirror - /// extensions, or objects containing additional client capabilities - /// or notification handlers. Any CodeMirror extensions provided - /// here will be included in the extension returned by - /// [`LSPPlugin.create`](#lsp-client.LSPPlugin^create). - extensions?: readonly (Extension | LSPClientExtension)[] -} - -/// Objects of this type can be included in the -/// [`extensions`](#lsp-client.LSPClientConfig.extensions) option to -/// `LSPClient` to modularly configure client capabilities or -/// notification handlers. -export type LSPClientExtension = { - /// Extra [client - /// capabilities](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#clientCapabilities) - /// to send to the server when initializing. The object provided - /// here will be merged with the capabilities the client provides by - /// default. - clientCapabilities?: Record, - /// Additional [notification - /// handlers](#lsp-client.LSPClientConfig.notificationHandlers). - /// These will be tried after notification handlers defined directly - /// in the config object, and then in order of appearance in the - /// [`extensions`](#lsp-client.LSPClientConfig.extensions) array. - notificationHandlers?: {[method: string]: (client: LSPClient, params: any) => boolean}, - /// An optional CodeMirror extension to include. - editorExtension?: Extension -} - -/// An LSP client manages a connection to a language server. It should -/// be explicitly [connected](#lsp-client.LSPClient.connect) before -/// use. -export class LSPClient { - /// @internal - transport: Transport | null = null - /// The client's [workspace](#lsp-client.Workspace). - workspace: Workspace - private nextReqID = 0 - private requests: Request[] = [] - /// @internal - activeMappings: WorkspaceMapping[] = [] - /// The capabilities advertised by the server. Will be null when not - /// connected or initialized. - serverCapabilities: lsp.ServerCapabilities | null = null - private supportSync = -1 - /// A promise that resolves once the client connection is initialized. Will be - /// replaced by a new promise object when you call `disconnect`. - initializing: Promise - declare private init: {resolve: (value: null) => void, reject: (err: any) => void} - private timeout: number - /// @internal - extensions: Extension[] = [] - - /// Create a client object. - constructor( - /// @internal - readonly config: LSPClientConfig = {} - ) { - this.receiveMessage = this.receiveMessage.bind(this) - this.initializing = new Promise((resolve, reject) => this.init = {resolve, reject}) - this.timeout = config.timeout ?? 3000 - this.workspace = config.workspace ? config.workspace(this) : new DefaultWorkspace(this) - - if (config.extensions) for (let ext of config.extensions) { - if (Array.isArray(ext) || (ext as any).extension) this.extensions.push(ext as Extension) - else if ((ext as LSPClientExtension).editorExtension) this.extensions.push((ext as LSPClientExtension).editorExtension!) - } - } - - /// Whether this client is connected (has a transport). - get connected() { return !!this.transport } - - /// Connect this client to a server over the given transport. Will - /// immediately start the initialization exchange with the server, - /// and resolve `this.initializing` (which it also returns) when - /// successful. - connect(transport: Transport) { - if (this.transport) this.transport.unsubscribe(this.receiveMessage) - this.transport = transport - transport.subscribe(this.receiveMessage) - let capabilities = clientCapabilities - if (this.config.extensions) for (let ext of this.config.extensions) { - let {clientCapabilities} = ext as LSPClientExtension - if (clientCapabilities) capabilities = mergeCapabilities(capabilities, clientCapabilities) - } - this.requestInner("initialize", { - processId: null, - clientInfo: {name: "@codemirror/lsp-client"}, - rootUri: this.config.rootUri || null, - capabilities - }).promise.then(resp => { - this.serverCapabilities = resp.capabilities - let sync = resp.capabilities.textDocumentSync - this.supportSync = sync == null ? 0 : typeof sync == "number" ? sync : sync.change ?? 0 - transport.send(JSON.stringify({jsonrpc: "2.0", method: "initialized", params: {}})) - this.init.resolve(null) - }, this.init.reject) - this.workspace.connected() - return this - } - - /// Disconnect the client from the server. - disconnect() { - if (this.transport) this.transport.unsubscribe(this.receiveMessage) - this.serverCapabilities = null - this.initializing = new Promise((resolve, reject) => this.init = {resolve, reject}) - this.workspace.disconnected() - } - - /// Create a plugin for this client, to add to an editor - /// configuration. This extension is necessary to use LSP-related - /// functionality exported by this package. The returned extension - /// will include the editor - /// extensions included in this client's - /// [configuration](#lsp-client.LSPClientConfig.extensions). - /// - /// Creating an editor with this plugin will cause - /// [`openFile`](#lsp-client.Workspace.openFile) to be called on the - /// workspace. - /// - /// By default, the language ID given to the server for this file is - /// derived from the editor's language configuration via - /// [`Language.name`](#language.Language.name). You can pass in - /// a specific ID as a third parameter. - plugin(fileURI: string, languageID?: string, options?: LSPPluginOptions): Extension { - return [ - lspPluginBinding({client: this, uri: fileURI, languageID, options}), - lspTheme, - this.extensions - ] - } - - /// Send a `textDocument/didOpen` notification to the server. - didOpen(file: WorkspaceFile) { - this.notification("textDocument/didOpen", { - textDocument: { - uri: file.uri, - languageId: file.languageId, - text: file.doc.toString(), - version: file.version - } - }) - } - - /// Send a `textDocument/didClose` notification to the server. - didClose(uri: string) { - this.notification("textDocument/didClose", {textDocument: {uri}}) - } - - private receiveMessage(msg: string) { - const value = JSON.parse(msg) as lsp.ResponseMessage | lsp.NotificationMessage | lsp.RequestMessage - if ("id" in value && !("method" in value)) { - let index = this.requests.findIndex(r => r.id == value.id) - if (index < 0) { - console.warn(`[lsp] Received a response for non-existent request ${value.id}`) - } else { - let req = this.requests[index] - clearTimeout(req.timeout) - this.requests.splice(index, 1) - if (value.error) req.reject(value.error) - else req.resolve(value.result) - } - } else if (!("id" in value)) { - let handler = this.config.notificationHandlers?.[value.method] - if (handler && handler(this, value.params)) return - if (this.config.extensions) for (let ext of this.config.extensions) { - let {notificationHandlers} = ext as LSPClientExtension - let handler = notificationHandlers?.[value.method] - if (handler && handler(this, value.params)) return - } - let deflt = defaultNotificationHandlers[value.method] - if (deflt) deflt(this, value.params) - else if (this.config.unhandledNotification) this.config.unhandledNotification(this, value.method, value.params) - } else { - let resp: lsp.ResponseMessage = { - jsonrpc: "2.0", - id: value.id, - error: {code: -32601 /* MethodNotFound */, message: "Method not implemented"} - } - this.transport!.send(JSON.stringify(resp)) - } - } - - /// Make a request to the server. Returns a promise that resolves to - /// the response or rejects with a failure message. You'll probably - /// want to use types from the `vscode-languageserver-protocol` - /// package for the type parameters. - /// - /// The caller is responsible for - /// [synchronizing](#lsp-client.LSPClient.sync) state before the - /// request and correctly handling state drift caused by local - /// changes that happened during the request. - request(method: string, params: Params): Promise { - if (!this.transport) return Promise.reject(new Error("Client not connected")) - return this.initializing.then(() => this.requestInner(method, params).promise) - } - - private requestInner( - method: string, - params: Params, - mapped = false - ): Request { - let id = ++this.nextReqID, data: lsp.RequestMessage = { - jsonrpc: "2.0", - id, - method, - params: params as any - } - let req = new Request(id, params, setTimeout(() => this.timeoutRequest(req), this.timeout)) - this.requests.push(req) - try { this.transport!.send(JSON.stringify(data)) } - catch(e) { req.reject(e) } - return req - } - - /// Send a notification to the server. - notification(method: string, params: Params) { - if (!this.transport) return - this.initializing.then(() => { - let data: lsp.NotificationMessage = { - jsonrpc: "2.0", - method, - params: params as any - } - this.transport!.send(JSON.stringify(data)) - }) - } - - /// Cancel the in-progress request with the given parameter value - /// (which is compared by identity). - cancelRequest(params: any) { - let found = this.requests.find(r => r.params === params) - if (found) this.notification("$/cancelRequest", {id: found.id}) - } - - /// @internal - hasCapability(name: keyof lsp.ServerCapabilities) { - return this.serverCapabilities ? !!this.serverCapabilities[name] : null - } - - /// Create a [workspace mapping](#lsp-client.WorkspaceMapping) that - /// tracks changes to files in this client's workspace, relative to - /// the moment where it was created. Make sure you call - /// [`destroy`](#lsp-client.WorkspaceMapping.destroy) on the mapping - /// when you're done with it. - workspaceMapping() { - let mapping = new WorkspaceMapping(this) - this.activeMappings.push(mapping) - return mapping - } - - /// Run the given promise with a [workspace - /// mapping](#lsp-client.WorkspaceMapping) active. Automatically - /// release the mapping when the promise resolves or rejects. - withMapping(f: (mapping: WorkspaceMapping) => Promise): Promise { - let mapping = this.workspaceMapping() - return f(mapping).finally(() => mapping.destroy()) - } - - /// Push any [pending changes](#lsp-client.Workspace.syncFiles) in - /// the open files to the server. You'll want to call this before - /// most types of requests, to make sure the server isn't working - /// with outdated information. - sync() { - for (let {file, changes, prevDoc} of this.workspace.syncFiles()) { - for (let mapping of this.activeMappings) - mapping.addChanges(file.uri, changes) - if (this.supportSync) this.notification("textDocument/didChange", { - textDocument: {uri: file.uri, version: file.version}, - contentChanges: contentChangesFor(file, prevDoc, changes, this.supportSync == 2 /* Incremental */) - }) - } - } - - private timeoutRequest(req: Request) { - let index = this.requests.indexOf(req) - if (index > -1) { - req.reject(new Error("Request timed out")) - this.requests.splice(index, 1) - } - } -} - -const enum Sync { AlwaysIfSmaller = 1024 } - -function contentChangesFor( - file: WorkspaceFile, - startDoc: Text, - changes: ChangeSet, - supportInc: boolean -): lsp.TextDocumentContentChangeEvent[] { - if (!supportInc || file.doc.length < Sync.AlwaysIfSmaller) - return [{text: file.doc.toString()}] - let events: lsp.TextDocumentContentChangeEvent[] = [] - changes.iterChanges((fromA, toA, fromB, toB, inserted) => { - events.push({ - range: {start: toPosition(startDoc, fromA), end: toPosition(startDoc, toA)}, - text: inserted.toString() - }) - }) - return events.reverse() -} - -function mergeCapabilities(base: any, add?: any) { - if (add == null) return base - if (typeof base != "object" || typeof add != "object") return add - let result: Record = {} - let baseProps = Object.keys(base), addProps = Object.keys(add) - for (let prop of baseProps) - result[prop] = addProps.indexOf(prop) > -1 ? mergeCapabilities(base[prop], add[prop]) : base[prop] - for (let prop of addProps) - if (baseProps.indexOf(prop) < 0) result[prop] = add[prop] - return result -} diff --git a/packages/lsp-client/src/completion.ts b/packages/lsp-client/src/completion.ts deleted file mode 100644 index 1a40af582..000000000 --- a/packages/lsp-client/src/completion.ts +++ /dev/null @@ -1,287 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {EditorState, Extension, Facet, ChangeDesc, ChangeSpec} from "@codemirror/state" -import {EditorView} from "@codemirror/view" -import {CompletionSource, Completion, CompletionResult, CompletionContext, - snippet, autocompletion, insertCompletionText} from "@codemirror/autocomplete" -import {LSPPlugin} from "./plugin" -import {fromPositionChecked} from "./pos" - -// Convert from LSP's snippet syntax to @codemirror/autocompletion's -// one. Remove backslashes before `$}]`, which don't support backslash -// escapes in CodeMirror, and expand `$1` to `${1}`. -function lspToSnippet(text: string): string { - return text.replace(/\\([$}\\])|\$(\d+)/g, (_m, esc, field) => esc || `\${${field}}`) -} - -/// Register the [language server completion -/// source](#lsp-client.serverCompletionSource) as an autocompletion -/// source. -let defaultServerCompletion: Extension | null = null - -export function serverCompletion(config: { - /// By default, the completion source that asks the language server - /// for completions is added as a regular source, in addition to any - /// other sources. Set this to true to make it replace all - /// completion sources. - override?: boolean - /// Set a custom - /// [`validFor`](#autocomplete.CompletionResult.validFor) expression - /// to use in the completion results. By default, the library uses an - /// expression that accepts word characters, optionally prefixed by - /// any non-word prefixes found in the results. - validFor?: RegExp -} = {}): Extension { - if (!config.override && !config.validFor && defaultServerCompletion) - return defaultServerCompletion - let result: Extension[] - if (config.override) { - result = [autocompletion({override: [serverCompletionSource]})] - } else { - let data = [{autocomplete: serverCompletionSource}] - result = [autocompletion(), EditorState.languageData.of(() => data)] - } - if (config.validFor) result.push(completionConfig.of({validFor: config.validFor})) - if (!config.override && !config.validFor) defaultServerCompletion = result - return result -} - -const completionConfig = Facet.define<{validFor: RegExp}, {validFor: RegExp | null}>({ - combine: results => results.length ? results[0] : {validFor: null} -}) - -function getCompletions(plugin: LSPPlugin, pos: number, context: lsp.CompletionContext, abort?: CompletionContext) { - if (plugin.client.hasCapability("completionProvider") === false) return Promise.resolve(null) - plugin.client.sync() - let params: lsp.CompletionParams = { - position: plugin.toPosition(pos), - textDocument: {uri: plugin.uri}, - context - } - if (abort) abort.addEventListener("abort", () => plugin.client.cancelRequest(params)) - return plugin.client.request( - "textDocument/completion", params) -} - -// Look for non-alphanumeric prefixes in the completions, and return a -// regexp that matches them, to use in validFor -function prefixRegexp(items: readonly lsp.CompletionItem[]) { - let step = Math.ceil(items.length / 50), prefixes: string[] = [] - for (let i = 0; i < items.length; i += step) { - let item = items[i], text = item.textEdit?.newText || item.textEditText || item.insertText || item.label - if (!/^\w/.test(text)) { - let prefix = /^[^\w]*/.exec(text)![0] - if (prefixes.indexOf(prefix) < 0) prefixes.push(prefix) - } - } - if (!prefixes.length) return /^\w*$/ - return new RegExp("^(?:" + prefixes.map((RegExp as any).escape || (s => s.replace(/[^\w\s]/g, "\\$&"))).join("|") + ")?\\w*$") -} - -function shouldTriggerCompletion(plugin: LSPPlugin, character: string) : "identifier" | "triggerCharacter" | null { - let triggers = plugin.client.serverCapabilities?.completionProvider?.triggerCharacters - if (triggers && triggers.indexOf(character) > -1) return "triggerCharacter" - if (/[a-zA-Z_]/.test(character)) return "identifier" - return null -} - -type TextEdit = {from: number, to: number, text: string} - -type ExtraEdits = {index: number, edits: readonly TextEdit[], text: string} - -function resultMapper(changes: ChangeDesc | null, extraEdits: ExtraEdits[]) { - return (result: CompletionResult, newChanges: ChangeDesc): CompletionResult => { - changes = changes ? changes.composeDesc(newChanges) : newChanges - let options = result.options.slice() - for (let {index, edits, text} of extraEdits) - options[index] = {...options[index], apply: applyEdits(edits, text, changes)} - return { - ...result, - options, - map: resultMapper(changes, extraEdits) - } - } -} - -function applyEdits(edits: readonly TextEdit[], text: string, mapped: ChangeDesc | null) { - return (view: EditorView, completion: Completion, from: number, to: number) => { - let base = insertCompletionText(view.state, text, from, to) - let changes: ChangeSpec[] = [] - for (let {from, to, text} of edits) { - if (mapped) { - if (mapped.touchesRange(from, to)) continue - let len = to - from - from = mapped.mapPos(from, 1) - to = from + len - } - changes.push({from, to, insert: text}) - } - view.dispatch(base, {changes}) - } -} - -function completionForPlugin(plugin: LSPPlugin, context: CompletionContext): Promise | null { - let triggerChar = context.state.sliceDoc(context.pos - 1, context.pos) - let triggerReason = context.explicit ? "invoked" : shouldTriggerCompletion(plugin, triggerChar) - if (!triggerReason) return null - let completionContext: lsp.CompletionContext = triggerReason == "triggerCharacter" - ? {triggerKind: 2 /* TriggerCharacter */, triggerCharacter: triggerChar} - : {triggerKind: 1 /* Invoked */} - return getCompletions(plugin, context.pos, completionContext, context).then(result => { - if (!result) return null - if (Array.isArray(result)) result = {items: result} as lsp.CompletionList - let {from, to} = completionResultRange(context, result) - let defaultCommitChars = result.itemDefaults?.commitCharacters - let config = context.state.facet(completionConfig) - let extraEdits: ExtraEdits[] = [] - - return { - from, to, - options: result.items.map((item, i) => { - let text = item.textEdit?.newText || item.textEditText || item.insertText || item.label - let option: Completion = { - label: item.filterText || item.label, - displayLabel: item.label, - type: item.kind && kindToType[item.kind], - } - let insertTextFormat = item.insertTextFormat ?? result.itemDefaults?.insertTextFormat - if (item.commitCharacters && item.commitCharacters != defaultCommitChars) - option.commitCharacters = item.commitCharacters - if (item.detail) option.detail = item.detail - if (item.sortText) option.sortText = item.sortText - if (insertTextFormat == 2 /* Snippet */) { - option.apply = (view, c, from, to) => snippet(lspToSnippet(text))(view, c, from, to) - } else if (item.additionalTextEdits) { - let edits: TextEdit[] = [] - for (let edit of item.additionalTextEdits) { - let from = fromPositionChecked(context.state.doc, edit.range.start) - let to = fromPositionChecked(context.state.doc, edit.range.end) - if (from != null && to != null) edits.push({from, to, text: edit.newText}) - } - extraEdits.push({index: i, text, edits}) - option.apply = applyEdits(edits, text, null) - } else if (option.label != text) { - option.apply = text - } - if (item.documentation) option.info = () => renderDocInfo(plugin, item.documentation!) - return option - }), - commitCharacters: defaultCommitChars, - validFor: result.isIncomplete ? undefined : (config.validFor ?? prefixRegexp(result.items)), - map: extraEdits.length ? resultMapper(null, extraEdits) : undefined - } - }, err => { - if ("code" in err && (err as lsp.ResponseError).code == -32800 /* RequestCancelled */) - return null - throw err - }) -} - -function mappedOptionRange(option: Completion, source: CompletionResult, from: number, to: number): Completion { - const sourceFrom = source.from, sourceTo = source.to ?? sourceFrom - if (sourceFrom == from && sourceTo == to) return option - const apply = option.apply ?? option.label - return { - ...option, - apply(view, completion) { - if (typeof apply == "function") apply(view, completion, sourceFrom, sourceTo) - else view.dispatch(insertCompletionText(view.state, apply, sourceFrom, sourceTo)) - } - } -} - -function mergeCompletionResults(results: readonly CompletionResult[]): CompletionResult | null { - if (!results.length) return null - if (results.length == 1) return results[0] - - let from = results[0].from, to = results[0].to ?? results[0].from - for (let i = 1; i < results.length; i++) { - from = Math.min(from, results[i].from) - to = Math.max(to, results[i].to ?? results[i].from) - } - - const options: Completion[] = [], seen = new Set() - for (const result of results) { - for (const raw of result.options) { - const option = result.commitCharacters && !raw.commitCharacters - ? {...raw, commitCharacters: result.commitCharacters} - : raw - // Providers often return identical keyword entries. Keep the first one, - // which follows binding priority, while preserving distinct details. - const key = `${option.label}\u0000${option.detail || ""}\u0000${option.type || ""}` - if (seen.has(key)) continue - seen.add(key) - options.push(mappedOptionRange(option, result, from, to)) - } - } - - const firstValidFor = results[0].validFor - const validFor = results.every(result => String(result.validFor) == String(firstValidFor)) - ? firstValidFor - : undefined - return {from, to, options, validFor} -} - -/// A completion source that requests completions from every eligible language -/// server attached to the editor and combines their results. Requests are -/// independent so one failing supplemental server cannot suppress the others. -export const serverCompletionSource: CompletionSource = context => { - if (!context.view) return null - const plugins = LSPPlugin.getAll(context.view, "completion") - .filter(plugin => plugin.client.hasCapability("completionProvider") !== false) - if (!plugins.length) return null - const requests = plugins.map(plugin => completionForPlugin(plugin, context)) - .filter((request): request is Promise => !!request) - if (!requests.length) return null - if (requests.length == 1) return requests[0] - return Promise.allSettled(requests).then(settled => { - const results: CompletionResult[] = [] - for (const item of settled) { - if (item.status == "fulfilled") { - if (item.value) results.push(item.value) - } else if (!context.aborted) { - console.warn("[lsp] Completion provider failed", item.reason) - } - } - return mergeCompletionResults(results) - }) -} - -function completionResultRange(cx: CompletionContext, result: lsp.CompletionList): {from: number, to: number} { - if (!result.items.length) return {from: cx.pos, to: cx.pos} - let defaultRange = result.itemDefaults?.editRange, item0 = result.items[0] - let range = defaultRange ? ("insert" in defaultRange ? defaultRange.insert : defaultRange) - : item0.textEdit ? ("range" in item0.textEdit ? item0.textEdit.range : item0.textEdit.insert) - : null - if (!range) return cx.state.wordAt(cx.pos) || {from: cx.pos, to: cx.pos} - let line = cx.state.doc.lineAt(cx.pos) - return {from: line.from + range.start.character, to: line.from + range.end.character} -} - -function renderDocInfo(plugin: LSPPlugin, doc: string | lsp.MarkupContent) { - let elt = document.createElement("div") - elt.className = "cm-lsp-documentation cm-lsp-completion-documentation" - elt.innerHTML = plugin.docToHTML(doc) - return elt -} - -const kindToType: {[kind: number]: string} = { - 1: "text", // Text - 2: "method", // Method - 3: "function", // Function - 4: "class", // Constructor - 5: "property", // Field - 6: "variable", // Variable - 7: "class", // Class - 8: "interface", // Interface - 9: "namespace", // Module - 10: "property", // Property - 11: "keyword", // Unit - 12: "constant", // Value - 13: "constant", // Enum - 14: "keyword", // Keyword - 16: "constant", // Color - 20: "constant", // EnumMember - 21: "constant", // Constant - 22: "class", // Struct - 25: "type" // TypeParameter -} diff --git a/packages/lsp-client/src/definition.ts b/packages/lsp-client/src/definition.ts deleted file mode 100644 index 0fda59e0c..000000000 --- a/packages/lsp-client/src/definition.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {EditorView, Command, KeyBinding} from "@codemirror/view" -import {LSPPlugin} from "./plugin" - -function getDefinition(plugin: LSPPlugin, pos: number) { - return plugin.client.request("textDocument/definition", { - textDocument: {uri: plugin.uri}, - position: plugin.toPosition(pos) - }) -} - -function getDeclaration(plugin: LSPPlugin, pos: number) { - return plugin.client.request("textDocument/declaration", { - textDocument: {uri: plugin.uri}, - position: plugin.toPosition(pos) - }) -} - -function getTypeDefinition(plugin: LSPPlugin, pos: number) { - return plugin.client.request("textDocument/typeDefinition", { - textDocument: {uri: plugin.uri}, - position: plugin.toPosition(pos) - }) -} - -function getImplementation(plugin: LSPPlugin, pos: number) { - return plugin.client.request("textDocument/implementation", { - textDocument: {uri: plugin.uri}, - position: plugin.toPosition(pos) - }) -} - -function jumpToOrigin(view: EditorView, type: {get: typeof getDefinition, capability: keyof lsp.ServerCapabilities}): boolean { - const feature = type.capability == "declarationProvider" ? "declaration" - : type.capability == "typeDefinitionProvider" ? "typeDefinition" - : type.capability == "implementationProvider" ? "implementation" - : "definition" - const plugin = LSPPlugin.getAll(view, feature) - .find(plugin => plugin.client.hasCapability(type.capability) !== false) - if (!plugin || plugin.client.hasCapability(type.capability) === false) return false - plugin.client.sync() - plugin.client.withMapping(mapping => type.get(plugin, view.state.selection.main.head).then(response => { - let loc = Array.isArray(response) ? response[0] : response - if (!loc) return - - return (loc.uri == plugin.uri ? Promise.resolve(view) : plugin.client.workspace.displayFile(loc.uri)).then(target => { - if (!target) return - let pos = mapping.getMapping(loc.uri) ? mapping.mapPosition(loc.uri, loc.range.start) - : plugin.fromPosition(loc.range.start, target.state.doc) - target.dispatch({selection: {anchor: pos}, scrollIntoView: true, userEvent: "select.definition"}) - }) - }, error => plugin.reportError("Find definition failed", error))) - return true -} - -/// Jump to the definition of the symbol at the cursor. To support -/// cross-file jumps, you'll need to implement -/// [`Workspace.displayFile`](#lsp-client.Workspace.displayFile). -export const jumpToDefinition: Command = view => jumpToOrigin(view, { - get: getDefinition, - capability: "definitionProvider" -}) - -/// Jump to the declaration of the symbol at the cursor. -export const jumpToDeclaration: Command = view => jumpToOrigin(view, { - get: getDeclaration, - capability: "declarationProvider" -}) - -/// Jump to the type definition of the symbol at the cursor. -export const jumpToTypeDefinition: Command = view => jumpToOrigin(view, { - get: getTypeDefinition, - capability: "typeDefinitionProvider" -}) - -/// Jump to the implementation of the symbol at the cursor. -export const jumpToImplementation: Command = view => jumpToOrigin(view, { - get: getImplementation, - capability: "implementationProvider" -}) - -/// Binds F12 to [`jumpToDefinition`](#lsp-client.jumpToDefinition). -export const jumpToDefinitionKeymap: readonly KeyBinding[] = [ - {key: "F12", run: jumpToDefinition, preventDefault: true}, -] diff --git a/packages/lsp-client/src/diagnostics.ts b/packages/lsp-client/src/diagnostics.ts deleted file mode 100644 index 4f5e8021a..000000000 --- a/packages/lsp-client/src/diagnostics.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {forEachDiagnostic, setDiagnostics} from "@codemirror/lint" -import type {Diagnostic} from "@codemirror/lint" -import {ViewPlugin, ViewUpdate} from "@codemirror/view" -import {LSPPlugin} from "./plugin" -import type {LSPClient, LSPClientExtension} from "./client" - -function toSeverity(sev: lsp.DiagnosticSeverity): Diagnostic["severity"] { - return sev == 1 ? "error" : sev == 2 ? "warning" : sev == 3 ? "info" : "hint" -} - -const diagnosticClient = Symbol("lspDiagnosticClient") -type ClientDiagnostic = Diagnostic & {[diagnosticClient]?: LSPClient} - -const autoSync = ViewPlugin.fromClass(class { - pending: ReturnType | null = null - update(update: ViewUpdate) { - if (update.docChanged) { - if (this.pending != null) clearTimeout(this.pending) - this.pending = setTimeout(() => { - this.pending = null - LSPPlugin.syncAll(update.view) - }, 500) - } - } - destroy() { - if (this.pending != null) clearTimeout(this.pending) - } -}) - -export function serverDiagnostics(): LSPClientExtension { - return { - clientCapabilities: {textDocument: {publishDiagnostics: {versionSupport: true}}}, - notificationHandlers: { - "textDocument/publishDiagnostics": (client, params: lsp.PublishDiagnosticsParams) => { - let file = client.workspace.getFile(params.uri) - if (!file || params.version != null && params.version != file.version) return false - const view = file.getView(), plugin = view && LSPPlugin.get(view, client) - if (!view || !plugin) return false - // The lint package already maps stored diagnostics through editor - // changes. Preserve those current positions for every other provider - // and replace only this client's contribution. - const diagnostics: ClientDiagnostic[] = [] - forEachDiagnostic(view.state, (item: ClientDiagnostic, from, to) => { - if (item[diagnosticClient] !== client) diagnostics.push({...item, from, to}) - }) - diagnostics.push(...params.diagnostics.map(item => ({ - from: plugin.unsyncedChanges.mapPos(plugin.fromPosition(item.range.start, plugin.syncedDoc)), - to: plugin.unsyncedChanges.mapPos(plugin.fromPosition(item.range.end, plugin.syncedDoc)), - severity: toSeverity(item.severity ?? 1), - message: item.message, - [diagnosticClient]: client - }))) - view.dispatch(setDiagnostics(view.state, diagnostics)) - return true - } - }, - editorExtension: autoSync - } -} diff --git a/packages/lsp-client/src/formatting.ts b/packages/lsp-client/src/formatting.ts deleted file mode 100644 index 82fea1640..000000000 --- a/packages/lsp-client/src/formatting.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {Command, KeyBinding} from "@codemirror/view" -import {ChangeSpec} from "@codemirror/state" -import {indentUnit, getIndentUnit} from "@codemirror/language" -import {LSPPlugin} from "./plugin" - -function getFormatting(plugin: LSPPlugin, options: lsp.FormattingOptions) { - return plugin.client.request("textDocument/formatting", { - options, - textDocument: {uri: plugin.uri}, - }) -} - -/// This command asks the language server to reformat the document, -/// and then applies the changes it returns. -export const formatDocument: Command = view => { - const plugin = LSPPlugin.getAll(view, "formatting") - .find(plugin => plugin.client.hasCapability("documentFormattingProvider") !== false) - if (!plugin) return false - plugin.client.sync() - plugin.client.withMapping(mapping => getFormatting(plugin, { - tabSize: getIndentUnit(view.state), - insertSpaces: view.state.facet(indentUnit).indexOf("\t") < 0, - }).then(response => { - if (!response) return - let changed = mapping.getMapping(plugin.uri) - let changes: ChangeSpec[] = [] - for (let change of response) { - let from = mapping.mapPosition(plugin.uri, change.range.start) - let to = mapping.mapPosition(plugin.uri, change.range.end) - if (changed) { - // Don't try to apply the changes if code inside of any of them was touched - if (changed.touchesRange(from, to)) return - from = changed.mapPos(from, 1) - to = changed.mapPos(to, -1) - } - changes.push({from, to, insert: change.newText}) - } - view.dispatch({ - changes, - userEvent: "format" - }) - }, err => { - plugin.reportError("Formatting request failed", err) - })) - return true -} - -/// A keymap that binds Shift-Alt-f to -/// [`formatDocument`](#lsp-client.formatDocument). -export const formatKeymap: readonly KeyBinding[] = [ - {key: "Shift-Alt-f", run: formatDocument, preventDefault: true} -] diff --git a/packages/lsp-client/src/hover.ts b/packages/lsp-client/src/hover.ts deleted file mode 100644 index 516a2059d..000000000 --- a/packages/lsp-client/src/hover.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {EditorView, Tooltip, hoverTooltip} from "@codemirror/view" -import {Extension} from "@codemirror/state" -import {language as languageFacet, highlightingFor} from "@codemirror/language" -import {highlightCode} from "@lezer/highlight" -import {fromPosition} from "./pos" -import {escHTML} from "./text" -import {LSPPlugin} from "./plugin" - -/// Create an extension that queries the language server for hover -/// tooltips when the user hovers over the code with their pointer, -/// and displays a tooltip when the server provides one. -let defaultHoverTooltips: Extension | null = null - -export function hoverTooltips(config: {hoverTime?: number} = {}): Extension { - if (config.hoverTime == null && defaultHoverTooltips) return defaultHoverTooltips - const extension = hoverTooltip(lspTooltipSource, { - hideOn: tr => tr.docChanged, - hoverTime: config.hoverTime - }) - if (config.hoverTime == null) defaultHoverTooltips = extension - return extension -} - -function hoverRequest(plugin: LSPPlugin, pos: number) { - if (plugin.client.hasCapability("hoverProvider") === false) return Promise.resolve(null) - plugin.client.sync() - return plugin.client.request("textDocument/hover", { - position: plugin.toPosition(pos), - textDocument: {uri: plugin.uri}, - }) -} - -function lspTooltipSource(view: EditorView, pos: number): Promise { - const plugins = LSPPlugin.getAll(view, "hover") - .filter(plugin => plugin.client.hasCapability("hoverProvider") !== false) - if (!plugins.length) return Promise.resolve(null) - return Promise.allSettled(plugins.map(plugin => hoverRequest(plugin, pos))).then(settled => { - const results: {plugin: LSPPlugin, result: lsp.Hover}[] = [] - for (let i = 0; i < settled.length; i++) { - const item = settled[i] - if (item.status == "fulfilled" && item.value) results.push({plugin: plugins[i], result: item.value}) - else if (item.status == "rejected") console.warn("[lsp] Hover provider failed", item.reason) - } - if (!results.length) return null - let from = pos, to = pos - for (const {result} of results) if (result.range) { - from = Math.min(from, fromPosition(view.state.doc, result.range.start)) - to = Math.max(to, fromPosition(view.state.doc, result.range.end)) - } - return { - pos: from, - end: to, - create() { - let elt = document.createElement("div") - elt.className = "cm-lsp-hover-tooltip cm-lsp-documentation" - for (let i = 0; i < results.length; i++) { - if (i) elt.appendChild(document.createElement("hr")) - const section = elt.appendChild(document.createElement("div")) - section.innerHTML = renderTooltipContent(results[i].plugin, results[i].result.contents) - } - return {dom: elt} - }, - above: true - } - }) -} - -function renderTooltipContent( - plugin: LSPPlugin, - value: string | lsp.MarkupContent | lsp.MarkedString | lsp.MarkedString[] -) { - if (Array.isArray(value)) return value.map(m => renderCode(plugin, m)).join("
") - if (typeof value == "string" || typeof value == "object" && "language" in value) return renderCode(plugin, value) - return plugin.docToHTML(value) -} - -function renderCode(plugin: LSPPlugin, code: lsp.MarkedString) { - if (typeof code == "string") return plugin.docToHTML(code, "markdown") - let {language, value} = code - let lang = plugin.client.config.highlightLanguage && plugin.client.config.highlightLanguage(language || "") - if (!lang) { - let viewLang = plugin.view.state.facet(languageFacet) - if (viewLang && (!language || viewLang.name == language)) lang = viewLang - } - if (!lang) return escHTML(value) - let result = "" - highlightCode(value, lang.parser.parse(value), {style: tags => highlightingFor(plugin.view.state, tags)}, (text, cls) => { - result += cls ? `${escHTML(text)}` : escHTML(text) - }, () => { - result += "
" - }) - return result -} diff --git a/packages/lsp-client/src/index.ts b/packages/lsp-client/src/index.ts deleted file mode 100644 index edaadd48f..000000000 --- a/packages/lsp-client/src/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -export {LSPClient, WorkspaceMapping} from "./client" -export type {Transport, LSPClientConfig, LSPClientExtension} from "./client" -export {LSPPlugin} from "./plugin" -export type {LSPFeature, LSPPluginOptions} from "./plugin" -export {Workspace} from "./workspace" -export type {WorkspaceFile} from "./workspace" -export {serverCompletion, serverCompletionSource} from "./completion" -export {hoverTooltips} from "./hover" -export {formatDocument, formatKeymap} from "./formatting" -export {renameSymbol, renameKeymap} from "./rename" -export {signatureHelp, nextSignature, prevSignature, showSignatureHelp, signatureKeymap} from "./signature" -export {jumpToDefinition, jumpToDeclaration, jumpToTypeDefinition, jumpToImplementation, jumpToDefinitionKeymap} from "./definition" -export {findReferences, closeReferencePanel, findReferencesKeymap} from "./references" -export {serverDiagnostics} from "./diagnostics" - -import type {Extension} from "@codemirror/state" -import {keymap} from "@codemirror/view" -import {LSPClient} from "./client" -import type {LSPClientExtension} from "./client" -import {LSPPlugin} from "./plugin" -import {serverCompletion} from "./completion" -import {hoverTooltips} from "./hover" -import {formatKeymap} from "./formatting" -import {renameKeymap} from "./rename" -import {signatureHelp} from "./signature" -import {jumpToDefinitionKeymap} from "./definition" -import {findReferencesKeymap} from "./references" -import {serverDiagnostics} from "./diagnostics" - -const languageServerKeymap = keymap.of([ - ...formatKeymap, - ...renameKeymap, - ...jumpToDefinitionKeymap, - ...findReferencesKeymap -]) - -/// Returns an extension that enables the [LSP -/// plugin](#lsp-client.LSPPlugin) as well as LSP based -/// autocompletion, hover tooltips, and signature help, along with the -/// keymaps for reformatting, renaming symbols, jumping to definition, -/// and finding references. -/// -/// This function is deprecated. Prefer to directly use -/// [`LSPPlugin.create`](#lsp-client.LSPPlugin^create) and either add -/// the extensions you need directly, or configure them in the client -/// via [`languageServerExtensions`](#lsp-client.languageServerExtensions). -export function languageServerSupport(client: LSPClient, uri: string, languageID?: string): Extension { - return [ - LSPPlugin.create(client, uri, languageID), - serverCompletion(), - hoverTooltips(), - languageServerKeymap, - signatureHelp(), - ] -} - -/// This function bundles all the extensions defined in this package, -/// in a way that can be passed to the -/// [`extensions`](#lsp-client.LSPClientConfig.extensions) option to -/// `LSPClient`. -export function languageServerExtensions(): readonly (Extension | LSPClientExtension)[] { - return [ - serverCompletion(), - hoverTooltips(), - languageServerKeymap, - signatureHelp(), - serverDiagnostics() - ] -} diff --git a/packages/lsp-client/src/plugin.ts b/packages/lsp-client/src/plugin.ts deleted file mode 100644 index 152a02bbf..000000000 --- a/packages/lsp-client/src/plugin.ts +++ /dev/null @@ -1,270 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {EditorView, ViewPlugin, ViewUpdate, showDialog} from "@codemirror/view" -import {ChangeSet, Text, Extension, Facet} from "@codemirror/state" -import {language} from "@codemirror/language" -import {type LSPClient} from "./client" -import {docToHTML, withContext} from "./text" -import {toPosition, fromPosition} from "./pos" - -/// Features that can be independently enabled or disabled for an editor -/// binding. This lets a supplemental server contribute completions and -/// diagnostics without becoming the formatter or rename provider. -export type LSPFeature = - | "completion" - | "hover" - | "signatureHelp" - | "diagnostics" - | "formatting" - | "rename" - | "definition" - | "declaration" - | "typeDefinition" - | "implementation" - | "references" - | "codeAction" - | "documentSymbol" - | "inlayHint" - | "documentColor" - -/// Options controlling a client's role when several clients are attached to -/// the same editor. -export type LSPPluginOptions = { - /// Higher-priority clients are considered first for single-owner features. - priority?: number - /// Features default to enabled. Set a feature to false to exclude this - /// client from that feature's provider set. - features?: Partial> -} - -type LSPPluginSpec = { - client: LSPClient - uri: string - languageID?: string - options?: LSPPluginOptions -} - -type ChangeEntry = { - revision: number - changes: ChangeSet -} - -const lspBindings = Facet.define({ - combine: values => values -}) - -class LSPViewPlugin { - private revision = 0 - private changes: ChangeEntry[] = [] - private plugins: LSPPlugin[] = [] - - constructor(readonly view: EditorView) { - this.configure(view.state.facet(lspBindings)) - } - - update(update: ViewUpdate) { - // Record each editor transaction once, regardless of how many language - // servers are attached. Per-client ChangeSets are composed lazily at sync. - if (update.docChanged) { - this.revision++ - this.changes.push({revision: this.revision, changes: update.changes}) - } - const before = update.startState.facet(lspBindings) - const after = update.state.facet(lspBindings) - if (before !== after) this.configure(after) - } - - destroy() { - for (const plugin of this.plugins) plugin.destroy() - this.plugins = [] - this.changes = [] - } - - get(client?: LSPClient): LSPPlugin | null { - if (!client) return this.plugins[0] || null - return this.plugins.find(plugin => plugin.client === client) || null - } - - getAll(feature?: LSPFeature): readonly LSPPlugin[] { - return feature - ? this.plugins.filter(plugin => plugin.featureEnabled(feature)) - : this.plugins - } - - pendingChanges(plugin: LSPPlugin): ChangeSet { - let result = ChangeSet.empty(plugin.syncedDoc.length) - for (const entry of this.changes) { - if (entry.revision > plugin.syncedRevision) - result = result.compose(entry.changes) - } - return result - } - - clear(plugin: LSPPlugin) { - plugin.syncedDoc = this.view.state.doc - plugin.syncedRevision = this.revision - this.pruneChanges() - } - - private configure(specs: readonly LSPPluginSpec[]) { - const unique: LSPPluginSpec[] = [] - for (const spec of specs) { - if (!unique.some(other => other.client === spec.client)) unique.push(spec) - } - - const remaining = new Set(this.plugins) - const next: {plugin: LSPPlugin, order: number}[] = [] - for (let order = 0; order < unique.length; order++) { - const spec = unique[order] - let plugin = this.plugins.find(candidate => - candidate.client === spec.client && - candidate.uri === spec.uri && - candidate.languageID === spec.languageID) - if (plugin) { - remaining.delete(plugin) - plugin.setOptions(spec.options) - } else { - plugin = new LSPPlugin(this.view, spec, this, this.revision) - } - next.push({plugin, order}) - } - for (const plugin of remaining) plugin.destroy() - next.sort((a, b) => b.plugin.priority - a.plugin.priority || a.order - b.order) - this.plugins = next.map(item => item.plugin) - this.pruneChanges() - } - - private pruneChanges() { - if (!this.plugins.length) { - this.changes = [] - return - } - let earliest = this.plugins[0].syncedRevision - for (let i = 1; i < this.plugins.length; i++) - earliest = Math.min(earliest, this.plugins[i].syncedRevision) - let drop = 0 - while (drop < this.changes.length && this.changes[drop].revision <= earliest) drop++ - if (drop) this.changes.splice(0, drop) - } -} - -/// A binding between one editor and one language server client. -export class LSPPlugin { - /// The client connection. - client: LSPClient - /// The URI of this file. - uri: string - /// The language identifier sent to this client. - languageID: string - /// The version of the document that was synchronized to the server. - syncedDoc: Text - /// @internal - syncedRevision: number - private options: LSPPluginOptions - - /// @internal - constructor( - readonly view: EditorView, - {client, uri, languageID, options}: LSPPluginSpec, - private owner: LSPViewPlugin, - revision: number - ) { - this.client = client - this.uri = uri - if (!languageID) { - const lang = view.state.facet(language) - languageID = lang ? lang.name : "" - } - this.languageID = languageID - this.options = options || {} - this.syncedDoc = view.state.doc - this.syncedRevision = revision - client.workspace.openFile(uri, languageID, view) - } - - /// Priority used when a feature needs a single owning client. - get priority() { return this.options.priority ?? 0 } - - /// Whether this binding is eligible to provide a feature. - featureEnabled(feature: LSPFeature) { - return this.options.features?.[feature] !== false - } - - /// @internal - setOptions(options?: LSPPluginOptions) { - this.options = options || {} - } - - /// Render a doc string from the server to HTML. - docToHTML(value: string | lsp.MarkupContent, defaultKind: lsp.MarkupKind = "plaintext") { - const html = withContext(this.view, this.client.config.highlightLanguage, () => docToHTML(value, defaultKind)) - return this.client.config.sanitizeHTML ? this.client.config.sanitizeHTML(html) : html - } - - /// Convert a document offset into an LSP position. - toPosition(pos: number, doc: Text = this.view.state.doc) { - return toPosition(doc, pos) - } - - /// Convert an LSP position into a document offset. - fromPosition(pos: lsp.Position, doc: Text = this.view.state.doc) { - return fromPosition(doc, pos) - } - - /// Display an error in this plugin's editor. - reportError(message: string, err: any) { - showDialog(this.view, { - label: this.view.state.phrase(message) + ": " + (err.message || err), - class: "cm-lsp-message cm-lsp-message-error", - top: true - }) - } - - /// Changes since this client last synchronized. They are composed lazily so - /// typing cost does not scale with the number of attached clients. - get unsyncedChanges(): ChangeSet { - return this.owner.pendingChanges(this) - } - - /// Mark this client's current pending changes as synchronized. - clear() { - this.owner.clear(this) - } - - /// @internal - destroy() { - this.client.workspace.closeFile(this.uri, this.view) - } - - /// Get the primary plugin, or the plugin for a specific client. - static get(view: EditorView, client?: LSPClient) { - return view.plugin(lspPlugin)?.get(client) || null - } - - /// Get all plugins, ordered by descending priority. - static getAll(view: EditorView, feature?: LSPFeature) { - return view.plugin(lspPlugin)?.getAll(feature) || [] - } - - /// Get the first plugin eligible for a feature. - static getForFeature(view: EditorView, feature: LSPFeature) { - return this.getAll(view, feature)[0] || null - } - - /// Synchronize all attached clients. Each client independently decides - /// whether it has pending document changes. - static syncAll(view: EditorView) { - for (const plugin of this.getAll(view)) plugin.client.sync() - } - - /// Deprecated. Use `LSPClient.plugin` instead. - static create(client: LSPClient, fileURI: string, languageID?: string, options?: LSPPluginOptions): Extension { - return client.plugin(fileURI, languageID, options) - } -} - -export const lspPlugin = ViewPlugin.fromClass(LSPViewPlugin) - -/// @internal -export function lspPluginBinding(spec: LSPPluginSpec): Extension { - return [lspBindings.of(spec), lspPlugin] -} diff --git a/packages/lsp-client/src/pos.ts b/packages/lsp-client/src/pos.ts deleted file mode 100644 index f7f877d47..000000000 --- a/packages/lsp-client/src/pos.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {Text} from "@codemirror/state" - -export function toPosition(doc: Text, pos: number): lsp.Position { - let line = doc.lineAt(pos) - return {line: line.number - 1, character: pos - line.from} -} - -export function fromPosition(doc: Text, pos: lsp.Position): number { - let line = doc.line(pos.line + 1) - return line.from + pos.character -} - -export function fromPositionChecked(doc: Text, pos: lsp.Position): number | null { - if (pos.line < 0 || pos.line >= doc.lines) return null - let line = doc.line(pos.line + 1) - if (pos.character < 0 || pos.character > line.length) return null - return line.from + pos.character -} - diff --git a/packages/lsp-client/src/references.ts b/packages/lsp-client/src/references.ts deleted file mode 100644 index 83b7b168d..000000000 --- a/packages/lsp-client/src/references.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {Command, KeyBinding, showPanel, PanelConstructor, EditorView} from "@codemirror/view" -import {StateField, StateEffect} from "@codemirror/state" -import {LSPPlugin} from "./plugin" -import {WorkspaceFile} from "./workspace" -import {WorkspaceMapping} from "./client" -import type {LSPClient} from "./client" - -function getReferences(plugin: LSPPlugin, pos: number) { - return plugin.client.request("textDocument/references", { - textDocument: {uri: plugin.uri}, - position: plugin.toPosition(pos), - context: {includeDeclaration: true} - }) -} - -type ReferenceLocation = {file: WorkspaceFile, range: lsp.Range} - -/// Ask the server to locate all references to the symbol at the -/// cursor. When the server can provide such references, show them as -/// a list in a panel. -export const findReferences: Command = view => { - const plugin = LSPPlugin.getAll(view, "references") - .find(plugin => plugin.client.hasCapability("referencesProvider") !== false) - if (!plugin || plugin.client.hasCapability("referencesProvider") === false) return false - plugin.client.sync() - let mapping = plugin.client.workspaceMapping(), passedMapping = false - getReferences(plugin, view.state.selection.main.head).then(response => { - if (!response) return - return Promise.all(response.map(loc => plugin.client.workspace.requestFile(loc.uri).then(file => { - return file ? {file, range: loc.range} : null - }))).then(resolved => { - let locs = resolved.filter(l => l) as ReferenceLocation[] - if (locs.length) { - displayReferences(plugin.view, locs, mapping, plugin.client) - passedMapping = true - } - }) - }, err => plugin.reportError("Finding references failed", err)).finally(() => { - if (!passedMapping) mapping.destroy() - }) - return true -} - -/// Close the reference panel, if it is open. -export const closeReferencePanel: Command = view => { - if (!view.state.field(referencePanel, false)) return false - view.dispatch({effects: setReferencePanel.of(null)}) - return true -} - -const referencePanel = StateField.define({ - create() { return null }, - - update(panel, tr) { - for (let e of tr.effects) if (e.is(setReferencePanel)) return e.value - return panel - }, - - provide: f => showPanel.from(f) -}) - -const setReferencePanel = StateEffect.define() - -function displayReferences(view: EditorView, locs: readonly ReferenceLocation[], mapping: WorkspaceMapping, client: LSPClient) { - let panel = createReferencePanel(locs, mapping, client) - let effect = view.state.field(referencePanel, false) === undefined - ? StateEffect.appendConfig.of(referencePanel.init(() => panel)) - : setReferencePanel.of(panel) - view.dispatch({effects: effect}) -} - -function createReferencePanel(locs: readonly ReferenceLocation[], mapping: WorkspaceMapping, client: LSPClient): PanelConstructor { - let created = false - // Make sure that if this panel isn't used, the mapping still gets destroyed - setTimeout(() => {if (!created) mapping.destroy()}, 500) - - return view => { - created = true - let prefixLen = findCommonPrefix(locs.map(l => l.file.uri)) - let panel = document.createElement("div"), curFile = null - panel.className = "cm-lsp-reference-panel" - panel.tabIndex = 0 - panel.role = "listbox" - panel.setAttribute("aria-label", view.state.phrase("Reference list")) - let options: HTMLElement[] = [] - for (let {file, range} of locs) { - let fileName = file.uri.slice(prefixLen) - if (fileName != curFile) { - curFile = fileName - let header = panel.appendChild(document.createElement("div")) - header.className = "cm-lsp-reference-file" - header.textContent = fileName - } - let entry = panel.appendChild(document.createElement("div")) - entry.className = "cm-lsp-reference" - entry.role = "option" - let from = mapping.mapPosition(file.uri, range.start, 1), to = mapping.mapPosition(file.uri, range.end, -1) - let view = file.getView(), line = (view ? view.state.doc : file.doc).lineAt(from) - let lineNumber = entry.appendChild(document.createElement("span")) - lineNumber.className = "cm-lsp-reference-line" - lineNumber.textContent = (line.number + ": ").padStart(5, " ") - let textBefore = line.text.slice(Math.max(0, from - line.from - 50), from - line.from) - if (textBefore) entry.appendChild(document.createTextNode(textBefore)) - entry.appendChild(document.createElement("strong")).textContent = line.text.slice(from - line.from, to - line.from) - let textAfter = line.text.slice(to - line.from, Math.min(line.length, 100 - textBefore.length)) - if (textAfter) entry.appendChild(document.createTextNode(textAfter)) - if (!options.length) entry.setAttribute("aria-selected", "true") - options.push(entry) - } - - function curSelection() { - for (let i = 0; i < options.length; i++) { - if (options[i].hasAttribute("aria-selected")) return i - } - return 0 - } - function setSelection(index: number) { - for (let i = 0; i < options.length; i++) { - if (i == index) options[i].setAttribute("aria-selected", "true") - else options[i].removeAttribute("aria-selected") - } - } - function showReference(index: number) { - let {file, range} = locs[index] - let plugin = LSPPlugin.get(view, client) - if (!plugin) return - Promise.resolve(file.uri == plugin.uri ? view : plugin.client.workspace.displayFile(file.uri)).then(view => { - if (!view) return - let pos = mapping.mapPosition(file.uri, range.start, 1) - view.focus() - view.dispatch({ - selection: {anchor: pos}, - scrollIntoView: true - }) - }) - } - - panel.addEventListener("keydown", event => { - if (event.keyCode == 27) { // Escape - closeReferencePanel(view) - view.focus() - } else if (event.keyCode == 38 || event.keyCode == 33) { // ArrowUp, PageUp - setSelection((curSelection() - 1 + locs.length) % locs.length) - } else if (event.keyCode == 40 || event.keyCode == 34) { // ArrowDown, PageDown - setSelection((curSelection() + 1) % locs.length) - } else if (event.keyCode == 36) { // Home - setSelection(0) - } else if (event.keyCode == 35) { // End - setSelection(options.length - 1) - } else if (event.keyCode == 13 || event.keyCode == 10) { // Enter, Space - showReference(curSelection()) - } else { - return - } - event.preventDefault() - }) - panel.addEventListener("click", event => { - for (let i = 0; i < options.length; i++) { - if (options[i].contains(event.target as HTMLElement)) { - setSelection(i) - showReference(i) - event.preventDefault() - } - } - }) - let dom = document.createElement("div") - dom.appendChild(panel) - let close = dom.appendChild(document.createElement("button")) - close.className = "cm-dialog-close" - close.textContent = "×" - close.addEventListener("click", () => closeReferencePanel(view)) - close.setAttribute("aria-label", view.state.phrase("close")) - - return { - dom, - destroy: () => mapping.destroy(), - mount: () => panel.focus(), - } - } -} - -function findCommonPrefix(uris: string[]) { - let first = uris[0], prefix = first.length - for (let i = 1; i < uris.length; i++) { - let uri = uris[i], j = 0 - for (let e = Math.min(prefix, uri.length); j < e && first[j] == uri[j]; j++) {} - prefix = j - } - while (prefix && first[prefix - 1] != "/") prefix-- - return prefix -} - -/// Binds Shift-F12 to [`findReferences`](#lsp-client.findReferences) -/// and Escape to -/// [`closeReferencePanel`](#lsp-client.closeReferencePanel). -export const findReferencesKeymap: readonly KeyBinding[] = [ - {key: "Shift-F12", run: findReferences, preventDefault: true}, - {key: "Escape", run: closeReferencePanel}, -] diff --git a/packages/lsp-client/src/rename.ts b/packages/lsp-client/src/rename.ts deleted file mode 100644 index 28381fca2..000000000 --- a/packages/lsp-client/src/rename.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {EditorView, Command, KeyBinding, showDialog, getDialog} from "@codemirror/view" -import {LSPPlugin} from "./plugin" - -function getRename(plugin: LSPPlugin, pos: number, newName: string) { - return plugin.client.request("textDocument/rename", { - newName, - position: plugin.toPosition(pos), - textDocument: {uri: plugin.uri}, - }) -} - -/// This command will, if the cursor is over a word, prompt the user -/// for a new name for that symbol, and ask the language server to -/// perform a rename of that symbol. -/// -/// Note that this may affect files other than the one loaded into -/// this view. See the -/// [`Workspace.updateFile`](#lsp-client.Workspace.updateFile) -/// method. -export const renameSymbol: Command = view => { - let wordRange = view.state.wordAt(view.state.selection.main.head) - let plugin = LSPPlugin.getAll(view, "rename") - .find(plugin => plugin.client.hasCapability("renameProvider") !== false) - if (!wordRange || !plugin || plugin.client.hasCapability("renameProvider") === false) return false - const word = view.state.sliceDoc(wordRange.from, wordRange.to) - let panel = getDialog(view, "cm-lsp-rename-panel") - if (panel) { - let input = panel.dom.querySelector("[name=name]") as HTMLInputElement - input.value = word - input.select() - } else { - let {close, result} = showDialog(view, { - label: view.state.phrase("New name"), - input: {name: "name", value: word}, - focus: true, - submitLabel: view.state.phrase("rename"), - class: "cm-lsp-rename-panel", - }) - result.then(form => { - view.dispatch({effects: close}) - if (form) doRename(view, (form.elements.namedItem("name") as HTMLInputElement).value) - }) - } - return true -} - -function doRename(view: EditorView, newName: string) { - const plugin = LSPPlugin.getAll(view, "rename") - .find(plugin => plugin.client.hasCapability("renameProvider") !== false) - const word = view.state.wordAt(view.state.selection.main.head) - if (!plugin || !word) return false - - plugin.client.sync() - plugin.client.withMapping(mapping => getRename(plugin, word.from, newName).then(response => { - if (!response) return - uris: for (let uri in response.changes) { - let lspChanges = response.changes[uri], file = plugin.client.workspace.getFile(uri) - if (!lspChanges.length || !file) continue - plugin.client.workspace.updateFile(uri, { - changes: lspChanges.map(change => ({ - from: mapping.mapPosition(uri, change.range.start), - to: mapping.mapPosition(uri, change.range.end), - insert: change.newText - })), - userEvent: "rename" - }) - } - }, err => { - plugin.reportError("Rename request failed", err) - })) -} - -/// A keymap that binds F2 to [`renameSymbol`](#lsp-client.renameSymbol). -export const renameKeymap: readonly KeyBinding[] = [ - {key: "F2", run: renameSymbol, preventDefault: true} -] diff --git a/packages/lsp-client/src/signature.ts b/packages/lsp-client/src/signature.ts deleted file mode 100644 index a0b07438c..000000000 --- a/packages/lsp-client/src/signature.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {StateField, StateEffect, Prec, Extension} from "@codemirror/state" -import {EditorView, ViewPlugin, ViewUpdate, keymap, KeyBinding, - Tooltip, showTooltip, Command} from "@codemirror/view" -import {LSPPlugin} from "./plugin" -import type {LSPClient} from "./client" - -function getSignatureHelp(plugin: LSPPlugin, pos: number, context: lsp.SignatureHelpContext) { - if (plugin.client.hasCapability("signatureHelpProvider") === false) return Promise.resolve(null) - plugin.client.sync() - return plugin.client.request("textDocument/signatureHelp", { - context, - position: plugin.toPosition(pos), - textDocument: {uri: plugin.uri}, - }) -} - -const signaturePlugin = ViewPlugin.fromClass(class { - activeRequest: {pos: number, drop: boolean} | null = null - delayedRequest: ReturnType | null = null - - update(update: ViewUpdate) { - if (this.activeRequest) { - if (update.selectionSet) { - this.activeRequest.drop = true - this.activeRequest = null - } else if (update.docChanged) { - this.activeRequest.pos = update.changes.mapPos(this.activeRequest.pos) - } - } - - const plugin = LSPPlugin.getAll(update.view, "signatureHelp") - .find(plugin => plugin.client.hasCapability("signatureHelpProvider") !== false) - if (!plugin) return - const sigState = update.view.state.field(signatureState) - let triggerCharacter = "" - if (update.docChanged && update.transactions.some(tr => tr.isUserEvent("input.type"))) { - const serverConf = plugin.client.serverCapabilities?.signatureHelpProvider - const triggers = (serverConf?.triggerCharacters || []).concat(sigState && serverConf?.retriggerCharacters || []) - if (triggers) { - update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { - let ins = inserted.toString() - if (ins) for (let ch of triggers) { - if (ins.indexOf(ch) > -1) triggerCharacter = ch - } - }) - } - } - - if (triggerCharacter) { - this.startRequest(plugin, { - triggerKind: 2 /* TriggerCharacter */, - isRetrigger: !!sigState, - triggerCharacter, - activeSignatureHelp: sigState ? sigState.data : undefined - }) - } else if (sigState && update.selectionSet) { - if (this.delayedRequest != null) clearTimeout(this.delayedRequest) - this.delayedRequest = setTimeout(() => { - this.startRequest(plugin, { - triggerKind: 3 /* ContentChange */, - isRetrigger: true, - activeSignatureHelp: sigState.data, - }) - }, 250) - } - } - - startRequest(plugin: LSPPlugin, context: lsp.SignatureHelpContext) { - if (this.delayedRequest != null) clearTimeout(this.delayedRequest) - let {view} = plugin, pos = view.state.selection.main.head - if (this.activeRequest) this.activeRequest.drop = true - let req = this.activeRequest = {pos, drop: false} - getSignatureHelp(plugin, pos, context).then(result => { - if (req.drop) return - if (result && result.signatures.length) { - let cur = view.state.field(signatureState) - let same = cur && sameSignatures(cur.data, result) - let active = same && context.triggerKind == 3 ? cur!.active : result.activeSignature ?? 0 - // Don't update at all if nothing changed - if (same && sameActiveParam(cur!.data, result, active)) return - view.dispatch({effects: signatureEffect.of({ - data: result, - active, - pos: same ? cur!.tooltip.pos : req.pos, - client: plugin.client - })}) - } else if (view.state.field(signatureState)) { - view.dispatch({effects: signatureEffect.of(null)}) - } - }, context.triggerKind == 1 /* Invoked */ ? err => plugin.reportError("Signature request failed", err) : undefined) - } - - destroy() { - if (this.delayedRequest != null) clearTimeout(this.delayedRequest) - if (this.activeRequest) this.activeRequest.drop = true - } -}) - -function sameSignatures(a: lsp.SignatureHelp, b: lsp.SignatureHelp) { - if (a.signatures.length != b.signatures.length) return false - return a.signatures.every((s, i) => s.label == b.signatures[i].label) -} - -function sameActiveParam(a: lsp.SignatureHelp, b: lsp.SignatureHelp, active: number) { - return (a.signatures[active].activeParameter ?? a.activeParameter) == - (b.signatures[active].activeParameter ?? b.activeParameter) -} - -class SignatureState { - constructor( - readonly data: lsp.SignatureHelp, - readonly active: number, - readonly tooltip: Tooltip, - readonly client: LSPClient - ) {} -} - -const signatureState = StateField.define({ - create() { return null }, - update(sig, tr) { - for (let e of tr.effects) if (e.is(signatureEffect)) { - if (e.value) { - return new SignatureState(e.value.data, e.value.active, - signatureTooltip(e.value.data, e.value.active, e.value.pos, e.value.client), e.value.client) - } else { - return null - } - } - if (sig && tr.docChanged) - return new SignatureState(sig.data, sig.active, - {...sig.tooltip, pos: tr.changes.mapPos(sig.tooltip.pos)}, sig.client) - return sig - }, - provide: f => showTooltip.from(f, sig => sig && sig.tooltip) -}) - -const signatureEffect = StateEffect.define<{ - data: lsp.SignatureHelp, - active: number, - pos: number, - client: LSPClient -} | null>() - -function signatureTooltip(data: lsp.SignatureHelp, active: number, pos: number, client: LSPClient): Tooltip { - return { - pos, - above: true, - create: view => drawSignatureTooltip(view, data, active, client) - } -} - -function drawSignatureTooltip(view: EditorView, data: lsp.SignatureHelp, active: number, client: LSPClient) { - let dom = document.createElement("div") - dom.className = "cm-lsp-signature-tooltip" - if (data.signatures.length > 1) { - dom.classList.add("cm-lsp-signature-multiple") - let num = dom.appendChild(document.createElement("div")) - num.className = "cm-lsp-signature-num" - num.textContent = `${active + 1}/${data.signatures.length}` - } - - let signature = data.signatures[active] - let sig = dom.appendChild(document.createElement("div")) - sig.className = "cm-lsp-signature" - let activeFrom = 0, activeTo = 0 - let activeN = signature.activeParameter ?? data.activeParameter - let activeParam = activeN != null && signature.parameters ? signature.parameters[activeN] : null - if (activeParam && Array.isArray(activeParam.label)) { - ;[activeFrom, activeTo] = activeParam.label - } else if (activeParam) { - let found = signature.label.indexOf(activeParam.label as string) - if (found > -1) { - activeFrom = found - activeTo = found + activeParam.label.length - } - } - if (activeTo) { - sig.appendChild(document.createTextNode(signature.label.slice(0, activeFrom))) - let activeElt = sig.appendChild(document.createElement("span")) - activeElt.className = "cm-lsp-active-parameter" - activeElt.textContent = signature.label.slice(activeFrom, activeTo) - sig.appendChild(document.createTextNode(signature.label.slice(activeTo))) - } else { - sig.textContent = signature.label - } - if (signature.documentation) { - let plugin = LSPPlugin.get(view, client) - if (plugin) { - let docs = dom.appendChild(document.createElement("div")) - docs.className = "cm-lsp-signature-documentation cm-lsp-documentation" - docs.innerHTML = plugin.docToHTML(signature.documentation) - } - } - return {dom} -} - -/// Explicitly prompt the server to provide signature help at the -/// cursor. -export const showSignatureHelp: Command = view => { - let plugin = view.plugin(signaturePlugin) - if (!plugin) { - view.dispatch({effects: StateEffect.appendConfig.of([signatureState, signaturePlugin])}) - plugin = view.plugin(signaturePlugin) - } - let field = view.state.field(signatureState) - if (!plugin || field === undefined) return false - let lspPlugin = LSPPlugin.getAll(view, "signatureHelp") - .find(plugin => plugin.client.hasCapability("signatureHelpProvider") !== false) - if (!lspPlugin) return false - plugin.startRequest(lspPlugin, { - triggerKind: 1 /* Invoked */, - activeSignatureHelp: field ? field.data : undefined, - isRetrigger: !!field - }) - return true -} - -/// If there is an active signature tooltip with multiple signatures, -/// move to the next one. -export const nextSignature: Command = view => { - let field = view.state.field(signatureState) - if (!field) return false - if (field.active < field.data.signatures.length - 1) - view.dispatch({effects: signatureEffect.of({ - data: field.data, active: field.active + 1, pos: field.tooltip.pos, client: field.client - })}) - return true -} - -/// If there is an active signature tooltip with multiple signatures, -/// move to the previous signature. -export const prevSignature: Command = view => { - let field = view.state.field(signatureState) - if (!field) return false - if (field.active > 0) - view.dispatch({effects: signatureEffect.of({ - data: field.data, active: field.active - 1, pos: field.tooltip.pos, client: field.client - })}) - return true -} - -/// A keymap that binds -/// -/// - Ctrl-Shift-Space (Cmd-Shift-Space on macOS) to -/// [`showSignatureHelp`](#lsp-client.showSignatureHelp) -/// -/// - Ctrl-Shift-ArrowUp (Cmd-Shift-ArrowUp on macOS) to -/// [`prevSignature`](#lsp-client.prevSignature) -/// -/// - Ctrl-Shift-ArrowDown (Cmd-Shift-ArrowDown on macOS) to -/// [`nextSignature`](#lsp-client.nextSignature) -/// -/// Note that these keys are automatically bound by -/// [`signatureHelp`](#lsp-client.signatureHelp) unless you pass it -/// `keymap: false`. -export const signatureKeymap: readonly KeyBinding[] = [ - {key: "Mod-Shift-Space", run: showSignatureHelp}, - {key: "Mod-Shift-ArrowUp", run: prevSignature}, - {key: "Mod-Shift-ArrowDown", run: nextSignature}, -] - -/// Returns an extension that enables signature help. Will bind the -/// keys in [`signatureKeymap`](#lsp-client.signatureKeymap) unless -/// `keymap` is set to `false`. -const signatureHelpWithoutKeymap: Extension = [signatureState, signaturePlugin] -const signatureHelpWithKeymap: Extension = [ - signatureHelpWithoutKeymap, - Prec.high(keymap.of(signatureKeymap)) -] - -export function signatureHelp(config: {keymap?: boolean} = {}): Extension { - return config.keymap === false ? signatureHelpWithoutKeymap : signatureHelpWithKeymap -} diff --git a/packages/lsp-client/src/text.ts b/packages/lsp-client/src/text.ts deleted file mode 100644 index 59773d08e..000000000 --- a/packages/lsp-client/src/text.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {Marked} from "marked" -import {EditorView} from "@codemirror/view" -import {Language, language as languageFacet, highlightingFor} from "@codemirror/language" -import {highlightCode, Highlighter} from "@lezer/highlight" - -let context: {view: EditorView, language: ((name: string) => Language | null) | undefined} | null = null - -export function withContext(view: EditorView, language: ((name: string) => Language | null) | undefined, f: () => T): T { - let prev = context - try { - context = {view, language} - return f() - } finally { - context = prev - } -} - -const marked = new Marked({ - walkTokens(token) { - if (!context || token.type != "code") return - - let lang = context.language && context.language(token.lang) - if (!lang) { - let viewLang = context.view.state.facet(languageFacet) - if (viewLang && viewLang.name == token.lang) lang = viewLang - } - if (!lang) return - let highlighter: Highlighter = {style: tags => highlightingFor(context!.view.state, tags)} - let result = "" - highlightCode(token.text, lang.parser.parse(token.text), highlighter, (text, cls) => { - result += cls ? `${escHTML(text)}` : escHTML(text) - }, () => { - result += "
" - }) - token.escaped = true - token.text = result - } -}) - -export function escHTML(text: string) { - return text.replace(/[\n<&]/g, ch => ch == "\n" ? "
" : ch == "<" ? "<" : "&") -} - -export function docToHTML(value: string | lsp.MarkupContent, defaultKind: lsp.MarkupKind) { - let kind = defaultKind, text = value - if (typeof text != "string") { - kind = text.kind - text = text.value - } - if (kind == "plaintext") { - return escHTML(text) - } else { - return marked.parse(text, {async: false, }) - } -} diff --git a/packages/lsp-client/src/theme.ts b/packages/lsp-client/src/theme.ts deleted file mode 100644 index 1c0c45560..000000000 --- a/packages/lsp-client/src/theme.ts +++ /dev/null @@ -1,79 +0,0 @@ -import {EditorView} from "@codemirror/view" - -export const lspTheme = EditorView.baseTheme({ - ".cm-lsp-documentation": { - padding: "0 7px", - "& p, & pre": { - margin: "2px 0" - } - }, - - ".cm-lsp-signature-tooltip": { - padding: "2px 6px", - borderRadius: "2.5px", - position: "relative", - maxWidth: "30em", - maxHeight: "10em", - overflowY: "scroll", - "& .cm-lsp-documentation": { - padding: "0", - fontSize: "80%", - }, - "& .cm-lsp-signature-num": { - fontFamily: "monospace", - position: "absolute", - left: "2px", top: "4px", - fontSize: "70%", - lineHeight: "1.3" - }, - "& .cm-lsp-signature": { - fontFamily: "monospace", - textIndent: "1em hanging", - }, - "& .cm-lsp-active-parameter": { - fontWeight: "bold" - }, - }, - ".cm-lsp-signature-multiple": { - paddingLeft: "1.5em" - }, - - ".cm-panel.cm-lsp-rename-panel": { - padding: "2px 6px 4px", - position: "relative", - "& label": { fontSize: "80%" }, - "& [name=close]": { - position: "absolute", - top: "0", bottom: "0", - right: "4px", - backgroundColor: "inherit", - border: "none", - font: "inherit", - padding: "0" - } - }, - - ".cm-lsp-message button[type=submit]": { - display: "block" - }, - - ".cm-lsp-reference-panel": { - fontFamily: "monospace", - whiteSpace: "pre", - padding: "3px 6px", - maxHeight: "120px", - overflow: "auto", - "& .cm-lsp-reference-file": { - fontWeight: "bold", - }, - "& .cm-lsp-reference": { - cursor: "pointer", - "&[aria-selected]": { - backgroundColor: "#0077ee44" - }, - }, - "& .cm-lsp-reference-line": { - opacity: "0.7", - }, - }, -}) diff --git a/packages/lsp-client/src/workspace.ts b/packages/lsp-client/src/workspace.ts deleted file mode 100644 index dd0c3b203..000000000 --- a/packages/lsp-client/src/workspace.ts +++ /dev/null @@ -1,161 +0,0 @@ -import {Text, ChangeSet, TransactionSpec} from "@codemirror/state" -import {EditorView} from "@codemirror/view" -import {LSPClient} from "./client" -import {LSPPlugin} from "./plugin" - -/// A file that is open in a workspace. -export interface WorkspaceFile { - /// The file's unique URI. - uri: string - /// The LSP language ID for the file's content. - languageId: string - /// The current version of the file. - version: number - /// The document corresponding to `this.version`. Will not reflect - /// changes made after that version was synchronized. Will be - /// updated, along with `version`, by - /// [`syncFiles`](#lsp-client.Workspace.syncFiles). - doc: Text - /// Get an active editor view for this file, if there is one. For - /// workspaces that support multiple views on a file, `main` - /// indicates a preferred view. - getView(main?: EditorView): EditorView | null -} - -interface WorkspaceFileUpdate { - file: WorkspaceFile - prevDoc: Text - changes: ChangeSet -} - -/// Implementing your own workspace class can provide more control -/// over the way files are loaded and managed when interacting with -/// the language server. See -/// [`LSPClientConfig.workspace`](#lsp-client.LSPClientConfig.workspace). -export abstract class Workspace { - /// The files currently open in the workspace. - abstract files: WorkspaceFile[] - - /// The constructor, as called by the client when creating a - /// workspace. - constructor( - /// The LSP client associated with this workspace. - readonly client: LSPClient - ) {} - - /// Find the open file with the given URI, if it exists. The default - /// implementation just looks it up in `this.files`. - getFile(uri: string) : WorkspaceFile | null { - return this.files.find(f => f.uri == uri) || null - } - - /// Check all open files for changes (usually from editors, but they - /// may also come from other sources). When a file is changed, - /// return a record that describes the changes, and update the file's - /// [`version`](#lsp-client.WorkspaceFile.version) and - /// [`doc`](#lsp-client.WorkspaceFile.doc) properties to reflect the - /// new version. - abstract syncFiles(): readonly WorkspaceFileUpdate[] - - /// Called to request that the workspace open a file. The default - /// implementation simply returns the file if it is open, null - /// otherwise. - requestFile(uri: string): Promise { - return Promise.resolve(this.getFile(uri)) - } - - /// Called when an editor is created for a file. The implementation - /// should track the file in - /// [`this.files`](#lsp-client.Workspace.files) and, if it wasn't - /// open already, call - /// [`LSPClient.didOpen`](#lsp-client.LSPClient.didOpen). - abstract openFile(uri: string, languageId: string, view: EditorView): void - - /// Called when an editor holding this file is destroyed or - /// reconfigured to no longer hold it. The implementation should - /// track this and, when it closes the file, make sure to call - /// [`LSPClient.didClose`](#lsp-client.LSPClient.didClose). - abstract closeFile(uri: string, view: EditorView): void - - /// Called when the client for this workspace is connected. The - /// default implementation calls - /// [`LSPClient.didOpen`](#lsp-client.LSPClient.didOpen) on all open - /// files. - connected(): void { - for (let file of this.files) this.client.didOpen(file) - } - - /// Called when the client for this workspace is disconnected. The - /// default implementation does nothing. - disconnected(): void {} - - /// Called when a server-initiated change to a file is applied. The - /// default implementation simply dispatches the update to the - /// file's view, if the file is open and has a view. - updateFile(uri: string, update: TransactionSpec): void { - let file = this.getFile(uri) - if (file) file.getView()?.dispatch(update) - } - - /// When the client needs to put a file other than the one loaded in - /// the current editor in front of the user, for example in - /// [`jumpToDefinition`](#lsp-client.jumpToDefinition), it will call - /// this function. It should make sure to create or find an editor - /// with the file and make it visible to the user, or return null if - /// this isn't possible. - displayFile(uri: string): Promise { - let file = this.getFile(uri) - return Promise.resolve(file ? file.getView() : null) - } -} - -class DefaultWorkspaceFile implements WorkspaceFile { - constructor(readonly uri: string, - readonly languageId: string, - public version: number, - public doc: Text, - readonly view: EditorView) {} - - getView() { return this.view } -} - -export class DefaultWorkspace extends Workspace { - files: DefaultWorkspaceFile[] = [] - private fileVersions: {[uri: string]: number} = Object.create(null) - - nextFileVersion(uri: string) { - return this.fileVersions[uri] = (this.fileVersions[uri] ?? -1) + 1 - } - - syncFiles() { - let result: WorkspaceFileUpdate[] = [] - for (let file of this.files) { - let plugin = LSPPlugin.get(file.view, this.client) - if (!plugin) continue - let changes = plugin.unsyncedChanges - if (!changes.empty) { - result.push({changes, file, prevDoc: file.doc}) - file.doc = file.view.state.doc - file.version = this.nextFileVersion(file.uri) - plugin.clear() - } - } - return result - } - - openFile(uri: string, languageId: string, view: EditorView) { - if (this.getFile(uri)) - throw new Error("Default workspace implementation doesn't support multiple views on the same file") - let file = new DefaultWorkspaceFile(uri, languageId, this.nextFileVersion(uri), view.state.doc, view) - this.files.push(file) - this.client.didOpen(file) - } - - closeFile(uri: string) { - let file = this.getFile(uri) - if (file) { - this.files = this.files.filter(f => f != file) - this.client.didClose(uri) - } - } -} diff --git a/packages/lsp-client/test/server.ts b/packages/lsp-client/test/server.ts deleted file mode 100644 index 4845fe9da..000000000 --- a/packages/lsp-client/test/server.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import {Transport} from "@codemirror/lsp-client" - -const serverCapabilities: lsp.ServerCapabilities = { - textDocumentSync: {openClose: true, change: 2}, - renameProvider: true, - documentFormattingProvider: true, - completionProvider: {triggerCharacters: [","]}, - hoverProvider: true, - signatureHelpProvider: {}, -} - -const requestHandlers: {[method: string]: (params: any, server: DummyServer) => any} = { - "initialize": (params: lsp.InitializeParams, server): lsp.InitializeResult => { - return {capabilities: serverCapabilities, serverInfo: {name: "Dummy server"}} - }, - - "textDocument/rename": (params: lsp.RenameParams, server): lsp.WorkspaceEdit | null => { - let file = server.getFile(params.textDocument.uri) - if (!file) throw new ServerError(-32602, "File not open") - let pos = resolvePosition(file.text, params.position) - let from = pos, to = pos - while (from && /\w/.test(file.text[from - 1])) from-- - while (to < file.text.length && /\w/.test(file.text[to])) to++ - if (from == to) return null - let word = file.text.slice(from, to), changes: {[uri: string]: lsp.TextEdit[]} = {} - for (let file of server.files) { - let found: lsp.TextEdit[] = [], pos = 0, next - while ((next = file.text.indexOf(word, pos)) > -1) { - pos = next + word.length - found.push({ - newText: params.newName, - range: {start: toPosition(file.text, next), end: toPosition(file.text, pos)} - }) - } - if (found.length) changes[file.uri] = found - } - return {changes} - }, - - "textDocument/formatting": (params: lsp.DocumentFormattingParams, server): lsp.TextEdit[] | null => { - let file = server.getFile(params.textDocument.uri) - if (!file) throw new ServerError(-32602, "File not open") - let end = toPosition(file.text, file.text.length) - return [{newText: "\n// formatted!", range: {start: end, end}}] - }, - - "textDocument/completion": (params: lsp.CompletionParams, server): lsp.CompletionList | null => { - let before = {line: params.position.line, character: params.position.character - 1} - return { - isIncomplete: true, - itemDefaults: { - editRange: {start: before, end: params.position} - }, - items: [ - {label: "one", kind: 14, commitCharacters: ["."], textEdit: {newText: "one!", range: {start: before, end: params.position}}}, - {label: "ookay", kind: 7, documentation: "`code` stuff", insertText: "okay"}, - {label: "fn", kind: 3, insertText: "fn(${1:\\$arg})$0", insertTextFormat: 2}, - {label: "Bar", kind: 7, insertText: "Bar", additionalTextEdits: [ - {range: {start: {line: 0, character: 0}, end: {line: 0, character: 0}}, newText: "use Bar;\n"}, - ]}, - {label: "Zap", kind: 7, insertText: "Zap", additionalTextEdits: [ - {range: {start: {line: 2, character: 0}, end: {line: 2, character: 0}}, newText: "MID\n"}, - ]}, - ] - } - }, - - "textDocument/hover": (params: lsp.HoverParams, server): lsp.Hover | null => { - return { - range: {start: params.position, end: params.position}, - contents: {language: "javascript", value: "'hover'"} - } - }, - - "textDocument/signatureHelp": (params: lsp.SignatureHelpParams, server): lsp.SignatureHelp | null => { - return { - signatures: [{ - label: "(a, b) => c", - activeParameter: 1, - parameters: [{label: [1, 2]}, {label: [4, 5]}] - }, { - label: "(x, y) => c", - activeParameter: 1, - parameters: [{label: [1, 2]}, {label: [4, 5]}] - }], - activeSignature: 0, - } - }, - - "custom/sendNotification": (params: {method: string, params: any}, server) => { - server.broadcast({jsonrpc: "2.0", method: params.method, params: params.params}) - }, -} - -const notificationHandlers: {[method: string]: (params: any, server: DummyServer) => void} = { - "initialized": (params: lsp.InitializedParams, server) => { - server.initialized = true - }, - "textDocument/didOpen": (params: lsp.DidOpenTextDocumentParams, server) => { - let {uri, text, languageId} = params.textDocument - if (!server.getFile(params.textDocument.uri)) - server.files.push(new OpenFile(uri, languageId, text)) - }, - "textDocument/didClose": (params: lsp.DidCloseTextDocumentParams, server) => { - server.files = server.files.filter(f => f.uri != params.textDocument.uri) - }, - "textDocument/didChange": (params: lsp.DidChangeTextDocumentParams, server) => { - let file = server.getFile(params.textDocument.uri) - if (file) for (let ch of params.contentChanges) { - if ("range" in ch) - file.text = file.text.slice(0, resolvePosition(file.text, ch.range.start)) + ch.text + - file.text.slice(resolvePosition(file.text, ch.range.end)) - else - file.text = ch.text - } - } -} - -function resolvePosition(text: string, pos: lsp.Position) { - let line = 0, off = 0 - while (line < pos.line) { - let next = text.indexOf("\n", off) - if (!next) throw new RangeError("Position out of bounds") - off = next + 1 - line++ - } - off += pos.character - if (off > text.length) throw new RangeError("Position out of bounds") - return off -} - -function toPosition(text: string, pos: number): lsp.Position { - for (let off = 0, line = 0;;) { - let next = text.indexOf("\n", off) - if (next < 0 || next >= pos) return {line, character: pos - off} - off = next + 1 - line++ - } -} - -class ServerError extends Error { - constructor(readonly code: number, message: string) { super(message) } -} - -class OpenFile { - constructor(readonly uri: string, readonly languageId: string, public text: string) {} -} - -export class DummyServer implements Transport { - initialized = false - subscribers: ((msg: string) => void)[] = [] - files: OpenFile[] = [] - - constructor(readonly config: { - delay?: {[method: string]: number}, - brokenPipe?: () => boolean - } = {}) { - } - - subscribe(listener: (msg: string) => void) { - this.subscribers.push(listener) - } - - unsubscribe(listener: (msg: string) => void) { - this.subscribers = this.subscribers.filter(l => l != listener) - } - - send(message: string) { - if (this.config.brokenPipe?.()) throw new Error("Broken Pipe") - const msg = JSON.parse(message) as lsp.RequestMessage | lsp.NotificationMessage - if ("id" in msg) { - this.handleRequest(msg.method, msg.params).then(result => { - this.broadcast({jsonrpc: "2.0", id: msg.id, result}) - }, e => { - let error = e instanceof ServerError ? {code: e.code, message: e.message} - : {code: -32603 /* InternalError */, message: String(e)} - this.broadcast({jsonrpc: "2.0", id: msg.id, error}) - }) - } else { - this.handleNotification(msg.method, msg.params) - } - } - - broadcast(message: any) { - for (let sub of this.subscribers) sub(JSON.stringify(message)) - } - - handleRequest(method: string, params: Params): Promise { - return new Promise(resolve => { - if (!this.initialized && method != "initialize") - throw new ServerError(-32002 /* ServerNotInitialized */, "Not initialized") - let handler = requestHandlers[method] - if (!handler) throw new ServerError(-32601 /* MethodNotFound */, "Method not found") - let result = handler(params, this), delay = this.config.delay?.[method] - if (delay) setTimeout(() => resolve(result), delay) - else queueMicrotask(() => resolve(result)) - }) - } - - handleNotification(method: string, params: Params) { - let handler = notificationHandlers[method] - if (handler) handler(params, this) - } - - getFile(uri: string) { return this.files.find(f => f.uri == uri) } -} diff --git a/packages/lsp-client/test/webtest-client.ts b/packages/lsp-client/test/webtest-client.ts deleted file mode 100644 index 3ebb4deba..000000000 --- a/packages/lsp-client/test/webtest-client.ts +++ /dev/null @@ -1,373 +0,0 @@ -import type * as lsp from "vscode-languageserver-protocol" -import ist from "ist" -import {LSPClientConfig, LSPClient, LSPPlugin, renameSymbol, - formatDocument, serverCompletion, hoverTooltips, - showSignatureHelp, nextSignature} from "@codemirror/lsp-client" -import {EditorView, EditorViewConfig} from "@codemirror/view" -import {javascript} from "@codemirror/lang-javascript" -import {syntaxHighlighting} from "@codemirror/language" -import {startCompletion, currentCompletions, acceptCompletion, autocompletion, moveCompletionSelection} from "@codemirror/autocomplete" -import {classHighlighter} from "@lezer/highlight" -import {DummyServer} from "./server.js" - -function setup(conf: {client?: LSPClientConfig, server?: ConstructorParameters[0]} = {}) { - let server = new DummyServer(conf.server) - let client = new LSPClient(conf.client) - client.connect(server) - return {server, client} -} - -const URI = "file:///home/holly/src/test.js" - -function ed(client: LSPClient, conf: EditorViewConfig, uri = URI) { - return new EditorView({...conf, extensions: [conf.extensions || [], LSPPlugin.create(client, uri, "javascript")]}) -} - -function sync(cm: EditorView) { - LSPPlugin.get(cm)!.client.sync() -} - -function wait(ms: number = 2) { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -function place(cm: EditorView) { - let ws = document.querySelector("#workspace")! - while (ws.firstChild) ws.firstChild.remove() - ws.appendChild(cm.dom) - setTimeout(() => cm.destroy(), 1000) - return cm -} - -describe("LSPClient", () => { - it("can connect to a server", () => { - let {client} = setup() - return client.initializing - }) - - it("can open a file", async () => { - let {client, server} = setup() - ed(client, {doc: "stitchwort"}) - await wait() - ist(server.getFile(URI)!.text, "stitchwort") - }) - - it("can update a file", async () => { - let {client, server} = setup() - let cm = ed(client, {doc: "goldenrod"}) - cm.dispatch({changes: {from: 1, insert: "-"}}) - sync(cm) - await wait() - ist(server.getFile(URI)!.text, cm.state.sliceDoc()) - }) - - it("can update a file with multiple changes", async () => { - let {client, server} = setup() - let cm = ed(client, {doc: "hawkweed\n".repeat(1000)}) - await wait() - cm.dispatch({changes: [{from: 0, insert: "<"}, {from: cm.state.doc.length, insert: ">"}]}) - sync(cm) - await wait() - ist(server.getFile(URI)!.text, cm.state.sliceDoc()) - }) - - it("can close a file", async () => { - let {client, server} = setup() - let cm = ed(client, {doc: "cowleek"}) - await wait() - cm.destroy() - await wait() - ist(!server.getFile(URI)) - }) - - it("can open multiple files", async () => { - let {client, server} = setup() - let cm1 = ed(client, {doc: "elder"}), cm2 = ed(client, {doc: "alfalfa"}, "file:///x") - cm1.dispatch({changes: {from: 5, insert: "?"}}) - cm2.dispatch({changes: {from: 7, insert: "!"}}) - sync(cm1) - await wait() - ist(server.getFile(URI)!.text, "elder?") - ist(server.getFile("file:///x")!.text, "alfalfa!") - }) - - it("can provide mapping", async () => { - let {client} = setup() - let cm = ed(client, {doc: "1\n2\n3"}) - await client.withMapping(async mapping => { - let req = client.request("textDocument/formatting", { - textDocument: {uri: URI}, - options: {tabSize: 2, insertSpaces: true} - }) - cm.dispatch({changes: {from: 1, to: 3}}) - let response = await req - ist(response![0].range.start.line, 2) - ist(mapping.mapPos(URI, 5), 3) - }) - }) - - it("can provide mapping across syncs", async () => { - let {client} = setup({server: {delay: {"textDocument/formatting": 10}}}) - let cm = ed(client, {doc: "1\n2\n3"}) - await client.withMapping(async mapping => { - let req = client.request("textDocument/formatting", { - textDocument: {uri: URI}, - options: {tabSize: 2, insertSpaces: true} - }) - cm.dispatch({changes: {from: 1, to: 3}}) - sync(cm) - cm.dispatch({changes: {from: 0, insert: "#"}}) - let response = await req - ist(response![0].range.start.line, 2) - ist(mapping.mapPos(URI, 5), 4) - }) - }) - - it("reports invalid methods", async () => { - let {client} = setup() - try { - await client.request("none/such", {}) - ist(false) - } catch (e: any) { - ist(e.code, -32601) - } - }) - - it("can receive custom notifications", async () => { - let received: any = null - let {client} = setup({client: {notificationHandlers: { - "custom/notification": (client, params) => received = params - }}}) - client.request("custom/sendNotification", {method: "custom/notification", params: {verify: true}}) - await wait() - ist(received.verify) - }) - - it("can report unknown notifications", async () => { - let received: any = null - let {client} = setup({client: {unhandledNotification: (client, method, params) => received = params}}) - client.request("custom/sendNotification", {method: "custom/notification", params: {verify: true}}) - await wait() - ist(received.verify) - }) - - it("can display messages in the editor", async () => { - let {client} = setup() - let cm = ed(client, {}) - client.request("custom/sendNotification", {method: "window/showMessage", params: {type: 2 /* Warning */, message: "WARNING"}}) - await wait() - let dialog = cm.dom.querySelector(".cm-lsp-message-warning")! - ist(dialog) - ist(dialog.innerHTML.indexOf("WARNING"), "-1", ">") - }) - - it("routes exceptions from Transport.send to the request promise", async () => { - let broken = false - let {client} = setup({server: {brokenPipe: () => broken}}) - await client.initializing - broken = true - let req = client.request("test", {}) - try { - await req - ist(false) - } catch (e: any) { - ist(e.message, "Broken Pipe") - } - }) - - describe("LSPPlugin", () => { - it("can render doc strings", () => { - let {client} = setup({client: {sanitizeHTML: s => s.replace(/x/g, "y")}}) - let cm = ed(client, {}) - ist(LSPPlugin.get(cm)!.docToHTML({kind: "markdown", value: "# xx"}), "

yy

\n") - }) - - it("can render doc strings with highlighting", () => { - let {client} = setup() - let cm = ed(client, {extensions: [ - javascript(), - syntaxHighlighting(classHighlighter) - ]}) - ist(LSPPlugin.get(cm)!.docToHTML({kind: "markdown", value: "```javascript\nreturn\n```"}), - '
return\n
\n') - }) - - it("can convert to LSP positions", () => { - let {client} = setup() - let cm = ed(client, {doc: "one\ntwo\nthree"}) - let pos = LSPPlugin.get(cm)!.toPosition(6) - ist(pos.line, 1) - ist(pos.character, 2) - }) - - it("can convert from positions", () => { - let {client} = setup() - let cm = ed(client, {doc: "one\ntwo\nthree"}) - ist(LSPPlugin.get(cm)!.fromPosition({line: 0, character: 3}), 3) - ist(LSPPlugin.get(cm)!.fromPosition({line: 2, character: 1}), 9) - }) - - it("can display errors", () => { - let {client} = setup() - let cm = ed(client, {}) - LSPPlugin.get(cm)!.reportError("E", "Oh no") - ist(cm.dom.querySelector(".cm-lsp-message-error")!.innerHTML.indexOf("Oh no"), -1, ">") - }) - }) - - describe("renameSymbol", () => { - it("can run a rename", async () => { - let {client} = setup() - let cm = place(ed(client, {doc: "let foo = 1; console.log(foo)", selection: {anchor: 4}})) - let cm2 = ed(client, {doc: "foo?"}, "file:///2") - await wait() - ist(renameSymbol(cm), true) - let form = cm.dom.querySelector(".cm-panel form") as HTMLFormElement - form.querySelector("input")!.value = "bar" - form.requestSubmit() - await wait() - ist(cm.state.sliceDoc(), "let bar = 1; console.log(bar)") - ist(cm2.state.sliceDoc(), "bar?") - cm.destroy() - }) - - it("can handle changes during the request", async () => { - let {client} = setup({server: {delay: {"textDocument/rename": 5}}}) - let cm = place(ed(client, {doc: "let foo = 1; console.log(foo)", selection: {anchor: 4}})) - await wait() - ist(renameSymbol(cm), true) - let form = cm.dom.querySelector(".cm-panel form") as HTMLFormElement - form.querySelector("input")!.value = "bar" - form.requestSubmit() - await wait() - cm.dispatch({changes: {from: 0, insert: " "}}) - await wait(10) - ist(cm.state.sliceDoc(), " let bar = 1; console.log(bar)") - cm.destroy() - }) - }) - - describe("formatDocument", () => { - it("can make format requests", async () => { - let {client} = setup() - let cm = ed(client, {doc: "hawthorn"}) - formatDocument(cm) - await wait() - ist(cm.state.sliceDoc(), "hawthorn\n// formatted!") - }) - }) - - describe("completion", () => { - it("can get completions from the server", async () => { - let {client} = setup() - let cm = ed(client, {doc: "..o", selection: {anchor: 3}, extensions: [ - serverCompletion(), - autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) - ]}) - startCompletion(cm) - await wait(60) - let cs = currentCompletions(cm.state) - ist(cs.length, 2) - ist(cs[0].label, "one") - ist(cs[1].label, "ookay") - acceptCompletion(cm) - ist(cm.state.sliceDoc(), "..one!") - cm.dispatch({changes: {from: 6, insert: "\no"}, userEvent: "input.type", selection: {anchor: 8}}) - await wait(20) - ist(currentCompletions(cm.state).length, 2) - moveCompletionSelection(true)(cm) - await wait() - ist(cm.dom.querySelector(".cm-completionInfo")) - acceptCompletion(cm) - ist(cm.state.sliceDoc(), "..one!\nokay") - }) - - it("expands snippet completions and unescapes LSP escapes", async () => { - let {client} = setup() - let cm = ed(client, {doc: "..f", selection: {anchor: 3}, extensions: [ - serverCompletion(), - autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) - ]}) - startCompletion(cm) - await wait(60) - let cs = currentCompletions(cm.state) - ist(cs.length, 1) - ist(cs[0].label, "fn") - acceptCompletion(cm) - // `\$arg` must come through as a literal `$arg`, not the raw `\$arg`. - ist(cm.state.sliceDoc(), "..fn($arg)") - }) - - it("applies additionalTextEdits (e.g. an auto-import)", async () => { - let {client} = setup() - let cm = ed(client, {doc: "..b", selection: {anchor: 3}, extensions: [ - serverCompletion(), - autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) - ]}) - startCompletion(cm) - await wait(60) - let cs = currentCompletions(cm.state) - ist(cs.length, 1) - ist(cs[0].label, "Bar") - acceptCompletion(cm) - // The completion inserts "Bar" *and* the item's additionalTextEdits add - // the `use Bar;` line at the top. - ist(cm.state.sliceDoc(), "use Bar;\n..Bar") - }) - - it("maps additionalTextEdit positions through changes that arrive while the completion is active", async () => { - let {client} = setup() - // Server's "Zap" completion ships an additionalTextEdit at line 2, - // char 0 — i.e. the start of "L2" in this document. - let cm = ed(client, {doc: "L0\nL1\nL2\n..z", selection: {anchor: 12}, extensions: [ - serverCompletion(), - autocompletion({interactionDelay: 0, activateOnTypingDelay: 10}) - ]}) - startCompletion(cm) - await wait(60) - let cs = currentCompletions(cm.state) - ist(cs.length, 1) - ist(cs[0].label, "Zap") - // Insert a line at the very top. This is far from the cursor, so CM - // keeps the completion alive — but the LSP edit's `line: 2` now refers - // to a different line in the new document. - cm.dispatch({changes: {from: 0, insert: "HEADER\n"}}) - acceptCompletion(cm) - // The "MID\n" insertion should land before the *original* L2 line, - // which after the HEADER insertion is the third line. - ist(cm.state.sliceDoc(), "HEADER\nL0\nL1\nMID\nL2\n..Zap") - }) - }) - - describe("hoverTooltips", () => { - it("can retrieve hover info", async () => { - let {client} = setup() - let cm = place(ed(client, {doc: "speedwell", extensions: [ - hoverTooltips({hoverTime: 10}), - javascript(), - syntaxHighlighting(classHighlighter) - ]})) - let pos = cm.coordsAtPos(1)!, x = pos.left, y = pos.top + 5 - cm.contentDOM.firstChild!.dispatchEvent(new MouseEvent("mousemove", { - screenX: x, screenY: y, - clientX: x, clientY: y, - bubbles: true - })) - await wait(15) - ist(cm.dom.querySelector(".cm-tooltip .tok-string")) - cm.destroy() - }) - }) - - describe("signatureHelp", () => { - it("can display a signature", async () => { - let {client} = setup() - let cm = ed(client, {doc: "bugloss"}) - showSignatureHelp(cm) - await wait() - ist(cm.dom.querySelector(".cm-lsp-active-parameter")!.innerHTML, "b") - nextSignature(cm) - ist(cm.dom.querySelector(".cm-lsp-active-parameter")!.innerHTML, "y") - }) - }) -}) diff --git a/src/cm/lsp/tooltipExtensions.ts b/src/cm/lsp/tooltipExtensions.ts index d1f3179eb..2a9a157ea 100644 --- a/src/cm/lsp/tooltipExtensions.ts +++ b/src/cm/lsp/tooltipExtensions.ts @@ -29,6 +29,7 @@ import { import { highlightCode } from "@lezer/highlight"; import type { HoverParams, + ServerCapabilities, SignatureHelpContext, SignatureHelpParams, } from "vscode-languageserver-protocol"; @@ -44,7 +45,6 @@ interface LspClientInternals { config?: { highlightLanguage?: (language: string) => Language | null | undefined; }; - hasCapability?: (name: string) => boolean; } const SIGNATURE_TRIGGER_DELAY = 120; @@ -56,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() @@ -350,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); } @@ -370,8 +376,7 @@ function lspTooltipSource( pos: number, ): Promise { const plugins = LSPPlugin.getAll(view, "hover").filter( - (plugin) => - plugin.client.hasCapability?.("hoverProvider") !== false, + (plugin) => clientHasCapability(plugin.client, "hoverProvider"), ); if (!plugins.length) return Promise.resolve(null); @@ -448,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); } @@ -645,7 +649,7 @@ const signaturePlugin = ViewPlugin.fromClass( const plugin = LSPPlugin.getAll(update.view, "signatureHelp").find( (candidate) => - candidate.client.hasCapability?.("signatureHelpProvider") !== false, + clientHasCapability(candidate.client, "signatureHelpProvider"), ); if (!plugin) return; @@ -807,7 +811,7 @@ export const showSignatureHelp: Command = (view) => { const lspPlugin = LSPPlugin.getAll(view, "signatureHelp").find( (candidate) => - candidate.client.hasCapability?.("signatureHelpProvider") !== false, + clientHasCapability(candidate.client, "signatureHelpProvider"), ); if (!lspPlugin) return false; diff --git a/tsconfig.json b/tsconfig.json index 26dd0ac13..98c8a285d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ "jsx": "preserve", "types": ["node"] }, - "include": ["src/**/*", "packages/lsp-client/src/**/*"], + "include": ["src/**/*"], "exclude": [ "node_modules", "platforms", From 2978461111d63aabba8499e1dc230f5394d6be78 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:39:41 +0530 Subject: [PATCH 5/5] fix --- bun.lock | 4 ++-- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index d3866fb7c..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": "git+https://github.com/Acode-Foundation/codemirror-lsp-client.git#db6930c2b6db67e14978f08ae8f5aeb149d29468", + "@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", @@ -426,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@git+https://github.com/Acode-Foundation/codemirror-lsp-client.git#db6930c2b6db67e14978f08ae8f5aeb149d29468", { "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" } }, "db6930c2b6db67e14978f08ae8f5aeb149d29468"], + "@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=="], diff --git a/package-lock.json b/package-lock.json index 2b9602ef7..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": "git+https://github.com/Acode-Foundation/codemirror-lsp-client.git#db6930c2b6db67e14978f08ae8f5aeb149d29468", + "@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/package.json b/package.json index 8710b5d94..d1fa0f3da 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,7 @@ "@codemirror/language-data": "^6.5.2", "@codemirror/legacy-modes": "^6.5.3", "@codemirror/lint": "^6.9.7", - "@codemirror/lsp-client": "git+https://github.com/Acode-Foundation/codemirror-lsp-client.git#db6930c2b6db67e14978f08ae8f5aeb149d29468", + "@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",