From 9f56d7ae8e339b0a2dcb95629a4e0e2f450b0232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 18 Sep 2026 07:16:25 +0000 Subject: [PATCH] :recycle: Back LSP helper with protocol library Replace the hand-rolled JSON-RPC client (framing, pending map, reply dispatch) with createMessageConnection and typed requests from vscode-languageserver-protocol. A catch-all onRequest answers the server requests the old client replied to, and CompletionList/CompletionItem replace the ad-hoc shape guards. Public LspSession API is unchanged. --- src/util/__tests__/lsp-completion.ts | 286 +++++++++------------------ 1 file changed, 90 insertions(+), 196 deletions(-) diff --git a/src/util/__tests__/lsp-completion.ts b/src/util/__tests__/lsp-completion.ts index b54a703..8c0891c 100644 --- a/src/util/__tests__/lsp-completion.ts +++ b/src/util/__tests__/lsp-completion.ts @@ -4,6 +4,22 @@ import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { pathToFileURL } from "node:url"; +import { + type CompletionItem, + type CompletionList, + CompletionRequest, + ConfigurationRequest, + createMessageConnection, + DidOpenTextDocumentNotification, + ExitNotification, + InitializedNotification, + InitializeRequest, + type MessageConnection, + ShutdownRequest, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-languageserver-protocol/node"; + /** * Print the completion labels TypeScript's language server offers at a marker * inside a source file. @@ -48,71 +64,18 @@ export interface CompletionResult { readonly labels: readonly string[]; } -interface CompletionItem { - readonly label: string; -} - -interface RpcMessage { - readonly id?: string | number; - readonly method?: string; - readonly params?: unknown; - readonly result?: unknown; - readonly error?: unknown; -} - -interface Deferred { - readonly resolve: (message: RpcMessage) => void; - readonly reject: (error: Error) => void; -} - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - -const isRpcMessage = (value: unknown): value is RpcMessage => { - if (!isRecord(value)) { - return false; - } - const { id, method } = value; - return ( - (id === undefined || - typeof id === "number" || - typeof id === "string") && - (method === undefined || typeof method === "string") - ); -}; - -const isCompletionItem = (value: unknown): value is CompletionItem => - isRecord(value) && typeof value["label"] === "string"; - -const serverRequestResult = (message: RpcMessage): readonly null[] | null => { - if (message.method !== "workspace/configuration") { - return null; - } - const items = isRecord(message.params) - ? message.params["items"] - : undefined; - return Array.isArray(items) ? items.map(() => null) : []; -}; - -const completionLabels = (result: unknown): readonly string[] => { - if (!isRecord(result)) { - return []; - } - const { items } = result; - if (!Array.isArray(items)) { +const completionLabels = ( + result: CompletionItem[] | CompletionList | null, +): readonly string[] => { + if (result === null) { return []; } + const items = Array.isArray(result) ? result : result.items; return items - .filter(isCompletionItem) .map((item) => item.label) .toSorted((left, right) => left.localeCompare(right)); }; -const frame = (message: object): string => { - const body = JSON.stringify(message); - return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`; -}; - const markerPosition = (source: string, marker: string): Position => { const index = source.indexOf(marker); if (index === -1) { @@ -124,153 +87,81 @@ const markerPosition = (source: string, marker: string): Position => { return { line: lineIndex, character: line === undefined ? 0 : line.length }; }; -/** Minimal JSON-RPC client for `tsc --lsp --stdio`. */ -class LspClient { - readonly #child: ChildProcessWithoutNullStreams; - readonly #pending = new Map(); - #buffer = Buffer.alloc(0); - #nextId = 1; - - public constructor(repoRoot: string) { - this.#child = spawn( - path.join(repoRoot, "node_modules", ".bin", "tsc"), - ["--lsp", "--stdio"], - { cwd: repoRoot }, - ); - this.#child.stdout.on("data", (chunk: Buffer) => { - this.#consume(chunk); - }); - this.#child.stderr.on("data", (chunk: Buffer) => { - process.stderr.write(chunk); - }); - } - - /** Send a request and settle when the server answers it. */ - public request(method: string, params?: unknown): Promise { - const id = this.#nextId; - this.#nextId += 1; - const message = frame({ jsonrpc: "2.0", id, method, params }); - return new Promise((resolve, reject) => { - this.#pending.set(id, { resolve, reject }); - this.#child.stdin.write(message); - }); - } - - public notify(method: string, params: unknown): void { - this.#child.stdin.write(frame({ jsonrpc: "2.0", method, params })); - } - - public dispose(): void { - this.#child.stdin.end(); - this.#child.kill(); - } - - #consume(chunk: Buffer): void { - this.#buffer = Buffer.concat([this.#buffer, chunk]); - for (;;) { - const headerEnd = this.#buffer.indexOf("\r\n\r\n"); - if (headerEnd === -1) { - return; - } - const header = this.#buffer.subarray(0, headerEnd).toString(); - const match = /Content-Length: (\d+)/i.exec(header); - const lengthText = match === null ? undefined : match[1]; - if (lengthText === undefined) { - return; - } - const bodyEnd = headerEnd + 4 + Number(lengthText); - if (this.#buffer.length < bodyEnd) { - return; - } - const body = this.#buffer - .subarray(headerEnd + 4, bodyEnd) - .toString(); - this.#buffer = this.#buffer.subarray(bodyEnd); - const parsed: unknown = JSON.parse(body); - if (isRpcMessage(parsed)) { - this.#handle(parsed); - } - } - } - - #handle(message: RpcMessage): void { - // Server -> client request: answer so the server does not stall. - if (message.method !== undefined && message.id !== undefined) { - this.#child.stdin.write( - frame({ - jsonrpc: "2.0", - id: message.id, - result: serverRequestResult(message), - }), - ); - return; - } - // Response to one of our requests. - const { id } = message; - if (id === undefined) { - return; - } - const deferred = this.#pending.get(id); - if (deferred === undefined) { - return; - } - this.#pending.delete(id); - if (message.error === undefined) { - deferred.resolve(message); - } else { - deferred.reject(new Error(JSON.stringify(message.error))); - } - } -} - /** * One language server, initialized on first use, shared across probes. Callers * own the lifecycle and must `close()` it. */ export class LspSession { - readonly #client: LspClient; + readonly #child: ChildProcessWithoutNullStreams; + readonly #connection: MessageConnection; readonly #repoRoot: string; #ready: Promise | undefined; public constructor(repoRoot: string) { this.#repoRoot = repoRoot; - this.#client = new LspClient(repoRoot); + this.#child = spawn( + path.join(repoRoot, "node_modules", ".bin", "tsc"), + ["--lsp", "--stdio"], + { cwd: repoRoot }, + ); + this.#child.stderr.on("data", (chunk: Buffer) => { + process.stderr.write(chunk); + }); + this.#connection = createMessageConnection( + new StreamMessageReader(this.#child.stdout), + new StreamMessageWriter(this.#child.stdin), + ); + // Server -> client requests the server waits on: answer so it proceeds. + // `workspace/configuration` wants one reply per requested item; a + // catch-all covers the rest (e.g. `client/registerCapability`). + this.#connection.onRequest(ConfigurationRequest.type, ({ items }) => + items.map(() => null), + ); + this.#connection.onRequest(() => null); + this.#connection.listen(); } public completionLabelsAt( target: CompletionTarget, ): Promise { - return this.#ensureInitialized().then(() => { - const { uri, position } = this.#open(target); - return this.#client - .request("textDocument/completion", { - textDocument: { uri }, - position, - context: { triggerKind: 1 }, - }) - .then((response) => ({ - position, - labels: completionLabels(response.result), - })); - }); + return this.#ensureInitialized() + .then(() => this.#open(target)) + .then(({ uri, position }) => + this.#connection + .sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position, + context: { triggerKind: 1 }, + }) + .then((result) => ({ + position, + labels: completionLabels(result), + })), + ); } /** `shutdown` + `exit`, then kill the server; safe after a failed probe. */ public close(): Promise { - return this.#client - .request("shutdown") - .then(() => { - this.#client.notify("exit", null); - return delay(EXIT_DELAY_MS); - }) + return this.#connection + .sendRequest(ShutdownRequest.type) + .then(() => + this.#connection.sendNotification(ExitNotification.type), + ) + .then(() => delay(EXIT_DELAY_MS)) .finally(() => { - this.#client.dispose(); + this.#connection.dispose(); + this.#child.kill(); }); } #ensureInitialized(): Promise { - this.#ready ??= this.#client - .request("initialize", { + this.#ready ??= this.#initialize(); + return this.#ready; + } + + #initialize(): Promise { + return this.#connection + .sendRequest(InitializeRequest.type, { processId: process.pid, rootUri: pathToFileURL(this.#repoRoot).href, workspaceFolders: [ @@ -285,16 +176,18 @@ export class LspSession { }, }, }) - .then(() => { - this.#client.notify("initialized", {}); - }); - return this.#ready; + .then(() => + this.#connection.sendNotification( + InitializedNotification.type, + {}, + ), + ); } - #open(target: CompletionTarget): { + #open(target: CompletionTarget): Promise<{ readonly uri: string; readonly position: Position; - } { + }> { const absolute = path.resolve(this.#repoRoot, target.file); const marker = target.marker ?? DEFAULT_MARKER; const position = markerPosition(target.source, marker); @@ -302,15 +195,16 @@ export class LspSession { const uri = pathToFileURL(absolute).href; // The server handles `didOpen` in order before the completion request, // so no settle delay is needed. - this.#client.notify("textDocument/didOpen", { - textDocument: { - uri, - languageId: "typescript", - version: 1, - text, - }, - }); - return { uri, position }; + return this.#connection + .sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri, + languageId: "typescript", + version: 1, + text, + }, + }) + .then(() => ({ uri, position })); } }