// Minimal LSP diagnostics client: spawns (and reuses) a language server per (root, serverId), // opens a file, and returns the diagnostics the server publishes. ponytail: diagnostics only — // no hover/defs/symbols/format. Used only in trusted projects, and only if the server binary is // available. Servers are kept warm and killed on process exit. import { type ChildProcess, spawnSync, spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { dirname, extname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { isTrusted } from "./settings.ts"; export interface ServerDef { id: string; exts: string[]; cmd: string[]; // [binary, ...args] markers?: string[]; // files that mark the project root languageId: (ext: string) => string; } const SERVERS: ServerDef[] = [ { id: "typescript", exts: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], cmd: ["typescript-language-server", "--stdio"], markers: ["tsconfig.json", "jsconfig.json", "package.json"], languageId: (e) => (e.includes("ts") ? "typescript" : "javascript") }, { id: "pyright", exts: [".py", ".pyi"], cmd: ["pyright-langserver", "--stdio"], markers: ["pyproject.toml", "setup.py", "requirements.txt"], languageId: () => "python" }, { id: "gopls", exts: [".go"], cmd: ["gopls"], markers: ["go.mod"], languageId: () => "go" }, { id: "rust", exts: [".rs"], cmd: ["rust-analyzer"], markers: ["Cargo.toml"], languageId: () => "rust" }, ]; /** Register an extra language server (used by tests, or for unsupported languages). */ export function registerServer(def: ServerDef): void { SERVERS.unshift(def); } const binCache = new Map(); function hasBin(bin: string): boolean { if (bin === "node" || bin.includes("/") || bin.includes("\\")) return true; // absolute/explicit const cached = binCache.get(bin); if (cached !== undefined) return cached; const probe = spawnSync(process.platform === "win32" ? "where" : "which", [bin], { encoding: "utf8" }); const ok = probe.status === 0 && !!(probe.stdout ?? "").trim(); binCache.set(bin, ok); return ok; } interface Diag { range: { start: { line: number; character: number } }; severity?: number; message: string; source?: string; } interface Session { proc: ChildProcess; send: (msg: object) => void; nextId: number; pending: Map void>; diagnostics: Map; waiters: Map void>; opened: Map; // uri -> version ready: Promise; } const sessions = new Map(); let cleanupHooked = false; function projectRoot(fileDir: string, markers: string[] | undefined): string { if (!markers?.length) return process.cwd(); let dir = fileDir; for (let i = 0; i < 40; i++) { if (markers.some((m) => existsSync(resolve(dir, m)))) return dir; const up = dirname(dir); if (up === dir) break; dir = up; } return process.cwd(); } function startSession(root: string, def: ServerDef): Session { const proc = spawn(def.cmd[0]!, def.cmd.slice(1), { cwd: root, stdio: ["pipe", "pipe", "ignore"] }); const send = (msg: object): void => { const json = JSON.stringify(msg); proc.stdin?.write(`Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`); }; const s: Session = { proc, send, nextId: 1, pending: new Map(), diagnostics: new Map(), waiters: new Map(), opened: new Map(), ready: Promise.resolve() }; let buf = Buffer.alloc(0); proc.stdout?.on("data", (d: Buffer) => { buf = Buffer.concat([buf, d]); for (;;) { const headerEnd = buf.indexOf("\r\n\r\n"); if (headerEnd < 0) break; const m = buf.slice(0, headerEnd).toString("ascii").match(/content-length:\s*(\d+)/i); const start = headerEnd + 4; if (!m) { buf = buf.slice(start); continue; } const len = Number(m[1]); if (buf.length < start + len) break; const body = buf.slice(start, start + len).toString("utf8"); buf = buf.slice(start + len); let msg: { id?: number; method?: string; result?: unknown; params?: { uri?: string; diagnostics?: Diag[] } }; try { msg = JSON.parse(body); } catch { continue; } if (msg.id != null && s.pending.has(msg.id)) { const r = s.pending.get(msg.id)!; s.pending.delete(msg.id); r(msg.result); } else if (msg.method === "textDocument/publishDiagnostics" && msg.params?.uri) { s.diagnostics.set(msg.params.uri, msg.params.diagnostics ?? []); s.waiters.get(msg.params.uri)?.(); } } }); proc.on("exit", () => sessions.delete(`${root}${def.id}`)); const call = (method: string, params: object): Promise => new Promise((res) => { const id = s.nextId++; s.pending.set(id, res); send({ jsonrpc: "2.0", id, method, params }); }); s.ready = (async () => { await call("initialize", { processId: process.pid, rootUri: pathToFileURL(root).href, workspaceFolders: [{ uri: pathToFileURL(root).href, name: "root" }], capabilities: { textDocument: { publishDiagnostics: {}, synchronization: { dynamicRegistration: false } } }, }); send({ jsonrpc: "2.0", method: "initialized", params: {} }); })(); if (!cleanupHooked) { cleanupHooked = true; process.on("exit", () => { for (const sess of sessions.values()) sess.proc.kill(); }); } return s; } const SEV = ["", "error", "warning", "info", "hint"]; /** Diagnostics (formatted `path:line:col [sev] message`) for a file, or [] if no server / untrusted. */ export async function getDiagnostics(abs: string, waitMs = 4000): Promise { if (!isTrusted(process.cwd()) || !existsSync(abs)) return []; const ext = extname(abs).toLowerCase(); const def = SERVERS.find((d) => d.exts.includes(ext) && hasBin(d.cmd[0]!)); if (!def) return []; const root = projectRoot(dirname(abs), def.markers); const key = `${root}${def.id}`; let s = sessions.get(key); if (!s) { s = startSession(root, def); sessions.set(key, s); } await s.ready; const uri = pathToFileURL(abs).href; let text: string; try { text = readFileSync(abs, "utf8"); } catch { return []; } s.diagnostics.delete(uri); const got = new Promise((res) => s!.waiters.set(uri, res)); const version = (s.opened.get(uri) ?? 0) + 1; s.opened.set(uri, version); if (version === 1) s.send({ jsonrpc: "2.0", method: "textDocument/didOpen", params: { textDocument: { uri, languageId: def.languageId(ext), version, text } } }); else s.send({ jsonrpc: "2.0", method: "textDocument/didChange", params: { textDocument: { uri, version }, contentChanges: [{ text }] } }); await Promise.race([got, new Promise((res) => setTimeout(res, waitMs))]); s.waiters.delete(uri); const diags = s.diagnostics.get(uri) ?? []; return diags.map((d) => `${abs}:${d.range.start.line + 1}:${d.range.start.character + 1} [${SEV[d.severity ?? 1]}] ${d.message}${d.source ? ` (${d.source})` : ""}`); }