/** * Minimal LSP client: spawn a server, sync file contents, collect diagnostics. * * Deliberately small — this exists to answer one question ("what does the * language server say about this file right now?"), not to expose LSP. * * Design points that address the fragility of the package this replaces: * - No dependencies, so no transitive protocol-version conflicts. * - Content is pushed with didOpen/didChange before every diagnostics read, so * the answer reflects the file on disk even if the edit was the session's * first action. * - `waitForDiagnostics` awaits the next publish for that document instead of * sleeping a fixed interval, with a timeout and a last-known fallback. * - Child handles are unref'd and shut down explicitly, so a one-shot `pi -p` * run still exits. */ import { type ChildProcess, spawn } from "node:child_process"; import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import type { LspDiagnostic } from "./format.ts"; import { createReaderState, encodeMessage, type JsonRpcMessage, readMessages } from "./protocol.ts"; const INITIALIZE_TIMEOUT_MS = 15_000; const DIAGNOSTICS_TIMEOUT_MS = 6_000; /** What a client actually needs to spawn — the built-in table's ServerConfig satisfies it. */ export interface SpawnConfig { command: string; args: string[]; } /** Per-server extras a plugin config can carry (built-in servers pass none). */ export interface LspClientOptions { /** Merged over process.env at spawn. */ env?: Record; /** Sent in the `initialize` request. */ initializationOptions?: unknown; /** Answers the server's workspace/configuration requests. */ settings?: unknown; startupTimeoutMs?: number; } export function pathToUri(path: string): string { return pathToFileURL(path).toString(); } /** * The reply to a server's workspace/configuration request: one settings object * per requested item (LSP 3.6 shape), `{}` when none are configured. */ export function configurationResponse(settings: unknown, params: unknown): unknown[] { const items = (params as { items?: unknown[] } | undefined)?.items; const count = Array.isArray(items) && items.length > 0 ? items.length : 1; return Array.from({ length: count }, () => settings ?? {}); } function unrefStream(stream: unknown): void { (stream as { unref?: () => void } | null | undefined)?.unref?.(); } interface OpenDocument { version: number; text: string; } export class LspClient { private child?: ChildProcess; private reader = createReaderState(); private nextId = 1; private pending = new Map void; reject: (error: Error) => void }>(); private diagnostics = new Map(); private waiters = new Map void>>(); private open = new Map(); private ready = false; private failure?: string; private publishes = 0; constructor( private readonly config: SpawnConfig, private readonly root: string, private readonly options: LspClientOptions = {}, ) {} get isRunning(): boolean { return this.ready && !!this.child && this.child.exitCode === null; } get error(): string | undefined { return this.failure; } get diagnosticsCount(): number { let total = 0; for (const list of this.diagnostics.values()) total += list.length; return total; } async start(): Promise { if (this.child) return; const child = spawn(this.config.command, this.config.args, { cwd: this.root, stdio: ["pipe", "pipe", "pipe"], env: this.options.env ? { ...process.env, ...this.options.env } : process.env, }); this.child = child; // Don't hold the event loop open once the agent is done. The stdio pipes // are sockets at runtime, so they carry unref() even though the stream // types don't declare it. child.unref(); unrefStream(child.stdout); unrefStream(child.stderr); unrefStream(child.stdin); let stderr = ""; child.stderr?.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-2000); }); child.stdout?.on("data", (chunk: Buffer) => { for (const message of readMessages(this.reader, chunk)) this.handle(message); }); child.on("error", (error) => { this.failure = `Could not start ${this.config.command}: ${error.message}`; this.rejectAll(new Error(this.failure)); }); // A write to a dead server's stdin is an EPIPE on the stream; without a // listener that is an unhandled 'error' event, i.e. a crash (review T10). child.stdin?.on("error", (error) => { this.failure ??= `${this.config.command} stopped accepting input: ${error.message}`; this.ready = false; }); child.on("exit", (code, signal) => { this.ready = false; if (!this.failure && code !== 0) { const detail = stderr.trim().split("\n").slice(-3).join(" "); this.failure = `${this.config.command} exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`; } this.rejectAll(new Error(this.failure ?? "language server exited")); }); await this.request( "initialize", { processId: process.pid, rootUri: pathToUri(this.root), workspaceFolders: [{ uri: pathToUri(this.root), name: "workspace" }], initializationOptions: this.options.initializationOptions, capabilities: { textDocument: { synchronization: { dynamicRegistration: false, didSave: false }, publishDiagnostics: { relatedInformation: false }, }, workspace: { workspaceFolders: true, configuration: true }, }, }, this.options.startupTimeoutMs ?? INITIALIZE_TIMEOUT_MS, ); this.notify("initialized", {}); this.ready = true; } private handle(message: JsonRpcMessage): void { if (message.method === "textDocument/publishDiagnostics") { const params = message.params as { uri: string; diagnostics: LspDiagnostic[] }; this.publishes++; this.diagnostics.set(params.uri, params.diagnostics ?? []); const waiting = this.waiters.get(params.uri); if (waiting) { this.waiters.delete(params.uri); for (const resolve of waiting) resolve(); } return; } // Requests from the server: answer the ones that block startup. if (message.id !== undefined && message.method) { const result = message.method === "workspace/configuration" ? configurationResponse(this.options.settings, message.params) : null; this.send({ id: message.id, result }); return; } if (message.id !== undefined) { const entry = this.pending.get(Number(message.id)); if (!entry) return; this.pending.delete(Number(message.id)); if (message.error) entry.reject(new Error(message.error.message)); else entry.resolve(message.result); } } private send(payload: Record): void { this.child?.stdin?.write(encodeMessage(payload)); } private notify(method: string, params: unknown): void { this.send({ method, params }); } request(method: string, params: unknown, timeoutMs = DIAGNOSTICS_TIMEOUT_MS): Promise { const id = this.nextId++; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`${method} timed out after ${timeoutMs}ms`)); }, timeoutMs); timer.unref?.(); this.pending.set(id, { resolve: (value) => { clearTimeout(timer); resolve(value); }, reject: (error) => { clearTimeout(timer); reject(error); }, }); this.send({ id, method, params }); }); } private rejectAll(error: Error): void { for (const [, entry] of this.pending) entry.reject(error); this.pending.clear(); for (const [, waiting] of this.waiters) for (const resolve of waiting) resolve(); this.waiters.clear(); } /** * Push the file's current on-disk content and return its uri. `languageId` * is per-document: one plugin server process can map different extensions to * different language ids. */ syncFile(path: string, languageId: string): string { const uri = pathToUri(path); let text: string; try { text = readFileSync(path, "utf-8"); } catch { return uri; } const existing = this.open.get(uri); if (!existing) { this.open.set(uri, { version: 1, text }); this.notify("textDocument/didOpen", { textDocument: { uri, languageId, version: 1, text }, }); } else if (existing.text !== text) { const version = existing.version + 1; this.open.set(uri, { version, text }); this.notify("textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text }], }); } return uri; } /** Wait for the next publish for `uri`; resolves early if one already arrived. */ private waitForPublish(uri: string, timeoutMs: number): Promise { return new Promise((resolve) => { const timer = setTimeout(() => resolve(), timeoutMs); timer.unref?.(); const list = this.waiters.get(uri) ?? []; list.push(() => { clearTimeout(timer); resolve(); }); this.waiters.set(uri, list); }); } /** * Syncs the file, waits for the server's next diagnostics publication, and * returns what it reported. Servers publish asynchronously and some republish * nothing when a file is unchanged, hence the timeout plus last-known * fallback. */ async getDiagnostics(path: string, languageId: string, timeoutMs = DIAGNOSTICS_TIMEOUT_MS): Promise { const uri = pathToUri(path); const hadContent = this.open.get(uri)?.text; const synced = this.syncFile(path, languageId); const changed = this.open.get(uri)?.text !== hadContent; if (changed || !this.diagnostics.has(synced)) { await this.waitForPublish(synced, timeoutMs); } return this.diagnostics.get(synced) ?? []; } /** Everything the server has published, per document uri (for the watcher). */ allDiagnostics(): Map { return new Map(this.diagnostics); } /** * Monotonic count of publishDiagnostics received — the watcher's cheap * "anything new since last look?" check, so the per-tool-round delta scan * only runs when a server actually republished. */ get publishCount(): number { return this.publishes; } async stop(): Promise { const child = this.child; if (!child) return; this.ready = false; try { // Send while this.child is still set — send() writes through it. this.send({ id: this.nextId++, method: "shutdown", params: null }); this.notify("exit", null); } catch { // Server may already be gone. } this.child = undefined; await new Promise((resolve) => { const timer = setTimeout(() => { child.kill("SIGKILL"); resolve(); }, 1000); timer.unref?.(); child.once("exit", () => { clearTimeout(timer); resolve(); }); }); } }