import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import * as fs from "node:fs"; import { createMessageConnection, StreamMessageReader, StreamMessageWriter, type MessageConnection, } from "vscode-jsonrpc/node.js"; import type { InitializeParams, PublishDiagnosticsParams, } from "vscode-languageserver-protocol"; import type { ServerConfig } from "./types.ts"; import { ServerNotFoundError } from "./types.ts"; import { findRoot, pathToUri, uriToPath } from "./root.ts"; import { isOnPath } from "./util.ts"; // LspClient - Thin wrapper that spawns a language server, performs the // initialize handshake, auto-opens a file, and exposes sendRequest so the // command dispatcher can forward raw LSP method calls. // // Method Names As Strings - We use plain method strings rather than the // typed ProtocolRequestType constants to sidestep a version skew between // vscode-jsonrpc and the copy re-exported by vscode-languageserver-protocol. // // Lifetime - One client per CLI invocation. On dispose() we do a best-effort // shutdown/exit. A future daemon will reuse a client per (server.id, rootUri). export class LspClient { private proc!: ChildProcessWithoutNullStreams; private conn!: MessageConnection; private diagnostics = new Map(); private diagListeners = new Set<(p: PublishDiagnosticsParams) => void>(); // Progress Tracking - Servers like gopls aren't ready to serve requests // until they finish their initial workspace load. They announce this via // `$/progress` end notifications. We track outstanding tokens so callers // can await readiness. private progressTokens = new Set(); private progressListeners = new Set<() => void>(); // Per-URI Version Counter - LSP requires monotonically increasing // version numbers in didOpen/didChange. We track them so the daemon // can resync files via notifyChange after on-disk edits. private versions = new Map(); constructor(private readonly server: ServerConfig) {} // Start - Spawns the server process and wires up JSON-RPC. async start(rootDir: string, env: NodeJS.ProcessEnv): Promise { // Verify Binary On PATH - Fail fast with a clear message instead of // letting spawn ENOENT surface as a generic error. Resolution uses the // caller/session env, not the daemon's launch-time env. if (!isOnPath(this.server.command, env)) { throw new ServerNotFoundError(this.server.command); } this.proc = spawn(this.server.command, this.server.args, { stdio: ["pipe", "pipe", "pipe"], cwd: rootDir, env, }); this.proc.on("error", (err) => { process.stderr.write( `[lsp:${this.server.id}] spawn error: ${err.message}\n`, ); }); // Drain Server Stderr - Many servers log verbosely; only forward when // LSP_DEBUG is set so normal output stays clean. this.proc.stderr.on("data", (chunk) => { if (process.env.LSP_DEBUG) process.stderr.write(chunk); }); this.conn = createMessageConnection( new StreamMessageReader(this.proc.stdout), new StreamMessageWriter(this.proc.stdin), ); // Capture Diagnostics - Stored by URI, also fanned out to listeners so // the diagnostics command can await the first publish. this.conn.onNotification( "textDocument/publishDiagnostics", (p: PublishDiagnosticsParams) => { this.diagnostics.set(p.uri, p); for (const l of this.diagListeners) l(p); }, ); // Track Progress Tokens - Count begin/end so we know when the server // has finished all startup work. We accept every createWorkDoneProgress // request so servers (gopls) will actually send progress. this.conn.onRequest( "window/workDoneProgress/create", (p: { token: string | number }) => { this.progressTokens.add(p.token); return null; }, ); this.conn.onNotification( "$/progress", (p: { token: string | number; value: { kind: string } }) => { if (p.value?.kind === "begin") this.progressTokens.add(p.token); else if (p.value?.kind === "end") { this.progressTokens.delete(p.token); for (const l of this.progressListeners) l(); } }, ); // Accept Common Server Requests - Return empty/null so servers don't // stall. Good enough for a CLI; a real client would answer properly. this.conn.onRequest("workspace/configuration", () => []); this.conn.onRequest("client/registerCapability", () => null); this.conn.onRequest("client/unregisterCapability", () => null); this.conn.listen(); const rootUri = pathToUri(rootDir); const params: InitializeParams = { processId: process.pid, rootUri, workspaceFolders: [{ uri: rootUri, name: rootDir }], capabilities: { textDocument: { hover: { contentFormat: ["markdown", "plaintext"] }, definition: { linkSupport: false }, references: {}, completion: { completionItem: { snippetSupport: false } }, documentSymbol: { hierarchicalDocumentSymbolSupport: true }, publishDiagnostics: {}, synchronization: { didSave: true }, }, workspace: { workspaceFolders: true, configuration: true }, }, }; await this.conn.sendRequest("initialize", { ...params, capabilities: { ...params.capabilities, window: { workDoneProgress: true }, }, }); this.conn.sendNotification("initialized", {}); } // Wait For Ready - Resolves when there are no outstanding progress // tokens, or after `timeoutMs`. For servers that never send progress, // this effectively just waits for `graceMs` of quiet. async waitForReady(timeoutMs = 15000, graceMs = 300): Promise { const deadline = Date.now() + timeoutMs; // Initial Grace - Give the server a moment to announce begin tokens. await new Promise((r) => setTimeout(r, graceMs)); while (this.progressTokens.size > 0 && Date.now() < deadline) { await new Promise((resolve) => { const onDone = () => { clearTimeout(timer); this.progressListeners.delete(onDone); resolve(); }; const timer = setTimeout(onDone, Math.min(500, deadline - Date.now())); this.progressListeners.add(onDone); }); } } // Open Document - Reads the file from disk and sends didOpen. Most // servers require this before they'll answer hover/definition/etc. // Idempotent-ish: callers should track whether they've already opened // a URI and prefer notifyChange for subsequent syncs. openDocument(filePath: string): string { const uri = pathToUri(filePath); const text = fs.readFileSync(filePath, "utf8"); this.versions.set(uri, 1); this.conn.sendNotification("textDocument/didOpen", { textDocument: { uri, languageId: this.server.languageId ?? this.server.match[0], version: 1, text, }, }); return uri; } // Notify Change - Re-reads the file from disk and sends a full-text // didChange. Used by the daemon to keep the server in sync after the // agent's edit/write tools modify a file. notifyChange(filePath: string): string { const uri = pathToUri(filePath); const text = fs.readFileSync(filePath, "utf8"); const version = (this.versions.get(uri) ?? 1) + 1; this.versions.set(uri, version); this.conn.sendNotification("textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text }], }); return uri; } // Send Raw LSP Request - Passthrough used by the command dispatcher. sendRequest(method: string, params: unknown): Promise { return this.conn.sendRequest(method, params) as Promise; } // Clear Diagnostics - Drops the cached diagnostics for a URI so callers // can force waitForDiagnostics to await a fresh publish after didChange. clearDiagnostics(uri: string): void { this.diagnostics.delete(uri); } // Wait For Diagnostics - Resolves on the first publish for `uri` or // after `timeoutMs`. Returns whatever we have for that URI. async waitForDiagnostics( uri: string, timeoutMs: number, ): Promise { if (this.diagnostics.has(uri)) return this.diagnostics.get(uri)!; return new Promise((resolve) => { const timer = setTimeout(() => { this.diagListeners.delete(listener); resolve(this.diagnostics.get(uri) ?? { uri, diagnostics: [] }); }, timeoutMs); const listener = (p: PublishDiagnosticsParams) => { if (p.uri !== uri) return; clearTimeout(timer); this.diagListeners.delete(listener); resolve(p); }; this.diagListeners.add(listener); }); } // Dispose - Best-effort shutdown; kills the process if it doesn't exit. async dispose(options: { graceful?: boolean } = {}): Promise { const graceful = options.graceful ?? true; if (this.conn) { if (graceful) { try { await this.conn.sendRequest("shutdown", undefined); this.conn.sendNotification("exit"); } catch { // Ignore - we're tearing down anyway. } } try { this.conn.dispose(); } catch { // Ignore - connection may already be closed. } } if (this.proc && !this.proc.killed) { this.proc.kill(); } } } // Convenience - Build a client, find root, start, and open the file. export async function startClientForFile( server: ServerConfig, filePath: string, env: NodeJS.ProcessEnv = process.env, ): Promise<{ client: LspClient; uri: string; rootDir: string }> { const rootDir = findRoot(filePath, server.rootMarkers); const client = new LspClient(server); await client.start(rootDir, env); const uri = client.openDocument(filePath); // Wait For Workspace Load - gopls & friends reject requests with errors // like "no views" until their initial load completes. await client.waitForReady(); return { client, uri, rootDir }; } export { uriToPath };