diff --git a/bun.lock b/bun.lock index 9a60acd1c..2e3f69e04 100644 --- a/bun.lock +++ b/bun.lock @@ -78,6 +78,10 @@ "picomatch": "^4.0.4", "url-parse": "^1.5.10", "vanilla-picker": "^2.12.3", + "vscode-css-languageservice": "6.3.10", + "vscode-html-languageservice": "5.6.2", + "vscode-json-languageservice": "5.7.2", + "vscode-languageserver-textdocument": "1.0.12", "yargs": "^18.0.0", }, "devDependencies": { @@ -726,6 +730,8 @@ "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], @@ -1538,6 +1544,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="], @@ -2174,12 +2182,22 @@ "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw=="], + "vscode-css-languageservice": ["vscode-css-languageservice@6.3.10", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA=="], + + "vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="], + + "vscode-json-languageservice": ["vscode-json-languageservice@5.7.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], diff --git a/package-lock.json b/package-lock.json index 50540a937..bd40e8ff3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,10 @@ "picomatch": "^4.0.4", "url-parse": "^1.5.10", "vanilla-picker": "^2.12.3", + "vscode-css-languageservice": "6.3.10", + "vscode-html-languageservice": "5.6.2", + "vscode-json-languageservice": "5.7.2", + "vscode-languageserver-textdocument": "1.0.12", "yargs": "^18.0.0" }, "devDependencies": { @@ -18565,6 +18569,67 @@ "version": "0.0.1", "dev": true, "license": "Apache-2.0" + }, + "node_modules/vscode-css-languageservice": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", + "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, + "node_modules/vscode-html-languageservice": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", + "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-json-languageservice": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.2.tgz", + "integrity": "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "jsonc-parser": "^3.3.1", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 39fc2f685..7ceabea90 100644 --- a/package.json +++ b/package.json @@ -187,6 +187,10 @@ "picomatch": "^4.0.4", "url-parse": "^1.5.10", "vanilla-picker": "^2.12.3", + "vscode-css-languageservice": "6.3.10", + "vscode-html-languageservice": "5.6.2", + "vscode-json-languageservice": "5.7.2", + "vscode-languageserver-textdocument": "1.0.12", "yargs": "^18.0.0" }, "overrides": { diff --git a/rspack.config.js b/rspack.config.js index c8e64e72d..08d1b8dce 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -13,6 +13,10 @@ module.exports = (env, options) => { : ''; const rules = [ + { + test: /typescript[\\/]lib[\\/]lib\..*\.d\.ts$/, + type: 'asset/source', + }, // TypeScript/TSX files - Custom JSX loader + SWC { test: /\.tsx?$/, @@ -120,12 +124,20 @@ module.exports = (env, options) => { const main = { mode, + node: { + __dirname: false, + __filename: false, + }, entry: { boot: './src/boot.js', main: './src/main.js', console: './src/lib/console.js', searchInFilesWorker: './src/sidebarApps/searchInFiles/worker.js', searchIndexWorker: './src/sidebarApps/searchInFiles/indexWorker.js', + htmlLspWorker: './src/cm/lsp/workers/html.worker.ts', + cssLspWorker: './src/cm/lsp/workers/css.worker.ts', + jsonLspWorker: './src/cm/lsp/workers/json.worker.ts', + typescriptLspWorker: './src/cm/lsp/workers/typescript.worker.ts', }, output: { path: path.resolve(__dirname, 'www/build/'), @@ -135,8 +147,17 @@ module.exports = (env, options) => { publicPath: devOrigin ? ''.concat(devOrigin, '/build/') : '/build/', clean: !isDev, }, + // TypeScript's Node-only plugin loader uses a dynamic require. It is never + // reached by the browser worker, but Rspack cannot prove that statically. + ignoreWarnings: [ + { + module: /typescript[\\/]lib[\\/]typescript\.js$/, + message: /Critical dependency/, + }, + ], module: { rules, + exprContextCritical: false, parser: { javascript: { exportsPresence: 'error', diff --git a/src/cm/lsp/clientManager.ts b/src/cm/lsp/clientManager.ts index 4dd47daa0..c22b59d7c 100644 --- a/src/cm/lsp/clientManager.ts +++ b/src/cm/lsp/clientManager.ts @@ -241,10 +241,13 @@ interface InitContext { key: string; normalizedRootUri: string | null; originalRootUri: string | null; + runtimeRootUri: string | null; + runtimeOriginalRootUri: string | null; originalDocumentUri: string; documentUri: string; runtimeProvider: LspRuntimeProvider; scope: LspClientScope; + signal: AbortSignal; } interface ResolvedRuntimeTarget { @@ -261,11 +264,56 @@ interface ExtendedLSPClient extends LSPClient { __acodeLoggedInfo?: boolean; } +interface PendingClientInitialization { + serverId: string; + controller: AbortController; + promise: Promise; +} + +function initializationCancelledError(serverId: string): Error { + const error = new Error(`LSP client initialization cancelled for ${serverId}`); + error.name = "AbortError"; + return error; +} + +function throwIfInitializationCancelled( + signal: AbortSignal, + serverId: string, +): void { + if (!signal.aborted) return; + throw initializationCancelledError(serverId); +} + +function waitForInitialization( + promise: PromiseLike, + signal: AbortSignal, + serverId: string, +): Promise { + throwIfInitializationCancelled(signal, serverId); + return new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener("abort", onAbort); + reject(initializationCancelledError(serverId)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + Promise.resolve(promise).then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + export class LspClientManager { options: ClientManagerOptions; #clients: Map; - #pendingClients: Map>; + #pendingClients: Map; constructor(options: ClientManagerOptions = {}) { this.options = { ...options }; @@ -449,6 +497,27 @@ export class LspClientManager { } } + async disposeServer(serverId: string): Promise { + const normalizedId = safeString(serverId).toLowerCase(); + if (!normalizedId) return; + const pendingDisposals: Promise[] = []; + for (const [key, pending] of this.#pendingClients.entries()) { + if (pending.serverId !== normalizedId) continue; + pending.controller.abort(); + this.#pendingClients.delete(key); + pendingDisposals.push( + pending.promise.then((state) => state.dispose()), + ); + } + const states = Array.from(this.#clients.values()).filter( + (state) => state.server.id.toLowerCase() === normalizedId, + ); + await Promise.allSettled([ + ...states.map((state) => state.dispose()), + ...pendingDisposals, + ]); + } + async dispose(): Promise { try { interface FileWithSession { @@ -499,6 +568,11 @@ export class LspClientManager { } const disposeOps: Promise[] = []; + for (const [key, pending] of this.#pendingClients.entries()) { + pending.controller.abort(); + this.#pendingClients.delete(key); + disposeOps.push(pending.promise.then((state) => state.dispose())); + } for (const [key, state] of this.#clients.entries()) { disposeOps.push(state.dispose()); this.#clients.delete(key); @@ -543,25 +617,50 @@ export class LspClientManager { // If initialization is already in progress, wait for it if (this.#pendingClients.has(key)) { - return this.#pendingClients.get(key)!; + const pending = await this.#pendingClients.get(key)!.promise; + if (useWsFolders && normalizedRootUri) { + const workspace = pending.client.workspace as AcodeWorkspace | null; + if (workspace && !workspace.hasWorkspaceFolder(normalizedRootUri)) { + workspace.addWorkspaceFolder(normalizedRootUri); + } + } + return pending; } // Create and track the pending initialization + const controller = new AbortController(); const initPromise = this.#initializeClient(server, context, { key, normalizedRootUri: useWsFolders ? null : normalizedRootUri, originalRootUri: useWsFolders ? null : originalRootUri, + runtimeRootUri: normalizedRootUri, + runtimeOriginalRootUri: originalRootUri, originalDocumentUri: target.originalDocumentUri, documentUri, runtimeProvider, scope, + signal: controller.signal, }); - this.#pendingClients.set(key, initPromise); + const pending: PendingClientInitialization = { + serverId: server.id.toLowerCase(), + controller, + promise: initPromise, + }; + this.#pendingClients.set(key, pending); try { - return await initPromise; + const state = await initPromise; + if (useWsFolders && normalizedRootUri) { + const workspace = state.client.workspace as AcodeWorkspace | null; + if (workspace && !workspace.hasWorkspaceFolder(normalizedRootUri)) { + workspace.addWorkspaceFolder(normalizedRootUri); + } + } + return state; } finally { - this.#pendingClients.delete(key); + if (this.#pendingClients.get(key) === pending) { + this.#pendingClients.delete(key); + } } } @@ -574,10 +673,13 @@ export class LspClientManager { key, normalizedRootUri, originalRootUri, + runtimeRootUri, + runtimeOriginalRootUri, originalDocumentUri, documentUri, runtimeProvider, scope, + signal, } = initContext; const workspaceOptions = { @@ -846,13 +948,14 @@ export class LspClientManager { let runtimeConnection: LspRuntimeConnection | undefined; try { + throwIfInitializationCancelled(signal, server.id); const runtimeContext = { ...context, uri: documentUri, documentUri, originalDocumentUri, - rootUri: normalizedRootUri ?? null, - originalRootUri: originalRootUri ?? undefined, + rootUri: runtimeRootUri, + originalRootUri: runtimeOriginalRootUri ?? undefined, serverId: server.id, allowNonTerminalWorkspace: this.options.allowNonTerminalWorkspace === true, @@ -871,17 +974,18 @@ export class LspClientManager { } }; runtimeConnection = connection; + throwIfInitializationCancelled(signal, server.id); transportHandle = createTransportFromRuntimeConnection( server, runtimeContext, connection, ); - await transportHandle.ready; + await waitForInitialization(transportHandle.ready, signal, server.id); client = new LSPClient(clientConfig) as ExtendedLSPClient; client.__acodeServerId = server.id; connectClient(client, transportHandle.transport, initializationOptions); - await client.initializing; + await waitForInitialization(client.initializing, signal, server.id); if (!client.__acodeLoggedInfo) { // Log root URI info to console if (normalizedRootUri) { @@ -906,6 +1010,11 @@ export class LspClientManager { client.__acodeLoggedInfo = true; } } catch (error) { + try { + client?.disconnect(); + } catch { + /* Client may not have finished connecting */ + } if (transportHandle) { await transportHandle.dispose?.(); } else { @@ -946,6 +1055,7 @@ export class LspClientManager { const fileRefs = new Map>(); const uriAliases = new Map(); const effectiveRoot = normalizedRootUri ?? originalRootUri ?? null; + let disposed = false; const attach = ( uri: string, @@ -995,6 +1105,9 @@ export class LspClientManager { }; const dispose = async (): Promise => { + if (disposed) return; + disposed = true; + this.#clients.delete(key); try { client.disconnect(); } catch (error) { @@ -1005,7 +1118,6 @@ export class LspClientManager { } catch (error) { console.warn(`Error disposing LSP transport ${server.id}`, error); } - this.#clients.delete(key); }; return { diff --git a/src/cm/lsp/diagnostics.ts b/src/cm/lsp/diagnostics.ts index ae671b374..c035e150a 100644 --- a/src/cm/lsp/diagnostics.ts +++ b/src/cm/lsp/diagnostics.ts @@ -301,13 +301,7 @@ export function lspDiagnosticsUiExtension(includeGutter = true): Extension[] { }), ]; if (includeGutter) { - extensions.splice( - 1, - 0, - lintGutter({ - tooltipFilter: diagnosticsTooltipFilter, - }), - ); + extensions.splice(1, 0, lintGutter()); } return extensions; } diff --git a/src/cm/lsp/index.ts b/src/cm/lsp/index.ts index e3dfebaf6..da567dfcb 100644 --- a/src/cm/lsp/index.ts +++ b/src/cm/lsp/index.ts @@ -103,6 +103,7 @@ export { registerRuntimeProvider, selectRuntimeProvider, unregisterRuntimeProvider, + WEB_WORKER_RUNTIME_ID, } from "./runtimeProviders"; export { checkRuntimeServerInstallation, diff --git a/src/cm/lsp/providerUtils.ts b/src/cm/lsp/providerUtils.ts index 93e8a3094..62c2890f4 100644 --- a/src/cm/lsp/providerUtils.ts +++ b/src/cm/lsp/providerUtils.ts @@ -13,6 +13,7 @@ export interface ManagedServerOptions { languages: string[]; enabled?: boolean; useWorkspaceFolders?: boolean; + runtimes?: string[]; command?: string; args?: string[]; transport?: Partial; @@ -70,6 +71,7 @@ export function defineServer(options: ManagedServerOptions): LspServerManifest { languages, enabled = true, useWorkspaceFolders = false, + runtimes, command, args, transport, @@ -95,6 +97,7 @@ export function defineServer(options: ManagedServerOptions): LspServerManifest { languages, enabled, useWorkspaceFolders, + runtimes, transport: { kind: "websocket", ...(transport || {}), diff --git a/src/cm/lsp/runtimeProviders.ts b/src/cm/lsp/runtimeProviders.ts index 0a4e4a7c4..61e932353 100644 --- a/src/cm/lsp/runtimeProviders.ts +++ b/src/cm/lsp/runtimeProviders.ts @@ -8,6 +8,7 @@ import { getConfiguredRuntimeId } from "./runtimeSettings"; export const BUILTIN_ALPINE_RUNTIME_ID = "builtin-alpine"; export const EXTERNAL_WEBSOCKET_RUNTIME_ID = "external-websocket"; +export const WEB_WORKER_RUNTIME_ID = "web-worker"; interface RegisterRuntimeProviderOptions { replace?: boolean; @@ -211,6 +212,7 @@ export function isBuiltinAlpineAccessible( export default { BUILTIN_ALPINE_RUNTIME_ID, + WEB_WORKER_RUNTIME_ID, getRuntimeProvider, inferWorkspaceKind, isBuiltinAlpineAccessible, diff --git a/src/cm/lsp/runtimes/registerBuiltins.ts b/src/cm/lsp/runtimes/registerBuiltins.ts index fe7efadd9..5e49c5c9f 100644 --- a/src/cm/lsp/runtimes/registerBuiltins.ts +++ b/src/cm/lsp/runtimes/registerBuiltins.ts @@ -1,6 +1,8 @@ import { registerRuntimeProvider } from "../runtimeProviders"; import builtinAlpineRuntimeProvider from "./builtinAlpine"; import externalWebSocketRuntimeProvider from "./externalWebSocket"; +import webWorkerRuntimeProvider from "./webWorker"; registerRuntimeProvider(builtinAlpineRuntimeProvider, { replace: true }); registerRuntimeProvider(externalWebSocketRuntimeProvider, { replace: true }); +registerRuntimeProvider(webWorkerRuntimeProvider, { replace: true }); diff --git a/src/cm/lsp/runtimes/webWorker.ts b/src/cm/lsp/runtimes/webWorker.ts new file mode 100644 index 000000000..67c4ddcc9 --- /dev/null +++ b/src/cm/lsp/runtimes/webWorker.ts @@ -0,0 +1,237 @@ +import { addLspLog } from "../logs"; +import type { + InstallCheckResult, + LspRuntimeProvider, + LspServerDefinition, + Transport, + TransportContext, + TransportHandle, +} from "../types"; + +export const WEB_WORKER_RUNTIME_ID = "web-worker"; + +const SUPPORTED_SERVERS = new Set(["html", "css", "json", "typescript"]); +const WORKER_URLS: Record = { + html: "build/htmlLspWorker.js", + css: "build/cssLspWorker.js", + json: "build/jsonLspWorker.js", + typescript: "build/typescriptLspWorker.js", +}; +const STARTUP_TIMEOUT = 10000; + +interface WorkerControlMessage { + kind: "ready" | "host-request" | "log"; + id?: number; + level?: "error" | "warn" | "info"; + message?: string; + method?: "readFile"; + uri?: string; +} + +interface WorkerHostResponse { + kind: "host-response"; + id: number; + result?: unknown; + error?: string; +} + +type MessageListener = (data: string) => void; + +const bundledStatus: InstallCheckResult = { + status: "present", + version: "bundled", + canInstall: false, + canUpdate: false, + message: "Built into Acode and runs offline in a Web Worker.", +}; + +async function fileSystemRequest( + method: NonNullable, + uri: string, +): Promise { + const { default: fsOperation } = await import("fileSystem"); + const fs = fsOperation(uri); + if (!fs) throw new Error(`No filesystem provider can handle ${uri}`); + return (await fs.readFile("utf-8")) as string; +} + +function isControlMessage(value: unknown): value is WorkerControlMessage { + return ( + !!value && + typeof value === "object" && + "kind" in value && + typeof (value as { kind?: unknown }).kind === "string" + ); +} + +function createWorkerTransport( + server: LspServerDefinition, + context: TransportContext, +): TransportHandle { + const workerUrl = WORKER_URLS[server.id]; + if (!workerUrl) { + throw new Error(`No built-in worker is available for ${server.id}`); + } + const worker = new Worker(workerUrl, { + name: `acode-${server.id}-lsp`, + }); + const listeners = new Set(); + let disposed = false; + let readySettled = false; + let resolveReady: () => void; + let rejectReady: (error: Error) => void; + + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + const startupTimer = setTimeout(() => { + if (readySettled || disposed) return; + readySettled = true; + rejectReady( + new Error(`Timed out starting the built-in ${server.label} worker`), + ); + worker.terminate(); + }, server.startupTimeout ?? STARTUP_TIMEOUT); + + function settleReady(error?: Error): void { + if (readySettled) return; + readySettled = true; + clearTimeout(startupTimer); + if (error) rejectReady(error); + else resolveReady(); + } + + async function handleHostRequest(message: WorkerControlMessage): Promise { + const id = message.id; + if (typeof id !== "number" || !message.method || !message.uri) { + return; + } + + const response: WorkerHostResponse = { + kind: "host-response", + id, + }; + try { + response.result = await fileSystemRequest(message.method, message.uri); + } catch (error) { + response.error = error instanceof Error ? error.message : String(error); + } + if (!disposed) worker.postMessage(response); + } + + worker.onmessage = (event: MessageEvent) => { + const data = event.data; + if (typeof data === "string") { + for (const listener of listeners) { + try { + listener(data); + } catch (error) { + console.error("Worker LSP transport listener failed", error); + } + } + return; + } + + if (!isControlMessage(data)) return; + switch (data.kind) { + case "ready": + settleReady(); + addLspLog(server.id, "info", "Built-in Web Worker is ready"); + break; + case "host-request": + void handleHostRequest(data); + break; + case "log": { + const level = data.level ?? "info"; + const message = data.message ?? "Worker message"; + addLspLog(server.id, level, message); + console[level](`[LSP:${server.id}:worker] ${message}`); + break; + } + } + }; + + worker.onerror = (event: ErrorEvent) => { + const error = new Error( + event.message || `The built-in ${server.label} worker failed`, + ); + addLspLog(server.id, "error", error.message); + settleReady(error); + }; + + worker.postMessage({ + kind: "configure", + serverId: server.id, + initializationOptions: server.initializationOptions, + rootUri: context.originalRootUri ?? context.rootUri, + }); + + const transport: Transport = { + send(message: string): void { + if (disposed) { + throw new Error(`The built-in ${server.label} worker is closed`); + } + worker.postMessage(message); + }, + subscribe(handler: MessageListener): void { + listeners.add(handler); + }, + unsubscribe(handler: MessageListener): void { + listeners.delete(handler); + }, + }; + + return { + transport, + ready, + dispose(): void { + if (disposed) return; + disposed = true; + clearTimeout(startupTimer); + listeners.clear(); + worker.terminate(); + }, + }; +} + +export const webWorkerRuntimeProvider: LspRuntimeProvider = { + id: WEB_WORKER_RUNTIME_ID, + label: "Built-in Web Worker", + priority: 100, + + canHandle(server) { + return SUPPORTED_SERVERS.has(server.id) && typeof Worker !== "undefined"; + }, + + resolveUris(_server, context) { + return { + documentUri: context.originalDocumentUri, + rootUri: context.originalRootUri, + scope: "workspace", + }; + }, + + async checkInstallation() { + return bundledStatus; + }, + + getInstallCommand() { + return null; + }, + + getUninstallCommand() { + return null; + }, + + async start(server, context) { + return { + kind: "transport", + providerId: WEB_WORKER_RUNTIME_ID, + transport: createWorkerTransport(server, context), + }; + }, +}; + +export default webWorkerRuntimeProvider; diff --git a/src/cm/lsp/servers/javascript.ts b/src/cm/lsp/servers/javascript.ts index 066359275..e8873835a 100644 --- a/src/cm/lsp/servers/javascript.ts +++ b/src/cm/lsp/servers/javascript.ts @@ -5,7 +5,7 @@ import { resolveJsTsLanguageId } from "./shared"; export const javascriptServers: LspServerManifest[] = [ defineServer({ id: "typescript", - label: "TypeScript / JavaScript", + label: "TypeScript / JavaScript (Web Worker)", useWorkspaceFolders: true, languages: [ "javascript", @@ -15,9 +15,30 @@ export const javascriptServers: LspServerManifest[] = [ "tsx", "jsx", ], - transport: { - kind: "websocket", + runtimes: ["web-worker"], + transport: { kind: "external" }, + enabled: true, + clientConfig: { + builtinExtensions: { + inlayHints: true, + }, }, + resolveLanguageId: ({ languageId, languageName }) => + resolveJsTsLanguageId(languageId, languageName), + }), + defineServer({ + id: "typescript-stdio", + label: "TypeScript / JavaScript (STDIO)", + useWorkspaceFolders: true, + languages: [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "tsx", + "jsx", + ], + runtimes: ["builtin-alpine"], command: "typescript-language-server", args: ["--stdio"], checkCommand: "which typescript-language-server", @@ -25,7 +46,7 @@ export const javascriptServers: LspServerManifest[] = [ executable: "typescript-language-server", packages: ["typescript-language-server", "typescript"], }), - enabled: true, + enabled: false, initializationOptions: { provideFormatter: true, hostInfo: "acode", @@ -62,6 +83,11 @@ export const javascriptServers: LspServerManifest[] = [ reportStyleChecksAsWarnings: true, }, }, + clientConfig: { + builtinExtensions: { + inlayHints: true, + }, + }, resolveLanguageId: ({ languageId, languageName }) => resolveJsTsLanguageId(languageId, languageName), }), @@ -77,6 +103,7 @@ export const javascriptServers: LspServerManifest[] = [ "tsx", "jsx", ], + runtimes: ["builtin-alpine"], transport: { kind: "websocket", }, diff --git a/src/cm/lsp/servers/web.ts b/src/cm/lsp/servers/web.ts index ecc074665..4b811312a 100644 --- a/src/cm/lsp/servers/web.ts +++ b/src/cm/lsp/servers/web.ts @@ -4,8 +4,51 @@ import type { LspServerBundle, LspServerManifest } from "../types"; export const webServers: LspServerManifest[] = [ defineServer({ id: "html", - label: "HTML", + label: "HTML (Web Worker)", languages: ["html", "vue", "svelte"], + runtimes: ["web-worker"], + transport: { kind: "external" }, + clientConfig: { + builtinExtensions: { + keymaps: false, + }, + }, + useWorkspaceFolders: true, + enabled: true, + }), + defineServer({ + id: "css", + label: "CSS (Web Worker)", + languages: ["css", "scss", "less"], + runtimes: ["web-worker"], + transport: { kind: "external" }, + clientConfig: { + builtinExtensions: { + keymaps: false, + }, + }, + useWorkspaceFolders: true, + enabled: true, + }), + defineServer({ + id: "json", + label: "JSON (Web Worker)", + languages: ["json", "jsonc"], + runtimes: ["web-worker"], + transport: { kind: "external" }, + clientConfig: { + builtinExtensions: { + keymaps: false, + }, + }, + useWorkspaceFolders: true, + enabled: true, + }), + defineServer({ + id: "html-stdio", + label: "HTML (STDIO)", + languages: ["html", "vue", "svelte"], + runtimes: ["builtin-alpine"], command: "vscode-html-language-server", args: ["--stdio"], checkCommand: "which vscode-html-language-server", @@ -18,12 +61,14 @@ export const webServers: LspServerManifest[] = [ keymaps: false, }, }, - enabled: true, + useWorkspaceFolders: true, + enabled: false, }), defineServer({ - id: "css", - label: "CSS", + id: "css-stdio", + label: "CSS (STDIO)", languages: ["css", "scss", "less"], + runtimes: ["builtin-alpine"], command: "vscode-css-language-server", args: ["--stdio"], checkCommand: "which vscode-css-language-server", @@ -36,12 +81,14 @@ export const webServers: LspServerManifest[] = [ keymaps: false, }, }, - enabled: true, + useWorkspaceFolders: true, + enabled: false, }), defineServer({ - id: "json", - label: "JSON", + id: "json-stdio", + label: "JSON (STDIO)", languages: ["json", "jsonc"], + runtimes: ["builtin-alpine"], command: "vscode-json-language-server", args: ["--stdio"], checkCommand: "which vscode-json-language-server", @@ -54,7 +101,8 @@ export const webServers: LspServerManifest[] = [ keymaps: false, }, }, - enabled: true, + useWorkspaceFolders: true, + enabled: false, }), ]; diff --git a/src/cm/lsp/workers/css.worker.ts b/src/cm/lsp/workers/css.worker.ts new file mode 100644 index 000000000..c5c3b5cff --- /dev/null +++ b/src/cm/lsp/workers/css.worker.ts @@ -0,0 +1,209 @@ +import { + getCSSLanguageService, + getLESSLanguageService, + getSCSSLanguageService, + type LanguageService, + type Stylesheet, +} from "vscode-css-languageservice"; +import type { TextDocument } from "vscode-languageserver-textdocument"; +import { + getTextDocument, + METHOD_NOT_HANDLED, + resolveReference, + startWorkerServer, +} from "./protocol"; + +interface CachedStylesheet { + version: number; + languageId: string; + stylesheet: Stylesheet; +} + +startWorkerServer(({ documents }) => { + const services = { + css: getCSSLanguageService(), + less: getLESSLanguageService(), + scss: getSCSSLanguageService(), + }; + const parsedDocuments = new Map(); + const documentContext = { resolveReference }; + + function getService(document: TextDocument): LanguageService { + if (document.languageId === "less") return services.less; + if (document.languageId === "scss") return services.scss; + return services.css; + } + + function getStylesheet( + document: TextDocument, + service: LanguageService, + ): Stylesheet { + const cached = parsedDocuments.get(document.uri); + if ( + cached?.version === document.version && + cached.languageId === document.languageId + ) { + return cached.stylesheet; + } + const stylesheet = service.parseStylesheet(document); + parsedDocuments.set(document.uri, { + version: document.version, + languageId: document.languageId, + stylesheet, + }); + return stylesheet; + } + + return { + capabilities: { + completionProvider: { + resolveProvider: false, + triggerCharacters: ["/", "-", ":", "("], + }, + hoverProvider: true, + documentFormattingProvider: true, + documentRangeFormattingProvider: true, + documentSymbolProvider: true, + definitionProvider: true, + referencesProvider: true, + renameProvider: { + prepareProvider: true, + }, + documentHighlightProvider: true, + documentLinkProvider: { + resolveProvider: false, + }, + codeActionProvider: true, + colorProvider: true, + foldingRangeProvider: true, + selectionRangeProvider: true, + }, + + validate(document) { + const service = getService(document); + return service.doValidation( + document, + getStylesheet(document, service), + ); + }, + + async request(method, params) { + const document = getTextDocument(documents, params); + if (!document) return null; + const service = getService(document); + const stylesheet = getStylesheet(document, service); + const request = params as { + position: Parameters[1]; + range?: Parameters[1]; + options: Parameters[2]; + newName: string; + context: Parameters[2]; + color: Parameters[2]; + positions: Parameters[1]; + }; + + switch (method) { + case "textDocument/completion": + return service.doComplete2( + document, + request.position, + stylesheet, + documentContext, + ); + case "textDocument/hover": + return service.doHover(document, request.position, stylesheet); + case "textDocument/formatting": + return service.format(document, undefined, request.options); + case "textDocument/rangeFormatting": + return service.format(document, request.range, request.options); + case "textDocument/documentSymbol": + return service.findDocumentSymbols2(document, stylesheet); + case "textDocument/definition": { + const definition = service.findDefinition( + document, + request.position, + stylesheet, + ); + return definition ? [definition] : null; + } + case "textDocument/references": + return service.findReferences( + document, + request.position, + stylesheet, + ); + case "textDocument/prepareRename": + return ( + service.prepareRename(document, request.position, stylesheet) ?? + null + ); + case "textDocument/rename": + return service.doRename( + document, + request.position, + request.newName, + stylesheet, + ); + case "textDocument/documentHighlight": + return service.findDocumentHighlights( + document, + request.position, + stylesheet, + ); + case "textDocument/documentLink": + return service.findDocumentLinks2( + document, + stylesheet, + documentContext, + ); + case "textDocument/codeAction": + return service.doCodeActions2( + document, + request.range!, + request.context, + stylesheet, + ); + case "textDocument/documentColor": + return service.findDocumentColors(document, stylesheet); + case "textDocument/colorPresentation": + return service.getColorPresentations( + document, + stylesheet, + request.color, + request.range!, + ); + case "textDocument/foldingRange": + return service.getFoldingRanges(document); + case "textDocument/selectionRange": + return service.getSelectionRanges( + document, + request.positions, + stylesheet, + ); + default: + return METHOD_NOT_HANDLED; + } + }, + + configure(settings) { + const languageSettings = settings as + | { + css?: Parameters[0]; + less?: Parameters[0]; + scss?: Parameters[0]; + } + | undefined; + services.css.configure(languageSettings?.css); + services.less.configure(languageSettings?.less); + services.scss.configure(languageSettings?.scss); + }, + + dispose() { + parsedDocuments.clear(); + }, + + closeDocument(uri) { + parsedDocuments.delete(uri); + }, + }; +}); diff --git a/src/cm/lsp/workers/html.worker.ts b/src/cm/lsp/workers/html.worker.ts new file mode 100644 index 000000000..8dfdb8f19 --- /dev/null +++ b/src/cm/lsp/workers/html.worker.ts @@ -0,0 +1,633 @@ +import { + getCSSLanguageService, + type LanguageService as CssLanguageService, + type Stylesheet, +} from "vscode-css-languageservice"; +import { + getLanguageService as getHtmlLanguageService, + type HTMLDocument, + type LanguageService as HtmlLanguageService, +} from "vscode-html-languageservice"; +import { + ClientCapabilities, + getLanguageService as getJsonLanguageService, + type JSONDocument, + type LanguageService as JsonLanguageService, +} from "vscode-json-languageservice"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import type { + Diagnostic, + DocumentSymbol, + FoldingRange, + Position, +} from "vscode-languageserver-types"; +import { + clearDocumentRegions, + getDocumentRegions, + type HTMLDocumentRegions, +} from "./htmlEmbeddedSupport"; +import { importMapSchema } from "./jsonSchemas"; +import { NestedLspClient } from "./nestedLspClient"; +import { + getTextDocument, + METHOD_NOT_HANDLED, + resolveReference, + startWorkerServer, +} from "./protocol"; + +interface CachedHtmlDocument { + version: number; + document: HTMLDocument; +} + +interface EmbeddedDocuments { + regions: HTMLDocumentRegions; + css: TextDocument | null; + importmap: TextDocument | null; + javascript: TextDocument | null; +} + +interface CachedEmbeddedDocuments extends EmbeddedDocuments { + version: number; +} + +interface RequestParams { + position: Position; + range?: Parameters[1]; + options: Parameters[2]; + newName: string; + context: Parameters[2]; + color: Parameters[2]; + positions: Parameters[1]; +} + +startWorkerServer(({ documents, requestFile, rootUri }) => { + const htmlService = getHtmlLanguageService(); + const cssService = getCSSLanguageService(); + const jsonService = createImportMapService(); + const parsedHtml = new Map(); + const embeddedDocuments = new Map(); + const parsedCss = new Map(); + const parsedJson = new Map(); + const documentContext = { resolveReference }; + let typescriptClient: NestedLspClient | null | undefined; + + function getTypeScriptClient(): NestedLspClient | null { + if (typescriptClient !== undefined) return typescriptClient; + if (typeof Worker === "undefined") { + typescriptClient = null; + return null; + } + typescriptClient = new NestedLspClient( + "typescriptLspWorker.js", + { + serverId: "typescript", + rootUri, + }, + requestFile, + ); + return typescriptClient; + } + + async function requestTypeScript( + method: string, + params: unknown, + document: TextDocument | null, + ): Promise { + const client = getTypeScriptClient(); + if (!client) return null; + return client.request(method, params, document ?? undefined); + } + + function getHtml(document: TextDocument): HTMLDocument { + const cached = parsedHtml.get(document.uri); + if (cached?.version === document.version) return cached.document; + const parsed = htmlService.parseHTMLDocument(document); + parsedHtml.set(document.uri, { + version: document.version, + document: parsed, + }); + return parsed; + } + + function getEmbedded(document: TextDocument): EmbeddedDocuments { + const cached = embeddedDocuments.get(document.uri); + if (cached?.version === document.version) return cached; + const regions = getDocumentRegions(htmlService, document); + const cssText = regions.getEmbeddedDocument("css", false); + const importMapText = regions.getEmbeddedDocument("importmap", false); + const javascriptText = regions.getEmbeddedDocument("javascript", false); + const result: CachedEmbeddedDocuments = { + version: document.version, + regions, + css: cssText + ? TextDocument.create( + document.uri, + "css", + document.version, + cssText, + ) + : null, + importmap: importMapText + ? TextDocument.create( + document.uri, + "json", + document.version, + importMapText, + ) + : null, + javascript: javascriptText + ? TextDocument.create( + document.uri, + "javascript", + document.version, + javascriptText, + ) + : null, + }; + embeddedDocuments.set(document.uri, result); + return result; + } + + function getCss(document: TextDocument): Stylesheet { + const cached = parsedCss.get(document.uri); + if (cached?.version === document.version) return cached.document; + const parsed = cssService.parseStylesheet(document); + parsedCss.set(document.uri, { + version: document.version, + document: parsed, + }); + return parsed; + } + + function getJson(document: TextDocument): JSONDocument { + const cached = parsedJson.get(document.uri); + if (cached?.version === document.version) return cached.document; + const parsed = jsonService.parseJSONDocument(document); + parsedJson.set(document.uri, { + version: document.version, + document: parsed, + }); + return parsed; + } + + function embeddedLanguageAt( + embedded: EmbeddedDocuments, + position?: Position, + ): string | undefined { + return position + ? embedded.regions.getEmbeddedLanguageAtPosition(position) + : undefined; + } + + return { + capabilities: { + completionProvider: { + resolveProvider: true, + triggerCharacters: ["<", " ", ":", '"', "=", "/", "-", "("], + }, + hoverProvider: true, + signatureHelpProvider: { + triggerCharacters: ["(", ",", "<"], + retriggerCharacters: [")"], + }, + documentFormattingProvider: true, + documentRangeFormattingProvider: true, + documentSymbolProvider: true, + definitionProvider: true, + referencesProvider: true, + renameProvider: true, + documentHighlightProvider: true, + documentLinkProvider: { + resolveProvider: false, + }, + codeActionProvider: true, + colorProvider: true, + foldingRangeProvider: true, + selectionRangeProvider: true, + linkedEditingRangeProvider: true, + inlayHintProvider: true, + }, + + async validate(document) { + const diagnostics: Diagnostic[] = []; + const embedded = getEmbedded(document); + const importMapRegion = embedded.regions.regions.find( + (region) => region.languageId === "importmap", + ); + + if (importMapRegion) { + const addOrderingDiagnostic = (range: { + start: number; + end: number; + }) => { + diagnostics.push({ + severity: 1, + range: { + start: document.positionAt(range.start), + end: document.positionAt(range.end), + }, + message: "Scripts are not allowed before the import map.", + source: "html", + }); + }; + for (const script of embedded.regions.importedScripts) { + if (script.end < importMapRegion.start) { + addOrderingDiagnostic(script); + } + } + for (const region of embedded.regions.regions) { + if ( + region.languageId === "javascript" && + region.end < importMapRegion.start + ) { + addOrderingDiagnostic(region); + } + } + } + + if (embedded.css) { + diagnostics.push( + ...cssService.doValidation( + embedded.css, + getCss(embedded.css), + ), + ); + } + if (embedded.importmap) { + diagnostics.push( + ...(await jsonService.doValidation( + embedded.importmap, + getJson(embedded.importmap), + undefined, + importMapSchema.schema, + )), + ); + } + if (embedded.javascript) { + const javascriptDiagnostics = await requestTypeScript( + "acode/validate", + { + textDocument: { uri: embedded.javascript.uri }, + }, + embedded.javascript, + ); + if (Array.isArray(javascriptDiagnostics)) { + diagnostics.push(...(javascriptDiagnostics as Diagnostic[])); + } + } + return diagnostics; + }, + + async request(method, params) { + if (method === "completionItem/resolve") { + if ( + params && + typeof params === "object" && + (params as { data?: { uri?: unknown } }).data?.uri + ) { + return requestTypeScript(method, params, null); + } + return jsonService.doResolve( + params as Parameters[0], + ); + } + + const document = getTextDocument(documents, params); + if (!document) return null; + const html = getHtml(document); + const embedded = getEmbedded(document); + const request = params as RequestParams; + const languageId = embeddedLanguageAt( + embedded, + request.position ?? request.range?.start, + ); + const css = embedded.css; + const importmap = embedded.importmap; + const javascript = embedded.javascript; + + switch (method) { + case "textDocument/completion": + if (languageId === "css" && css) { + return cssService.doComplete2( + css, + request.position, + getCss(css), + documentContext, + ); + } + if (languageId === "importmap" && importmap) { + return jsonService.doComplete( + importmap, + request.position, + getJson(importmap), + ); + } + if (languageId === "javascript") { + return requestTypeScript(method, params, javascript); + } + return htmlService.doComplete2( + document, + request.position, + html, + documentContext, + ); + case "textDocument/hover": + if (languageId === "css" && css) { + return cssService.doHover(css, request.position, getCss(css)); + } + if (languageId === "importmap" && importmap) { + return jsonService.doHover( + importmap, + request.position, + getJson(importmap), + ); + } + if (languageId === "javascript") { + return requestTypeScript(method, params, javascript); + } + return htmlService.doHover(document, request.position, html); + case "textDocument/signatureHelp": + return languageId === "javascript" + ? requestTypeScript(method, params, javascript) + : null; + case "textDocument/formatting": + return htmlService.format(document, undefined, { + ...request.options, + contentUnformatted: "script", + }); + case "textDocument/rangeFormatting": + if (languageId === "css" && css) { + return cssService.format(css, request.range, request.options); + } + if (languageId === "importmap" && importmap) { + return jsonService.format( + importmap, + request.range, + { + tabSize: request.options.tabSize ?? 4, + insertSpaces: request.options.insertSpaces ?? true, + }, + ); + } + if (languageId === "javascript") { + return requestTypeScript(method, params, javascript); + } + return htmlService.format( + document, + request.range, + request.options, + ); + case "textDocument/documentSymbol": { + const symbols: DocumentSymbol[] = + htmlService.findDocumentSymbols2(document, html); + if (css) { + symbols.push( + ...cssService.findDocumentSymbols2(css, getCss(css)), + ); + } + if (importmap) { + symbols.push( + ...jsonService.findDocumentSymbols2( + importmap, + getJson(importmap), + ), + ); + } + if (javascript) { + const javascriptSymbols = await requestTypeScript( + method, + params, + javascript, + ); + if (Array.isArray(javascriptSymbols)) { + symbols.push(...(javascriptSymbols as DocumentSymbol[])); + } + } + return symbols; + } + case "textDocument/definition": + if (languageId === "css" && css) { + const definition = cssService.findDefinition( + css, + request.position, + getCss(css), + ); + return definition ? [definition] : null; + } + if (languageId === "importmap" && importmap) { + return jsonService.findDefinition( + importmap, + request.position, + getJson(importmap), + ); + } + if (languageId === "javascript") { + return requestTypeScript(method, params, javascript); + } + return null; + case "textDocument/references": + if (languageId === "javascript") { + return requestTypeScript(method, params, javascript); + } + return languageId === "css" && css + ? cssService.findReferences( + css, + request.position, + getCss(css), + ) + : null; + case "textDocument/rename": + if (languageId === "css" && css) { + return cssService.doRename( + css, + request.position, + request.newName, + getCss(css), + ); + } + if (languageId === "javascript") { + return requestTypeScript(method, params, javascript); + } + if (languageId) return null; + return htmlService.doRename( + document, + request.position, + request.newName, + html, + ); + case "textDocument/documentHighlight": + if (languageId === "css" && css) { + return cssService.findDocumentHighlights( + css, + request.position, + getCss(css), + ); + } + if (languageId === "javascript") { + return requestTypeScript(method, params, javascript); + } + if (languageId) return null; + return htmlService.findDocumentHighlights( + document, + request.position, + html, + ); + case "textDocument/documentLink": { + const links = htmlService.findDocumentLinks( + document, + documentContext, + ); + if (css) { + links.push( + ...(await cssService.findDocumentLinks2( + css, + getCss(css), + documentContext, + )), + ); + } + if (importmap) { + links.push( + ...(await jsonService.findLinks( + importmap, + getJson(importmap), + )), + ); + } + return links; + } + case "textDocument/codeAction": + if (languageId === "javascript") { + return requestTypeScript(method, params, javascript); + } + return languageId === "css" && css + ? cssService.doCodeActions2( + css, + request.range!, + request.context, + getCss(css), + ) + : []; + case "textDocument/documentColor": + return css + ? cssService.findDocumentColors(css, getCss(css)) + : []; + case "textDocument/colorPresentation": + return languageId === "css" && css + ? cssService.getColorPresentations( + css, + getCss(css), + request.color, + request.range!, + ) + : []; + case "textDocument/foldingRange": { + const ranges: FoldingRange[] = + htmlService.getFoldingRanges(document); + if (css) ranges.push(...cssService.getFoldingRanges(css)); + if (importmap) { + ranges.push(...jsonService.getFoldingRanges(importmap)); + } + if (javascript) { + const javascriptRanges = await requestTypeScript( + method, + params, + javascript, + ); + if (Array.isArray(javascriptRanges)) { + ranges.push(...(javascriptRanges as FoldingRange[])); + } + } + return ranges.sort((a, b) => a.startLine - b.startLine); + } + case "textDocument/selectionRange": + if ( + embeddedLanguageAt(embedded, request.positions?.[0]) === "css" && + css + ) { + return cssService.getSelectionRanges( + css, + request.positions, + getCss(css), + ); + } + if ( + embeddedLanguageAt(embedded, request.positions?.[0]) === + "importmap" && + importmap + ) { + return jsonService.getSelectionRanges( + importmap, + request.positions, + getJson(importmap), + ); + } + if ( + embeddedLanguageAt(embedded, request.positions?.[0]) === + "javascript" && + javascript + ) { + return requestTypeScript(method, params, javascript); + } + return htmlService.getSelectionRanges( + document, + request.positions, + ); + case "textDocument/linkedEditingRange": { + if (languageId) return null; + const ranges = htmlService.findLinkedEditingRanges( + document, + request.position, + html, + ); + return ranges ? { ranges } : null; + } + case "textDocument/inlayHint": + return languageId === "javascript" + ? requestTypeScript(method, params, javascript) + : []; + default: + return METHOD_NOT_HANDLED; + } + }, + + closeDocument(uri) { + parsedHtml.delete(uri); + embeddedDocuments.delete(uri); + parsedCss.delete(uri); + parsedJson.delete(uri); + clearDocumentRegions(uri); + jsonService.resetSchema(uri); + typescriptClient?.closeDocument(uri); + }, + + dispose() { + parsedHtml.clear(); + embeddedDocuments.clear(); + parsedCss.clear(); + parsedJson.clear(); + clearDocumentRegions(); + typescriptClient?.dispose(); + typescriptClient = null; + }, + }; +}); + +function createImportMapService(): JsonLanguageService { + const service = getJsonLanguageService({ + promiseConstructor: Promise, + clientCapabilities: ClientCapabilities.LATEST, + }); + service.configure({ + validate: true, + allowComments: false, + schemas: [ + { + ...importMapSchema, + fileMatch: ["*"], + }, + ], + }); + return service; +} diff --git a/src/cm/lsp/workers/htmlEmbeddedSupport.ts b/src/cm/lsp/workers/htmlEmbeddedSupport.ts new file mode 100644 index 000000000..af0759064 --- /dev/null +++ b/src/cm/lsp/workers/htmlEmbeddedSupport.ts @@ -0,0 +1,311 @@ +import { + type LanguageService, + type Position, + type TextDocument, + TokenType, +} from "vscode-html-languageservice"; + +export interface EmbeddedRegion { + start: number; + end: number; + languageId?: string; + attributeValue?: boolean; +} + +export interface ImportedScript { + start: number; + end: number; + src: string; +} + +export interface HTMLDocumentRegions { + readonly regions: readonly EmbeddedRegion[]; + readonly importedScripts: readonly ImportedScript[]; + getEmbeddedDocument( + languageId: string, + ignoreAttributeValues: boolean, + ): string | null; + getEmbeddedLanguages(ignoreAttributeValues?: boolean): string[]; + getEmbeddedLanguageAtPosition(position: Position): string | undefined; + hasEmbeddedLanguage( + languageId: string, + ignoreAttributeValues?: boolean, + ): boolean; +} + +interface CachedRegions { + regions: EmbeddedRegion[]; + importedScripts: ImportedScript[]; + version: number; + expires: number; +} + +const cache = new Map(); + +export function clearDocumentRegions(uri?: string): void { + if (uri) cache.delete(uri); + else cache.clear(); +} + +export function getDocumentRegions( + languageService: LanguageService, + document: TextDocument, +): HTMLDocumentRegions { + const regions: EmbeddedRegion[] = []; + const importedScripts: ImportedScript[] = []; + const cached = cache.get(document.uri); + + if ( + cached && + cached.version === document.version && + cached.expires > Date.now() + ) { + regions.push(...cached.regions); + importedScripts.push(...cached.importedScripts); + } else { + const scanner = languageService.createScanner(document.getText()); + let lastTagName = ""; + let lastAttributeName: string | null = null; + let lastLanguageId: string | undefined; + let token = scanner.scan(); + + while (token !== TokenType.EOS) { + switch (token) { + case TokenType.StartTag: + lastTagName = scanner.getTokenText(); + lastAttributeName = null; + lastLanguageId = "javascript"; + break; + case TokenType.Styles: + regions.push({ + languageId: "css", + start: scanner.getTokenOffset(), + end: scanner.getTokenEnd(), + }); + break; + case TokenType.Script: + regions.push({ + languageId: lastLanguageId, + start: scanner.getTokenOffset(), + end: scanner.getTokenEnd(), + }); + break; + case TokenType.AttributeName: + lastAttributeName = scanner.getTokenText(); + break; + case TokenType.AttributeValue: + if ( + lastAttributeName === "src" && + lastTagName.toLowerCase() === "script" + ) { + let src = scanner.getTokenText(); + if (src[0] === "'" || src[0] === '"') { + src = src.slice(1, -1); + } + importedScripts.push({ + start: scanner.getTokenOffset(), + end: scanner.getTokenEnd(), + src, + }); + } else if ( + lastAttributeName === "type" && + lastTagName.toLowerCase() === "script" + ) { + const tokenText = scanner.getTokenText(); + if ( + /["'](module|(text|application)\/(java|ecma)script|text\/babel)["']/.test( + tokenText, + ) + ) { + lastLanguageId = "javascript"; + } else if (/["']importmap["']/.test(tokenText)) { + lastLanguageId = "importmap"; + } else { + lastLanguageId = undefined; + } + } else if (lastAttributeName) { + const languageId = getAttributeLanguage(lastAttributeName); + if (languageId) { + let start = scanner.getTokenOffset(); + let end = scanner.getTokenEnd(); + const firstChar = document.getText()[start]; + if (firstChar === "'" || firstChar === '"') { + start++; + end--; + } + regions.push({ + languageId, + start, + end, + attributeValue: true, + }); + } + } + lastAttributeName = null; + break; + } + token = scanner.scan(); + } + + cache.set(document.uri, { + regions, + importedScripts, + version: document.version, + expires: Date.now() + 30000, + }); + } + + return { + regions, + importedScripts, + getEmbeddedDocument: (languageId, ignoreAttributeValues) => + getEmbeddedDocument( + document, + regions, + languageId, + ignoreAttributeValues, + ), + getEmbeddedLanguages: (ignoreAttributeValues) => + getEmbeddedLanguages(regions, ignoreAttributeValues), + getEmbeddedLanguageAtPosition: (position) => + getEmbeddedLanguageAtPosition(document, regions, position), + hasEmbeddedLanguage: (languageId, ignoreAttributeValues) => + regions.some( + (region) => + region.languageId === languageId && + (!ignoreAttributeValues || !region.attributeValue), + ), + }; +} + +function getEmbeddedLanguages( + regions: EmbeddedRegion[], + ignoreAttributeValues?: boolean, +): string[] { + const result: string[] = []; + for (const { languageId, attributeValue } of regions) { + if ( + languageId && + (!ignoreAttributeValues || !attributeValue) && + !result.includes(languageId) + ) { + result.push(languageId); + } + } + return result; +} + +function getEmbeddedLanguageAtPosition( + document: TextDocument, + regions: EmbeddedRegion[], + position: Position, +): string | undefined { + const offset = document.offsetAt(position); + for (const region of regions) { + if (region.start > offset) break; + if (offset <= region.end) return region.languageId; + } + return undefined; +} + +function getEmbeddedDocument( + document: TextDocument, + regions: EmbeddedRegion[], + languageId: string, + ignoreAttributeValues: boolean, +): string | null { + const documentText = document.getText(); + let currentPosition = 0; + let result = ""; + let lastSuffix = ""; + let hasAny = false; + + for (const region of regions) { + if ( + region.languageId === languageId && + (!ignoreAttributeValues || !region.attributeValue) + ) { + result = substituteWithWhitespace( + result, + currentPosition, + region.start, + documentText, + lastSuffix, + getPrefix(region), + ); + result += updateContent( + region, + documentText.substring(region.start, region.end), + ); + currentPosition = region.end; + lastSuffix = getSuffix(region); + hasAny = true; + } + } + + return hasAny ? result + lastSuffix : null; +} + +function getPrefix(region: EmbeddedRegion): string { + if (region.attributeValue && region.languageId === "css") return "__{"; + return ""; +} + +function getSuffix(region: EmbeddedRegion): string { + if (!region.attributeValue) return ""; + if (region.languageId === "css") return "}"; + if (region.languageId === "javascript") return ";"; + return ""; +} + +function updateContent(region: EmbeddedRegion, content: string): string { + if (!region.attributeValue && region.languageId === "javascript") { + return content.replace("", " */"); + } + return content; +} + +function substituteWithWhitespace( + result: string, + start: number, + end: number, + oldContent: string, + before: string, + after: string, +): string { + result += before; + let accumulatedWhitespace = -before.length; + for (let index = start; index < end; index++) { + const character = oldContent[index]; + if (character === "\n" || character === "\r") { + accumulatedWhitespace = 0; + result += character; + } else { + accumulatedWhitespace++; + } + } + result = append(result, " ", accumulatedWhitespace - after.length); + return result + after; +} + +function append(result: string, value: string, count: number): string { + let repeatedValue = value; + let remaining = count; + while (remaining > 0) { + if (remaining & 1) result += repeatedValue; + remaining >>= 1; + repeatedValue += repeatedValue; + } + return result; +} + +function getAttributeLanguage(attributeName: string): string | null { + if (attributeName === "style") return "css"; + if ( + attributeName.startsWith("on") && + /^[a-z]+$/.test(attributeName.slice(2)) + ) { + return "javascript"; + } + return null; +} diff --git a/src/cm/lsp/workers/json.worker.ts b/src/cm/lsp/workers/json.worker.ts new file mode 100644 index 000000000..5357d6e1a --- /dev/null +++ b/src/cm/lsp/workers/json.worker.ts @@ -0,0 +1,183 @@ +import { + ClientCapabilities, + getLanguageService, + type JSONDocument, + type LanguageService, +} from "vscode-json-languageservice"; +import type { TextDocument } from "vscode-languageserver-textdocument"; +import { + getTextDocument, + METHOD_NOT_HANDLED, + startWorkerServer, +} from "./protocol"; +import { schemas } from "./jsonSchemas"; + +interface CachedJsonDocument { + version: number; + languageId: string; + document: JSONDocument; +} + +startWorkerServer(({ documents, requestFile }) => { + async function requestSchema(uri: string): Promise { + const url = new URL(uri); + if (url.protocol === "http:" || url.protocol === "https:") { + const response = await fetch(url.href); + if (!response.ok) { + throw new Error( + `Unable to load JSON schema (${response.status} ${response.statusText})`, + ); + } + return response.text(); + } + return requestFile(uri); + } + + function createService(allowComments: boolean): LanguageService { + const service = getLanguageService({ + schemaRequestService: requestSchema, + workspaceContext: { + resolveRelativePath(relativePath, resource) { + return new URL(relativePath, resource).href; + }, + }, + promiseConstructor: Promise, + clientCapabilities: ClientCapabilities.LATEST, + }); + service.configure({ + validate: true, + allowComments, + schemas, + }); + return service; + } + + const services = { + json: createService(false), + jsonc: createService(true), + }; + const parsedDocuments = new Map(); + + function getService(document: TextDocument): LanguageService { + return document.languageId === "jsonc" ? services.jsonc : services.json; + } + + function getParsed( + document: TextDocument, + service: LanguageService, + ): JSONDocument { + const cached = parsedDocuments.get(document.uri); + if ( + cached?.version === document.version && + cached.languageId === document.languageId + ) { + return cached.document; + } + const parsed = service.parseJSONDocument(document); + parsedDocuments.set(document.uri, { + version: document.version, + languageId: document.languageId, + document: parsed, + }); + return parsed; + } + + return { + capabilities: { + completionProvider: { + resolveProvider: true, + triggerCharacters: [" ", ":", '"'], + }, + hoverProvider: true, + documentFormattingProvider: true, + documentRangeFormattingProvider: true, + documentSymbolProvider: true, + definitionProvider: true, + documentLinkProvider: { + resolveProvider: false, + }, + colorProvider: true, + foldingRangeProvider: true, + selectionRangeProvider: true, + }, + + validate(document) { + const service = getService(document); + return service.doValidation(document, getParsed(document, service), { + comments: document.languageId === "jsonc" ? "ignore" : "error", + trailingCommas: + document.languageId === "jsonc" ? "warning" : "error", + }); + }, + + async request(method, params) { + if (method === "completionItem/resolve") { + return services.json.doResolve( + params as Parameters[0], + ); + } + + const document = getTextDocument(documents, params); + if (!document) return null; + const service = getService(document); + const json = getParsed(document, service); + const request = params as { + position: Parameters[1]; + range?: Parameters[1]; + options: Parameters[2]; + color: Parameters[2]; + positions: Parameters[1]; + }; + + switch (method) { + case "textDocument/completion": + return service.doComplete(document, request.position, json); + case "textDocument/hover": + return service.doHover(document, request.position, json); + case "textDocument/formatting": + return service.format(document, undefined, request.options); + case "textDocument/rangeFormatting": + return service.format(document, request.range, request.options); + case "textDocument/documentSymbol": + return service.findDocumentSymbols2(document, json); + case "textDocument/definition": + return service.findDefinition(document, request.position, json); + case "textDocument/documentLink": + return service.findLinks(document, json); + case "textDocument/documentColor": + return service.findDocumentColors(document, json); + case "textDocument/colorPresentation": + return service.getColorPresentations( + document, + json, + request.color, + request.range!, + ); + case "textDocument/foldingRange": + return service.getFoldingRanges(document); + case "textDocument/selectionRange": + return service.getSelectionRanges( + document, + request.positions, + json, + ); + default: + return METHOD_NOT_HANDLED; + } + }, + + dispose() { + parsedDocuments.clear(); + for (const uri of documents.keys()) { + services.json.resetSchema(uri); + services.jsonc.resetSchema(uri); + } + }, + + closeDocument(uri) { + parsedDocuments.delete(uri); + services.json.resetSchema(uri); + services.jsonc.resetSchema(uri); + }, + }; +}); diff --git a/src/cm/lsp/workers/jsonSchemas.ts b/src/cm/lsp/workers/jsonSchemas.ts new file mode 100644 index 000000000..893270071 --- /dev/null +++ b/src/cm/lsp/workers/jsonSchemas.ts @@ -0,0 +1,60 @@ +import type { SchemaConfiguration } from "vscode-json-languageservice"; + +export const importMapSchema: SchemaConfiguration = { + uri: "https://github.com/denoland/vscode_deno/blob/main/schemas/import_map.schema.json", + fileMatch: [ + "import_map.json", + "import-map.json", + "importmap.json", + "importMap.json", + "*.importmap", + ], + schema: { + $schema: "http://json-schema.org/draft-07/schema#", + title: "An Import Map", + description: + "An import map which is used to remap imports when modules are loaded.", + type: "object", + properties: { + imports: { + description: "A map of specifiers to their remapped specifiers.", + type: "object", + properties: { + "@jsxRuntime": { + description: + "The key is the specifier for JSX import source, with a value that represents the target specifier.", + type: "string", + default: "https://esm.sh/react@18.3.1", + }, + }, + additionalProperties: { + description: + "The key is the specifier or partial specifier to match, with a value that represents the target specifier.", + type: "string", + }, + }, + scopes: { + description: + "Define a scope which remaps a specifier in only a specified scope", + type: "object", + additionalProperties: { + description: "A definition of a scoped remapping.", + type: "object", + additionalProperties: { + description: + "The key is the specifier or partial specifier to match within the referring scope, with a value that represents the target specifier.", + type: "string", + }, + }, + }, + }, + }, +}; + +export const schemas: SchemaConfiguration[] = [ + importMapSchema, + { + uri: "https://json.schemastore.org/tsconfig", + fileMatch: ["tsconfig.json"], + }, +]; diff --git a/src/cm/lsp/workers/nestedLspClient.ts b/src/cm/lsp/workers/nestedLspClient.ts new file mode 100644 index 000000000..db1ace69d --- /dev/null +++ b/src/cm/lsp/workers/nestedLspClient.ts @@ -0,0 +1,174 @@ +import type { TextDocument } from "vscode-languageserver-textdocument"; + +interface HostRequest { + kind: "host-request"; + id: number; + method: "readFile"; + uri: string; +} + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; +} + +export class NestedLspClient { + private readonly worker: Worker; + private readonly pending = new Map(); + private readonly versions = new Map(); + private nextRequestId = 0; + private ready: Promise; + + constructor( + url: string, + config: { + serverId: string; + rootUri?: string | null; + initializationOptions?: Record; + }, + private readonly requestFile: (uri: string) => Promise, + ) { + this.worker = new Worker(url, { + name: `acode-embedded-${config.serverId}-lsp`, + }); + this.ready = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Timed out starting ${config.serverId} worker`)), + 10000, + ); + this.worker.onmessage = (event: MessageEvent) => { + const data = event.data; + if ( + data && + typeof data === "object" && + (data as { kind?: unknown }).kind === "ready" + ) { + clearTimeout(timeout); + resolve(); + return; + } + void this.handleMessage(data); + }; + this.worker.onerror = (event) => { + clearTimeout(timeout); + reject(new Error(event.message || "Nested worker failed")); + }; + }).then(async () => { + await this.requestRaw("initialize", { + processId: null, + rootUri: config.rootUri ?? null, + capabilities: {}, + }); + this.notify("initialized", {}); + }); + this.worker.postMessage({ kind: "configure", ...config }); + } + + async syncDocument(document: TextDocument): Promise { + await this.ready; + const previousVersion = this.versions.get(document.uri); + if (previousVersion === document.version) return; + if (previousVersion === undefined) { + this.notify("textDocument/didOpen", { + textDocument: { + uri: document.uri, + languageId: document.languageId, + version: document.version, + text: document.getText(), + }, + }); + } else { + this.notify("textDocument/didChange", { + textDocument: { + uri: document.uri, + version: document.version, + }, + contentChanges: [{ text: document.getText() }], + }); + } + this.versions.set(document.uri, document.version); + } + + async request( + method: string, + params: unknown, + document?: TextDocument, + ): Promise { + await this.ready; + if (document) await this.syncDocument(document); + return this.requestRaw(method, params); + } + + closeDocument(uri: string): void { + if (!this.versions.delete(uri)) return; + this.notify("textDocument/didClose", { + textDocument: { uri }, + }); + } + + dispose(): void { + for (const request of this.pending.values()) { + request.reject(new Error("Nested worker disposed")); + } + this.pending.clear(); + this.versions.clear(); + this.worker.terminate(); + } + + private requestRaw(method: string, params: unknown): Promise { + const id = ++this.nextRequestId; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.worker.postMessage( + JSON.stringify({ jsonrpc: "2.0", id, method, params }), + ); + }); + } + + private notify(method: string, params: unknown): void { + this.worker.postMessage(JSON.stringify({ jsonrpc: "2.0", method, params })); + } + + private async handleMessage(data: unknown): Promise { + if (typeof data === "string") { + const message = JSON.parse(data) as { + id?: number; + result?: unknown; + error?: { message?: string }; + }; + if (typeof message.id !== "number") return; + const request = this.pending.get(message.id); + if (!request) return; + this.pending.delete(message.id); + if (message.error) { + request.reject(new Error(message.error.message || "Nested request failed")); + } else { + request.resolve(message.result); + } + return; + } + + const hostRequest = data as HostRequest | null; + if ( + !hostRequest || + hostRequest.kind !== "host-request" || + hostRequest.method !== "readFile" + ) { + return; + } + try { + const result = await this.requestFile(hostRequest.uri); + this.worker.postMessage({ + kind: "host-response", + id: hostRequest.id, + result, + }); + } catch (error) { + this.worker.postMessage({ + kind: "host-response", + id: hostRequest.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/src/cm/lsp/workers/protocol.ts b/src/cm/lsp/workers/protocol.ts new file mode 100644 index 000000000..e9bbf8b95 --- /dev/null +++ b/src/cm/lsp/workers/protocol.ts @@ -0,0 +1,407 @@ +import { + TextDocument, + type TextDocumentContentChangeEvent, +} from "vscode-languageserver-textdocument"; +import type { Diagnostic } from "vscode-languageserver-types"; + +export const METHOD_NOT_HANDLED = Symbol("method-not-handled"); + +type JsonRpcId = number | string | null; + +interface JsonRpcMessage { + jsonrpc?: string; + id?: JsonRpcId; + method?: string; + params?: unknown; +} + +interface ConfigureMessage { + kind: "configure"; + serverId: string; + initializationOptions?: Record; + rootUri?: string | null; +} + +interface HostResponseMessage { + kind: "host-response"; + id: number; + result?: unknown; + error?: string; +} + +interface HostRequestMessage { + kind: "host-request"; + id: number; + method: "readFile"; + uri: string; +} + +interface WorkerScope { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: unknown): void; + close(): void; +} + +interface TextDocumentIdentifier { + uri: string; +} + +interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier { + version: number; +} + +interface TextDocumentItem extends VersionedTextDocumentIdentifier { + languageId: string; + text: string; +} + +interface DidOpenParams { + textDocument: TextDocumentItem; +} + +interface DidChangeParams { + textDocument: VersionedTextDocumentIdentifier; + contentChanges: TextDocumentContentChangeEvent[]; +} + +interface DidCloseParams { + textDocument: TextDocumentIdentifier; +} + +export interface WorkerServerContext { + documents: Map; + initializationOptions?: Record; + rootUri?: string | null; + requestFile(uri: string): Promise; +} + +export interface WorkerLanguageAdapter { + capabilities: Record; + validate?(document: TextDocument): Diagnostic[] | PromiseLike; + request( + method: string, + params: unknown, + ): unknown | PromiseLike | typeof METHOD_NOT_HANDLED; + configure?(settings: unknown): void; + closeDocument?(uri: string): void; + dispose?(): void; +} + +type AdapterFactory = ( + context: WorkerServerContext, +) => WorkerLanguageAdapter | Promise; + +const workerScope = globalThis as unknown as WorkerScope; +const documents = new Map(); +const validationTimers = new Map>(); +const cancelledRequests = new Set(); +const hostRequests = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void } +>(); + +let adapter: WorkerLanguageAdapter | null = null; +let serverId = "worker"; +let nextHostRequestId = 0; + +function sendJson(message: unknown): void { + workerScope.postMessage(JSON.stringify(message)); +} + +function sendResponse(id: JsonRpcId, result: unknown): void { + sendJson({ jsonrpc: "2.0", id, result: result ?? null }); +} + +function sendError(id: JsonRpcId, code: number, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + sendJson({ + jsonrpc: "2.0", + id, + error: { + code, + message, + }, + }); +} + +function sendNotification(method: string, params: unknown): void { + sendJson({ jsonrpc: "2.0", method, params }); +} + +function sendLog(level: "error" | "warn" | "info", message: string): void { + workerScope.postMessage({ kind: "log", level, message }); +} + +function isConfigureMessage(value: unknown): value is ConfigureMessage { + return ( + !!value && + typeof value === "object" && + (value as { kind?: unknown }).kind === "configure" + ); +} + +function isHostResponse(value: unknown): value is HostResponseMessage { + return ( + !!value && + typeof value === "object" && + (value as { kind?: unknown }).kind === "host-response" + ); +} + +function requestHost(method: HostRequestMessage["method"], uri: string) { + const id = ++nextHostRequestId; + const message: HostRequestMessage = { + kind: "host-request", + id, + method, + uri, + }; + return new Promise((resolve, reject) => { + hostRequests.set(id, { resolve, reject }); + workerScope.postMessage(message); + }); +} + +async function requestFile(uri: string): Promise { + return String(await requestHost("readFile", uri)); +} + +function handleHostResponse(message: HostResponseMessage): void { + const pending = hostRequests.get(message.id); + if (!pending) return; + hostRequests.delete(message.id); + if (message.error) pending.reject(new Error(message.error)); + else pending.resolve(message.result); +} + +function publishDiagnostics( + uri: string, + version: number, + diagnostics: Diagnostic[], +): void { + sendNotification("textDocument/publishDiagnostics", { + uri, + version, + diagnostics, + }); +} + +function scheduleValidation(uri: string): void { + const existing = validationTimers.get(uri); + if (existing) clearTimeout(existing); + + validationTimers.set( + uri, + setTimeout(async () => { + validationTimers.delete(uri); + const document = documents.get(uri); + if (!document || !adapter?.validate) return; + const version = document.version; + try { + const diagnostics = await adapter.validate(document); + const current = documents.get(uri); + if (current?.version === version) { + publishDiagnostics(uri, version, diagnostics ?? []); + } + } catch (error) { + sendLog( + "warn", + `Validation failed for ${uri}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }, 180), + ); +} + +function didOpen(params: DidOpenParams): void { + const item = params.textDocument; + const document = TextDocument.create( + item.uri, + item.languageId, + item.version, + item.text, + ); + documents.set(item.uri, document); + scheduleValidation(item.uri); +} + +function didChange(params: DidChangeParams): void { + const identifier = params.textDocument; + const document = documents.get(identifier.uri); + if (!document) return; + TextDocument.update(document, params.contentChanges, identifier.version); + scheduleValidation(identifier.uri); +} + +function didClose(params: DidCloseParams): void { + const { uri } = params.textDocument; + const timer = validationTimers.get(uri); + if (timer) clearTimeout(timer); + validationTimers.delete(uri); + documents.delete(uri); + adapter?.closeDocument?.(uri); + publishDiagnostics(uri, 0, []); +} + +function cancelRequest(params: unknown): void { + const id = (params as { id?: JsonRpcId } | null)?.id; + if (id !== undefined) cancelledRequests.add(id); +} + +async function handleRequest(message: JsonRpcMessage): Promise { + const id = message.id ?? null; + const method = message.method ?? ""; + + try { + switch (method) { + case "initialize": + sendResponse(id, { + capabilities: { + textDocumentSync: { + openClose: true, + change: 2, + }, + workspace: { + workspaceFolders: { + supported: true, + changeNotifications: true, + }, + }, + ...(adapter?.capabilities ?? {}), + }, + serverInfo: { + name: `Acode built-in ${serverId} worker`, + }, + }); + return; + case "shutdown": + sendResponse(id, null); + return; + } + + if (!adapter) { + sendError(id, -32002, "Worker language service is not initialized"); + return; + } + + const result = await adapter.request(method, message.params); + if (cancelledRequests.delete(id)) { + sendError(id, -32800, "Request cancelled"); + return; + } + if (result === METHOD_NOT_HANDLED) { + sendError(id, -32601, `Method not implemented: ${method}`); + return; + } + sendResponse(id, result); + } catch (error) { + sendError(id, -32603, error); + } +} + +function handleNotification(message: JsonRpcMessage): void { + switch (message.method) { + case "textDocument/didOpen": + didOpen(message.params as DidOpenParams); + break; + case "textDocument/didChange": + didChange(message.params as DidChangeParams); + break; + case "textDocument/didClose": + didClose(message.params as DidCloseParams); + break; + case "workspace/didChangeConfiguration": + adapter?.configure?.( + (message.params as { settings?: unknown } | null)?.settings, + ); + for (const uri of documents.keys()) scheduleValidation(uri); + break; + case "$/cancelRequest": + cancelRequest(message.params); + break; + case "exit": + adapter?.dispose?.(); + workerScope.close(); + break; + } +} + +async function handleJsonRpc(data: string): Promise { + let message: JsonRpcMessage; + try { + message = JSON.parse(data) as JsonRpcMessage; + } catch { + sendLog("warn", "Ignored an invalid JSON-RPC message"); + return; + } + + if (!message.method) return; + if (message.id !== undefined) { + await handleRequest(message); + } else { + handleNotification(message); + } +} + +export function startWorkerServer(factory: AdapterFactory): void { + workerScope.onmessage = (event: MessageEvent) => { + const data = event.data; + if (isHostResponse(data)) { + handleHostResponse(data); + return; + } + if (isConfigureMessage(data)) { + if (adapter) return; + serverId = data.serverId; + Promise.resolve( + factory({ + documents, + initializationOptions: data.initializationOptions, + rootUri: data.rootUri, + requestFile, + }), + ).then( + (value) => { + adapter = value; + workerScope.postMessage({ kind: "ready" }); + }, + (error) => { + sendLog( + "error", + `Failed to initialize worker: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + throw error; + }, + ); + return; + } + if (typeof data === "string") { + void handleJsonRpc(data); + } + }; +} + +export function getTextDocument( + documentsMap: Map, + params: unknown, +): TextDocument | null { + const uri = ( + params as { textDocument?: { uri?: unknown } } | null + )?.textDocument?.uri; + return typeof uri === "string" ? (documentsMap.get(uri) ?? null) : null; +} + +export function resolveReference( + reference: string, + baseUri: string, +): string | undefined { + try { + return new URL(reference, baseUri).href; + } catch { + return undefined; + } +} diff --git a/src/cm/lsp/workers/typescript.worker.ts b/src/cm/lsp/workers/typescript.worker.ts new file mode 100644 index 000000000..2626d8a3c --- /dev/null +++ b/src/cm/lsp/workers/typescript.worker.ts @@ -0,0 +1,976 @@ +import ts from "typescript"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { + CompletionItemKind, + DiagnosticSeverity, + DiagnosticTag, + DocumentHighlightKind, + FoldingRangeKind, + InlayHintKind, + Range, + SelectionRange, + SymbolKind, + type CodeAction, + type CodeActionContext, + type CompletionItem, + type Diagnostic, + type DocumentHighlight, + type DocumentSymbol, + type FoldingRange, + type FormattingOptions, + type InlayHint, + type Location, + type LocationLink, + type MarkupContent, + type Position, + type SelectionRange as LspSelectionRange, + type SignatureHelp, + type TextEdit, + type WorkspaceEdit, +} from "vscode-languageserver-types"; +import { + getTextDocument, + METHOD_NOT_HANDLED, + startWorkerServer, +} from "./protocol"; +import libraries from "./typescriptLibs"; + +interface CompletionData { + uri: string; + offset: number; + name: string; + source?: string; + entryData?: ts.CompletionEntryData; +} + +interface RequestParams { + position: Position; + range?: Range; + options: FormattingOptions; + newName: string; + context: CodeActionContext & { + triggerKind?: number; + triggerCharacter?: string; + isRetrigger?: boolean; + }; +} + +class TypeScriptHost implements ts.LanguageServiceHost { + constructor( + private readonly documents: Map, + private readonly options: ts.CompilerOptions, + ) {} + + getCompilationSettings(): ts.CompilerOptions { + return this.options; + } + + getCurrentDirectory(): string { + return "/"; + } + + getDefaultLibFileName(): string { + return "lib.es2022.full.d.ts"; + } + + getScriptFileNames(): string[] { + return [...this.documents.keys()]; + } + + getScriptVersion(fileName: string): string { + const document = this.documents.get(fileName); + return document ? String(document.version) : "1"; + } + + getProjectVersion(): string { + return [...this.documents] + .map(([uri, document]) => `${uri}:${document.version}`) + .join("|"); + } + + getScriptSnapshot(fileName: string): ts.IScriptSnapshot | undefined { + const text = this.readFile(fileName); + return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text); + } + + getScriptKind(fileName: string): ts.ScriptKind { + const languageId = this.documents.get(fileName)?.languageId; + if (languageId === "typescriptreact" || languageId === "tsx") { + return ts.ScriptKind.TSX; + } + if (languageId === "javascriptreact" || languageId === "jsx") { + return ts.ScriptKind.JSX; + } + if (languageId === "typescript") return ts.ScriptKind.TS; + if (languageId === "javascript") return ts.ScriptKind.JS; + const path = uriPath(fileName).toLowerCase(); + if (path.endsWith(".tsx")) return ts.ScriptKind.TSX; + if (path.endsWith(".jsx")) return ts.ScriptKind.JSX; + if ( + path.endsWith(".ts") || + path.endsWith(".mts") || + path.endsWith(".cts") + ) { + return ts.ScriptKind.TS; + } + if ( + path.endsWith(".js") || + path.endsWith(".mjs") || + path.endsWith(".cjs") + ) { + return ts.ScriptKind.JS; + } + if (path.endsWith(".json")) return ts.ScriptKind.JSON; + return ts.ScriptKind.Unknown; + } + + fileExists(fileName: string): boolean { + return this.documents.has(fileName) || fileName in libraries; + } + + readFile(fileName: string): string | undefined { + return this.documents.get(fileName)?.getText() ?? libraries[fileName]; + } + + readDirectory(): string[] { + return [...this.documents.keys()]; + } + + directoryExists(): boolean { + return true; + } + + getDirectories(): string[] { + return []; + } + + useCaseSensitiveFileNames(): boolean { + return true; + } + + getNewLine(): string { + return "\n"; + } + + resolveModuleNames( + moduleNames: string[], + containingFile: string, + ): (ts.ResolvedModule | undefined)[] { + return moduleNames.map((moduleName) => { + const openDocument = this.resolveOpenDocument( + moduleName, + containingFile, + ); + if (openDocument) return openDocument; + return ts.resolveModuleName( + moduleName, + containingFile, + this.options, + this, + ).resolvedModule; + }); + } + + private resolveOpenDocument( + moduleName: string, + containingFile: string, + ): ts.ResolvedModule | undefined { + if (!moduleName.startsWith(".") && !/^[a-z]+:/i.test(moduleName)) { + return undefined; + } + let base: string; + try { + base = new URL(moduleName, containingFile).href; + } catch { + return undefined; + } + const candidates = [ + base, + `${base}.ts`, + `${base}.tsx`, + `${base}.js`, + `${base}.jsx`, + `${base}/index.ts`, + `${base}/index.tsx`, + `${base}/index.js`, + `${base}/index.jsx`, + ]; + for (const candidate of candidates) { + if (!this.documents.has(candidate)) continue; + return { + resolvedFileName: candidate, + isExternalLibraryImport: false, + }; + } + return undefined; + } +} + +startWorkerServer(async ({ documents, requestFile, rootUri }) => { + const compilerOptions = await loadCompilerOptions(rootUri, requestFile); + const host = new TypeScriptHost(documents, compilerOptions); + const service = ts.createLanguageService( + host, + ts.createDocumentRegistry(true, "/"), + ); + + return { + capabilities: { + completionProvider: { + resolveProvider: true, + triggerCharacters: [".", "/", '"', "'", "<"], + }, + hoverProvider: true, + signatureHelpProvider: { + triggerCharacters: ["(", ",", "<"], + retriggerCharacters: [")"], + }, + documentFormattingProvider: true, + documentRangeFormattingProvider: true, + documentSymbolProvider: true, + definitionProvider: true, + referencesProvider: true, + renameProvider: { + prepareProvider: true, + }, + documentHighlightProvider: true, + codeActionProvider: true, + foldingRangeProvider: true, + selectionRangeProvider: true, + inlayHintProvider: true, + }, + + validate(document) { + const diagnostics: ts.Diagnostic[] = [ + ...service.getSyntacticDiagnostics(document.uri), + ...service.getSuggestionDiagnostics(document.uri), + ]; + if (isTypeScriptDocument(document)) { + diagnostics.push(...service.getSemanticDiagnostics(document.uri)); + } + return diagnostics.map((diagnostic) => + toDiagnostic(document, diagnostic), + ); + }, + + request(method, params) { + if (method === "completionItem/resolve") { + return resolveCompletion( + service, + documents, + params as CompletionItem, + ); + } + + const document = getTextDocument(documents, params); + if (!document) return null; + const request = params as RequestParams; + const offset = request.position + ? document.offsetAt(request.position) + : 0; + + switch (method) { + case "acode/validate": { + const diagnostics: ts.Diagnostic[] = [ + ...service.getSyntacticDiagnostics(document.uri), + ...service.getSuggestionDiagnostics(document.uri), + ]; + if (isTypeScriptDocument(document)) { + diagnostics.push( + ...service.getSemanticDiagnostics(document.uri), + ); + } + return diagnostics.map((diagnostic) => + toDiagnostic(document, diagnostic), + ); + } + case "textDocument/completion": + return completions(service, document, offset); + case "textDocument/hover": + return hover(service, document, offset); + case "textDocument/signatureHelp": + return signatureHelp( + service, + document, + offset, + request.context, + ); + case "textDocument/formatting": + return format(service, document, undefined, request.options); + case "textDocument/rangeFormatting": + return format( + service, + document, + request.range, + request.options, + ); + case "textDocument/documentSymbol": + return documentSymbols(service, document); + case "textDocument/definition": + return definitions(service, documents, document, offset); + case "textDocument/references": + return references(service, documents, document, offset); + case "textDocument/prepareRename": { + const info = service.getRenameInfo(document.uri, offset, { + allowRenameOfImportPath: true, + }); + return info.canRename + ? rangeFromSpan(document, info.triggerSpan) + : null; + } + case "textDocument/rename": + return rename( + service, + documents, + document, + offset, + request.newName, + ); + case "textDocument/documentHighlight": + return documentHighlights(service, document, offset); + case "textDocument/codeAction": + return codeActions( + service, + documents, + document, + request.range!, + request.context, + request.options, + ); + case "textDocument/foldingRange": + return foldingRanges(service, document); + case "textDocument/selectionRange": + return selectionRanges( + service, + document, + (params as { positions: Position[] }).positions, + ); + case "textDocument/inlayHint": + return inlayHints(service, document, request.range!); + default: + return METHOD_NOT_HANDLED; + } + }, + + closeDocument() { + service.cleanupSemanticCache(); + }, + + dispose() { + service.dispose(); + }, + }; +}); + +async function loadCompilerOptions( + rootUri: string | null | undefined, + requestFile: (uri: string) => Promise, +): Promise { + const defaults: ts.CompilerOptions = { + allowJs: true, + checkJs: true, + allowImportingTsExtensions: true, + noEmit: true, + isolatedModules: true, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + moduleDetection: ts.ModuleDetectionKind.Force, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, + useDefineForClassFields: true, + }; + ( + defaults as ts.CompilerOptions & { + allowNonTsExtensions: boolean; + } + ).allowNonTsExtensions = true; + if (!rootUri) return defaults; + + try { + const base = rootUri.endsWith("/") ? rootUri : `${rootUri}/`; + const configUri = new URL("tsconfig.json", base).href; + const text = await requestFile(configUri); + const parsed = ts.parseConfigFileTextToJson(configUri, text); + if (parsed.error || !parsed.config?.compilerOptions) return defaults; + const converted = ts.convertCompilerOptionsFromJson( + parsed.config.compilerOptions, + base, + ); + return { ...defaults, ...converted.options, noEmit: true }; + } catch { + return defaults; + } +} + +function completions( + service: ts.LanguageService, + document: TextDocument, + offset: number, +) { + const result = service.getCompletionsAtPosition(document.uri, offset, { + allowIncompleteCompletions: true, + allowRenameOfImportPath: true, + includeCompletionsForImportStatements: true, + includeCompletionsForModuleExports: true, + includeCompletionsWithInsertText: true, + includeAutomaticOptionalChainCompletions: true, + includeCompletionsWithClassMemberSnippets: true, + includeCompletionsWithObjectLiteralMethodSnippets: true, + includeCompletionsWithSnippetText: true, + importModuleSpecifierPreference: "shortest", + }); + if (!result) return { isIncomplete: false, items: [] }; + return { + isIncomplete: !!result.isIncomplete, + items: result.entries + .filter((entry) => entry.name) + .map((entry): CompletionItem => { + const item: CompletionItem = { + label: entry.name, + kind: completionKind(entry.kind), + sortText: entry.sortText, + filterText: entry.filterText, + insertText: entry.insertText ?? entry.name, + commitCharacters: + entry.commitCharacters ?? result.defaultCommitCharacters, + data: { + uri: document.uri, + offset, + name: entry.name, + source: entry.source, + entryData: entry.data, + } satisfies CompletionData, + }; + if (entry.replacementSpan) { + item.textEdit = { + range: rangeFromSpan(document, entry.replacementSpan), + newText: entry.insertText ?? entry.name, + }; + } + return item; + }), + }; +} + +function resolveCompletion( + service: ts.LanguageService, + documents: Map, + item: CompletionItem, +): CompletionItem { + const data = item.data as CompletionData | undefined; + if (!data || !documents.has(data.uri)) return item; + const details = service.getCompletionEntryDetails( + data.uri, + data.offset, + data.name, + formatSettings({ tabSize: 4, insertSpaces: true }), + data.source, + { + allowRenameOfImportPath: true, + importModuleSpecifierPreference: "shortest", + }, + data.entryData, + ); + if (!details) return item; + + item.detail = ts.displayPartsToString(details.displayParts); + const documentation = ts.displayPartsToString(details.documentation); + const tags = details.tags?.map(jsDocTag).join("\n\n") ?? ""; + item.documentation = markdownContent( + [documentation, tags].filter(Boolean).join("\n\n"), + ); + const edits: TextEdit[] = []; + for (const action of details.codeActions ?? []) { + for (const change of action.changes) { + const target = documents.get(change.fileName); + if (!target || change.fileName !== data.uri) continue; + for (const textChange of change.textChanges) { + edits.push({ + range: rangeFromSpan(target, textChange.span), + newText: textChange.newText, + }); + } + } + } + if (edits.length) item.additionalTextEdits = edits; + return item; +} + +function hover( + service: ts.LanguageService, + document: TextDocument, + offset: number, +) { + const info = service.getQuickInfoAtPosition(document.uri, offset); + if (!info) return null; + const signature = ts.displayPartsToString(info.displayParts); + const documentation = ts.displayPartsToString(info.documentation); + const tags = info.tags?.map(jsDocTag).join("\n\n") ?? ""; + return { + range: rangeFromSpan(document, info.textSpan), + contents: [ + { + language: isTypeScriptDocument(document) + ? "typescript" + : "javascript", + value: signature, + }, + [documentation, tags].filter(Boolean).join("\n\n"), + ], + }; +} + +function signatureHelp( + service: ts.LanguageService, + document: TextDocument, + offset: number, + context: RequestParams["context"], +): SignatureHelp | null { + const items = service.getSignatureHelpItems(document.uri, offset, { + triggerReason: signatureTrigger(context), + }); + if (!items) return null; + return { + activeSignature: items.selectedItemIndex, + activeParameter: items.argumentIndex, + signatures: items.items.map((item) => ({ + label: + ts.displayPartsToString(item.prefixDisplayParts) + + item.parameters + .map((parameter) => + ts.displayPartsToString(parameter.displayParts), + ) + .join(ts.displayPartsToString(item.separatorDisplayParts)) + + ts.displayPartsToString(item.suffixDisplayParts), + documentation: markdownContent( + ts.displayPartsToString(item.documentation), + ), + parameters: item.parameters.map((parameter) => ({ + label: ts.displayPartsToString(parameter.displayParts), + documentation: markdownContent( + ts.displayPartsToString(parameter.documentation), + ), + })), + })), + }; +} + +function markdownContent(value: string): MarkupContent | undefined { + return value ? { kind: "markdown", value } : undefined; +} + +function format( + service: ts.LanguageService, + document: TextDocument, + range: Range | undefined, + options: FormattingOptions, +): TextEdit[] { + const settings = formatSettings(options); + const edits = range + ? service.getFormattingEditsForRange( + document.uri, + document.offsetAt(range.start), + document.offsetAt(range.end), + settings, + ) + : service.getFormattingEditsForDocument(document.uri, settings); + return edits.map((edit) => ({ + range: rangeFromSpan(document, edit.span), + newText: edit.newText, + })); +} + +function documentSymbols( + service: ts.LanguageService, + document: TextDocument, +): DocumentSymbol[] { + const convert = (item: ts.NavigationTree): DocumentSymbol => ({ + name: item.text, + kind: symbolKind(item.kind), + range: rangeFromSpan(document, item.spans[0]), + selectionRange: rangeFromSpan(document, item.spans[0]), + children: item.childItems?.map(convert), + }); + return service.getNavigationTree(document.uri).childItems?.map(convert) ?? []; +} + +function definitions( + service: ts.LanguageService, + documents: Map, + document: TextDocument, + offset: number, +): LocationLink[] { + const result = service.getDefinitionAndBoundSpan(document.uri, offset); + if (!result?.definitions) return []; + const links: LocationLink[] = []; + for (const definition of result.definitions) { + const target = documents.get(definition.fileName); + if (!target) continue; + const selection = rangeFromSpan(target, definition.textSpan); + links.push({ + targetUri: definition.fileName, + targetRange: definition.contextSpan + ? rangeFromSpan(target, definition.contextSpan) + : selection, + targetSelectionRange: selection, + originSelectionRange: rangeFromSpan(document, result.textSpan), + }); + } + return links; +} + +function references( + service: ts.LanguageService, + documents: Map, + document: TextDocument, + offset: number, +): Location[] { + const result: Location[] = []; + for (const reference of + service.getReferencesAtPosition(document.uri, offset) ?? []) { + const target = documents.get(reference.fileName); + if (!target) continue; + result.push({ + uri: reference.fileName, + range: rangeFromSpan(target, reference.textSpan), + }); + } + return result; +} + +function rename( + service: ts.LanguageService, + documents: Map, + document: TextDocument, + offset: number, + newName: string, +): WorkspaceEdit | null { + const locations = service.findRenameLocations( + document.uri, + offset, + false, + false, + { providePrefixAndSuffixTextForRename: false }, + ); + if (!locations) return null; + const changes: Record = {}; + for (const location of locations) { + const target = documents.get(location.fileName); + if (!target) continue; + (changes[location.fileName] ??= []).push({ + range: rangeFromSpan(target, location.textSpan), + newText: newName, + }); + } + return { changes }; +} + +function documentHighlights( + service: ts.LanguageService, + document: TextDocument, + offset: number, +): DocumentHighlight[] { + const result: DocumentHighlight[] = []; + for (const item of + service.getDocumentHighlights(document.uri, offset, [document.uri]) ?? + []) { + for (const highlight of item.highlightSpans) { + result.push({ + range: rangeFromSpan(document, highlight.textSpan), + kind: + highlight.kind === "writtenReference" + ? DocumentHighlightKind.Write + : DocumentHighlightKind.Text, + }); + } + } + return result; +} + +function codeActions( + service: ts.LanguageService, + documents: Map, + document: TextDocument, + range: Range, + context: CodeActionContext, + options: FormattingOptions, +): CodeAction[] { + const fixes = service.getCodeFixesAtPosition( + document.uri, + document.offsetAt(range.start), + document.offsetAt(range.end), + context.diagnostics + .map((diagnostic) => Number(diagnostic.code)) + .filter(Number.isFinite), + formatSettings(options), + {}, + ); + return fixes.map((fix) => { + const changes: Record = {}; + for (const change of fix.changes) { + const target = documents.get(change.fileName); + if (!target) continue; + changes[change.fileName] = change.textChanges.map((textChange) => ({ + range: rangeFromSpan(target, textChange.span), + newText: textChange.newText, + })); + } + return { + title: fix.description, + kind: "quickfix", + edit: Object.keys(changes).length ? { changes } : undefined, + }; + }); +} + +function foldingRanges( + service: ts.LanguageService, + document: TextDocument, +): FoldingRange[] { + const result: FoldingRange[] = []; + for (const item of service.getOutliningSpans(document.uri)) { + const range = rangeFromSpan(document, item.textSpan); + if (range.start.line >= range.end.line) continue; + result.push({ + startLine: range.start.line, + endLine: range.end.line, + kind: + item.kind === ts.OutliningSpanKind.Comment + ? FoldingRangeKind.Comment + : item.kind === ts.OutliningSpanKind.Region + ? FoldingRangeKind.Region + : undefined, + }); + } + return result; +} + +function selectionRanges( + service: ts.LanguageService, + document: TextDocument, + positions: Position[], +): LspSelectionRange[] { + const convert = (selection: ts.SelectionRange): LspSelectionRange => + SelectionRange.create( + rangeFromSpan(document, selection.textSpan), + selection.parent ? convert(selection.parent) : undefined, + ); + return positions.map((position) => + convert( + service.getSmartSelectionRange( + document.uri, + document.offsetAt(position), + ), + ), + ); +} + +function inlayHints( + service: ts.LanguageService, + document: TextDocument, + range: Range, +): InlayHint[] { + const start = document.offsetAt(range.start); + const end = document.offsetAt(range.end); + return service + .provideInlayHints( + document.uri, + { start, length: end - start }, + { + includeInlayParameterNameHints: "all", + includeInlayParameterNameHintsWhenArgumentMatchesName: true, + includeInlayFunctionParameterTypeHints: true, + includeInlayVariableTypeHints: true, + includeInlayPropertyDeclarationTypeHints: true, + includeInlayFunctionLikeReturnTypeHints: true, + includeInlayEnumMemberValueHints: true, + }, + ) + .map((hint) => ({ + position: document.positionAt(hint.position), + label: + hint.text || + hint.displayParts?.map((part) => part.text).join("") || + "", + kind: + hint.kind === ts.InlayHintKind.Parameter + ? InlayHintKind.Parameter + : InlayHintKind.Type, + paddingLeft: hint.whitespaceBefore, + paddingRight: hint.whitespaceAfter, + })); +} + +function toDiagnostic( + document: TextDocument, + diagnostic: ts.Diagnostic, +): Diagnostic { + const tags: DiagnosticTag[] = []; + if (diagnostic.reportsUnnecessary) tags.push(DiagnosticTag.Unnecessary); + if (diagnostic.reportsDeprecated) tags.push(DiagnosticTag.Deprecated); + return { + range: rangeFromSpan(document, diagnostic), + code: diagnostic.code, + severity: diagnosticSeverity(diagnostic.category), + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + source: diagnostic.source ?? "typescript", + tags, + }; +} + +function rangeFromSpan( + document: TextDocument, + span: { start?: number; length?: number }, +): Range { + const startOffset = span.start ?? 0; + return Range.create( + document.positionAt(startOffset), + document.positionAt(startOffset + (span.length ?? 0)), + ); +} + +function formatSettings(options: FormattingOptions): ts.FormatCodeSettings { + const tabSize = options.tabSize ?? 4; + const insertSpaces = options.insertSpaces ?? true; + return { + tabSize, + indentSize: tabSize, + convertTabsToSpaces: insertSpaces, + trimTrailingWhitespace: options.trimTrailingWhitespace, + insertSpaceAfterCommaDelimiter: insertSpaces, + insertSpaceAfterSemicolonInForStatements: insertSpaces, + insertSpaceBeforeAndAfterBinaryOperators: insertSpaces, + insertSpaceAfterKeywordsInControlFlowStatements: insertSpaces, + insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: insertSpaces, + }; +} + +function completionKind(kind: ts.ScriptElementKind): CompletionItemKind { + switch (kind) { + case ts.ScriptElementKind.primitiveType: + case ts.ScriptElementKind.keyword: + return CompletionItemKind.Keyword; + case ts.ScriptElementKind.constElement: + case ts.ScriptElementKind.letElement: + case ts.ScriptElementKind.variableElement: + case ts.ScriptElementKind.localVariableElement: + case ts.ScriptElementKind.alias: + case ts.ScriptElementKind.parameterElement: + return CompletionItemKind.Variable; + case ts.ScriptElementKind.memberVariableElement: + case ts.ScriptElementKind.memberGetAccessorElement: + case ts.ScriptElementKind.memberSetAccessorElement: + return CompletionItemKind.Field; + case ts.ScriptElementKind.functionElement: + case ts.ScriptElementKind.localFunctionElement: + return CompletionItemKind.Function; + case ts.ScriptElementKind.memberFunctionElement: + case ts.ScriptElementKind.constructSignatureElement: + case ts.ScriptElementKind.callSignatureElement: + case ts.ScriptElementKind.indexSignatureElement: + return CompletionItemKind.Method; + case ts.ScriptElementKind.enumElement: + return CompletionItemKind.Enum; + case ts.ScriptElementKind.enumMemberElement: + return CompletionItemKind.EnumMember; + case ts.ScriptElementKind.moduleElement: + case ts.ScriptElementKind.externalModuleName: + return CompletionItemKind.Module; + case ts.ScriptElementKind.classElement: + case ts.ScriptElementKind.typeElement: + return CompletionItemKind.Class; + case ts.ScriptElementKind.interfaceElement: + return CompletionItemKind.Interface; + case ts.ScriptElementKind.scriptElement: + return CompletionItemKind.File; + case ts.ScriptElementKind.directory: + return CompletionItemKind.Folder; + default: + return CompletionItemKind.Property; + } +} + +function symbolKind(kind: ts.ScriptElementKind): SymbolKind { + switch (kind) { + case ts.ScriptElementKind.memberVariableElement: + return SymbolKind.Field; + case ts.ScriptElementKind.functionElement: + case ts.ScriptElementKind.localFunctionElement: + return SymbolKind.Function; + case ts.ScriptElementKind.memberFunctionElement: + return SymbolKind.Method; + case ts.ScriptElementKind.enumElement: + return SymbolKind.Enum; + case ts.ScriptElementKind.enumMemberElement: + return SymbolKind.EnumMember; + case ts.ScriptElementKind.moduleElement: + case ts.ScriptElementKind.externalModuleName: + return SymbolKind.Module; + case ts.ScriptElementKind.classElement: + case ts.ScriptElementKind.typeElement: + return SymbolKind.Class; + case ts.ScriptElementKind.interfaceElement: + return SymbolKind.Interface; + case ts.ScriptElementKind.scriptElement: + return SymbolKind.File; + default: + return SymbolKind.Variable; + } +} + +function diagnosticSeverity( + category: ts.DiagnosticCategory, +): DiagnosticSeverity { + switch (category) { + case ts.DiagnosticCategory.Error: + return DiagnosticSeverity.Error; + case ts.DiagnosticCategory.Warning: + return DiagnosticSeverity.Warning; + case ts.DiagnosticCategory.Suggestion: + return DiagnosticSeverity.Hint; + default: + return DiagnosticSeverity.Information; + } +} + +function signatureTrigger( + context: RequestParams["context"], +): ts.SignatureHelpTriggerReason { + if (context?.triggerKind === 2 && context.triggerCharacter) { + return context.isRetrigger + ? { + kind: "retrigger", + triggerCharacter: + context.triggerCharacter as ts.SignatureHelpTriggerCharacter, + } + : { + kind: "characterTyped", + triggerCharacter: + context.triggerCharacter as ts.SignatureHelpTriggerCharacter, + }; + } + return context?.isRetrigger ? { kind: "retrigger" } : { kind: "invoked" }; +} + +function jsDocTag(tag: ts.JSDocTagInfo): string { + const text = Array.isArray(tag.text) + ? tag.text.map((part) => part.text).join("") + : (tag.text ?? ""); + return `*@${tag.name}*${text ? ` — ${text}` : ""}`; +} + +function isTypeScriptDocument(document: TextDocument): boolean { + return ( + document.languageId.startsWith("typescript") || + document.languageId === "tsx" + ); +} + +function uriPath(uri: string): string { + try { + return new URL(uri).pathname; + } catch { + return uri; + } +} diff --git a/src/cm/lsp/workers/typescriptLibs.ts b/src/cm/lsp/workers/typescriptLibs.ts new file mode 100644 index 000000000..616e5dbab --- /dev/null +++ b/src/cm/lsp/workers/typescriptLibs.ts @@ -0,0 +1,29 @@ +interface RspackContext { + (key: string): string | { default: string }; + keys(): string[]; +} + +interface RspackRequire { + context( + directory: string, + useSubdirectories: boolean, + filter: RegExp, + ): RspackContext; +} + +declare const require: RspackRequire; + +const context = require.context( + "typescript/lib", + false, + /^\.\/lib\..*\.d\.ts$/, +); + +const libraries: Record = {}; +for (const key of context.keys()) { + const value = context(key); + libraries[key.slice(2)] = + typeof value === "string" ? value : value.default; +} + +export default libraries; diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index 5341cc8ff..300dce6fb 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -3291,7 +3291,9 @@ async function EditorManager($header, $body) { lspClientManager.setOptions({ resolveRoot: resolveRootUriForContext, onClientIdle: ({ server }) => { - if (server?.id) stopManagedServer(server.id); + if (!server?.id) return; + stopManagedServer(server.id); + void lspClientManager.disposeServer(server.id); }, displayFile: async (targetUri) => { if (!targetUri) return null; diff --git a/src/settings/lspServerDetail.js b/src/settings/lspServerDetail.js index 98c05e3a2..5e8179185 100644 --- a/src/settings/lspServerDetail.js +++ b/src/settings/lspServerDetail.js @@ -1,4 +1,5 @@ import lspApi from "cm/lsp/api"; +import lspClientManager from "cm/lsp/clientManager"; import { checkRuntimeServerInstallation, getRuntimeInstallCommand, @@ -579,6 +580,7 @@ export default function lspServerDetail(serverId) { case "enabled": await persistEnabled(serverId, value); if (!value) { + await lspClientManager.disposeServer(serverId); stopManagedServer(serverId); } toast( @@ -601,6 +603,7 @@ export default function lspServerDetail(serverId) { break; } $loader.show(); + await lspClientManager.disposeServer(serverId); stopManagedServer(serverId); await removeCustomServer(serverId); toast(strings["lsp-custom-server-removed"]);