/** * LSP Core - Language Server Protocol client management */ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import * as path from "node:path"; import * as fs from "node:fs"; import { pathToFileURL, fileURLToPath } from "node:url"; import { createMessageConnection, StreamMessageReader, StreamMessageWriter, type MessageConnection, InitializeRequest, InitializedNotification, DidOpenTextDocumentNotification, DidChangeTextDocumentNotification, DidCloseTextDocumentNotification, DidSaveTextDocumentNotification, PublishDiagnosticsNotification, DocumentDiagnosticRequest, WorkspaceDiagnosticRequest, DefinitionRequest, ReferencesRequest, HoverRequest, SignatureHelpRequest, DocumentSymbolRequest, RenameRequest, CodeActionRequest, } from "vscode-languageserver-protocol/node.js"; import { type Diagnostic, type Location, type LocationLink, type DocumentSymbol, type SymbolInformation, type Hover, type SignatureHelp, type WorkspaceEdit, type CodeAction, type Command, DiagnosticSeverity, CodeActionKind, DocumentDiagnosticReportKind, } from "vscode-languageserver-protocol"; // Config const INIT_TIMEOUT_MS = 30000; const MAX_OPEN_FILES = 30; const IDLE_TIMEOUT_MS = 60_000; const CLEANUP_INTERVAL_MS = 30_000; export const LANGUAGE_IDS: Record = { ".dart": "dart", ".ts": "typescript", ".tsx": "typescriptreact", ".js": "javascript", ".jsx": "javascriptreact", ".mjs": "javascript", ".cjs": "javascript", ".mts": "typescript", ".cts": "typescript", ".vue": "vue", ".svelte": "svelte", ".astro": "astro", ".py": "python", ".pyi": "python", ".go": "go", ".rs": "rust", }; // Types interface LSPServerConfig { id: string; extensions: string[]; findRoot: (file: string, cwd: string) => string | undefined; spawn: (root: string) => Promise<{ process: ChildProcessWithoutNullStreams; initOptions?: Record } | undefined>; } interface OpenFile { version: number; lastAccess: number; } interface LSPClient { connection: MessageConnection; process: ChildProcessWithoutNullStreams; diagnostics: Map; openFiles: Map; listeners: Map void>>; stderr: string[]; capabilities?: unknown; root: string; closed: boolean; } export interface FileDiagnosticItem { file: string; diagnostics: Diagnostic[]; status: "ok" | "timeout" | "error" | "unsupported"; error?: string; } export interface FileDiagnosticsResult { items: FileDiagnosticItem[]; } // Utilities const SEARCH_PATHS = [ ...(process.env.PATH?.split(path.delimiter) || []), "/usr/local/bin", "/opt/homebrew/bin", `${process.env.HOME}/.pub-cache/bin`, `${process.env.HOME}/fvm/default/bin`, `${process.env.HOME}/go/bin`, `${process.env.HOME}/.cargo/bin`, ]; function which(cmd: string): string | undefined { const ext = process.platform === "win32" ? ".exe" : ""; for (const dir of SEARCH_PATHS) { const full = path.join(dir, cmd + ext); try { if (fs.existsSync(full) && fs.statSync(full).isFile()) return full; } catch {} } } function normalizeFsPath(p: string): string { try { return fs.realpathSync(p); } catch { return p; } } function findNearestFile(startDir: string, targets: string[], stopDir: string): string | undefined { let current = path.resolve(startDir); const stop = path.resolve(stopDir); while (current.length >= stop.length) { for (const t of targets) { const candidate = path.join(current, t); if (fs.existsSync(candidate)) return candidate; } const parent = path.dirname(current); if (parent === current) break; current = parent; } } function findRoot(file: string, cwd: string, markers: string[]): string | undefined { const found = findNearestFile(path.dirname(file), markers, cwd); return found ? path.dirname(found) : undefined; } function timeout(promise: Promise, ms: number, name: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`${name} timed out`)), ms); promise.then( (r) => { clearTimeout(timer); resolve(r); }, (e) => { clearTimeout(timer); reject(e); } ); }); } function simpleSpawn(bin: string, args: string[] = ["--stdio"]) { return async (root: string) => { const cmd = which(bin); if (!cmd) return undefined; return { process: spawn(cmd, args, { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) }; }; } async function spawnChecked(cmd: string, args: string[], cwd: string): Promise { try { const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] }); return await new Promise((resolve) => { let settled = false; const cleanup = () => { child.removeListener("exit", onExit); child.removeListener("error", onError); }; let timer: NodeJS.Timeout | null = null; const finish = (value: ChildProcessWithoutNullStreams | undefined) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); cleanup(); resolve(value); }; const onExit = () => finish(undefined); const onError = () => finish(undefined); child.once("exit", onExit); child.once("error", onError); timer = setTimeout(() => finish(child), 200); (timer as NodeJS.Timeout & { unref?: () => void }).unref?.(); }); } catch { return undefined; } } async function spawnWithFallback(cmd: string, argsVariants: string[][], cwd: string): Promise { for (const args of argsVariants) { const child = await spawnChecked(cmd, args, cwd); if (child) return child; } return undefined; } // Server Configs export const LSP_SERVERS: LSPServerConfig[] = [ { id: "dart", extensions: [".dart"], findRoot: (f, cwd) => findRoot(f, cwd, ["pubspec.yaml", "analysis_options.yaml"]), spawn: async (root) => { let dart = which("dart"); const pubspec = path.join(root, "pubspec.yaml"); if (fs.existsSync(pubspec)) { try { const content = fs.readFileSync(pubspec, "utf-8"); if (content.includes("flutter:") || content.includes("sdk: flutter")) { const flutter = which("flutter"); if (flutter) { const dir = path.dirname(fs.realpathSync(flutter)); for (const p of ["cache/dart-sdk/bin/dart", "../cache/dart-sdk/bin/dart"]) { const c = path.join(dir, p); if (fs.existsSync(c)) { dart = c; break; } } } } } catch {} } if (!dart) return undefined; return { process: spawn(dart, ["language-server", "--protocol=lsp"], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) }; }, }, { id: "typescript", extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], findRoot: (f, cwd) => { // Skip if this is a Deno project if (findNearestFile(path.dirname(f), ["deno.json", "deno.jsonc"], cwd)) return undefined; return findRoot(f, cwd, ["package.json", "tsconfig.json", "jsconfig.json"]); }, spawn: async (root) => { // Prefer tsgo (TypeScript Go) - much faster const tsgo = which("tsgo"); if (tsgo) { const proc = await spawnChecked(tsgo, ["--lsp", "--stdio"], root); if (proc) return { process: proc }; } // Fall back to typescript-language-server const local = path.join(root, "node_modules/.bin/typescript-language-server"); const cmd = fs.existsSync(local) ? local : which("typescript-language-server"); if (!cmd) return undefined; return { process: spawn(cmd, ["--stdio"], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) }; }, }, { id: "vue", extensions: [".vue"], findRoot: (f, cwd) => findRoot(f, cwd, ["package.json", "vite.config.ts", "vite.config.js"]), spawn: simpleSpawn("vue-language-server"), }, { id: "svelte", extensions: [".svelte"], findRoot: (f, cwd) => findRoot(f, cwd, ["package.json", "svelte.config.js"]), spawn: simpleSpawn("svelteserver"), }, { id: "pyright", extensions: [".py", ".pyi"], findRoot: (f, cwd) => findRoot(f, cwd, ["pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"]), spawn: simpleSpawn("pyright-langserver"), }, { id: "gopls", extensions: [".go"], findRoot: (f, cwd) => findRoot(f, cwd, ["go.work"]) || findRoot(f, cwd, ["go.mod"]), spawn: simpleSpawn("gopls", []), }, { id: "rust-analyzer", extensions: [".rs"], findRoot: (f, cwd) => findRoot(f, cwd, ["Cargo.toml"]), spawn: simpleSpawn("rust-analyzer", []), }, ]; // Singleton Manager let sharedManager: LSPManager | null = null; let managerCwd: string | null = null; export function getOrCreateManager(cwd: string): LSPManager { if (!sharedManager || managerCwd !== cwd) { sharedManager?.shutdown().catch(() => {}); sharedManager = new LSPManager(cwd); managerCwd = cwd; } return sharedManager; } export function getManager(): LSPManager | null { return sharedManager; } export async function shutdownManager(): Promise { const manager = sharedManager; if (!manager) return; sharedManager = null; managerCwd = null; await manager.shutdown(); } // LSP Manager export class LSPManager { private clients = new Map(); private spawning = new Map>(); private broken = new Set(); private cwd: string; private cleanupTimer: NodeJS.Timeout | null = null; constructor(cwd: string) { this.cwd = cwd; this.cleanupTimer = setInterval(() => this.cleanupIdleFiles(), CLEANUP_INTERVAL_MS); this.cleanupTimer.unref(); } private cleanupIdleFiles() { const now = Date.now(); for (const client of this.clients.values()) { for (const [fp, state] of client.openFiles) { if (now - state.lastAccess > IDLE_TIMEOUT_MS) this.closeFile(client, fp); } } } private closeFile(client: LSPClient, absPath: string) { if (!client.openFiles.has(absPath)) return; client.openFiles.delete(absPath); if (client.closed) return; try { void client.connection .sendNotification(DidCloseTextDocumentNotification.type, { textDocument: { uri: pathToFileURL(absPath).href }, }) .catch(() => {}); } catch {} } private evictLRU(client: LSPClient) { if (client.openFiles.size <= MAX_OPEN_FILES) return; let oldest: { path: string; time: number } | null = null; for (const [fp, s] of client.openFiles) { if (!oldest || s.lastAccess < oldest.time) oldest = { path: fp, time: s.lastAccess }; } if (oldest) this.closeFile(client, oldest.path); } private key(id: string, root: string) { return `${id}:${root}`; } private async initClient(config: LSPServerConfig, root: string): Promise { const k = this.key(config.id, root); try { const handle = await config.spawn(root); if (!handle) { this.broken.add(k); return undefined; } const reader = new StreamMessageReader(handle.process.stdout!); const writer = new StreamMessageWriter(handle.process.stdin!); const conn = createMessageConnection(reader, writer); handle.process.stdin?.on("error", () => {}); handle.process.stdout?.on("error", () => {}); const stderr: string[] = []; const MAX_STDERR_LINES = 200; handle.process.stderr?.on("data", (chunk: Buffer) => { try { const text = chunk.toString("utf-8"); for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue; stderr.push(line); if (stderr.length > MAX_STDERR_LINES) stderr.splice(0, stderr.length - MAX_STDERR_LINES); } } catch {} }); handle.process.stderr?.on("error", () => {}); const client: LSPClient = { connection: conn, process: handle.process, diagnostics: new Map(), openFiles: new Map(), listeners: new Map(), stderr, root, closed: false, }; conn.onNotification("textDocument/publishDiagnostics", (params: { uri: string; diagnostics: Diagnostic[] }) => { const fpRaw = decodeURIComponent(new URL(params.uri).pathname); const fp = normalizeFsPath(fpRaw); client.diagnostics.set(fp, params.diagnostics); const listeners1 = client.listeners.get(fp); const listeners2 = fp !== fpRaw ? client.listeners.get(fpRaw) : undefined; listeners1?.slice().forEach((fn) => { try { fn(); } catch {} }); listeners2?.slice().forEach((fn) => { try { fn(); } catch {} }); }); conn.onError(() => {}); conn.onClose(() => { client.closed = true; this.clients.delete(k); }); conn.onRequest("workspace/configuration", () => [handle.initOptions ?? {}]); conn.onRequest("window/workDoneProgress/create", () => null); conn.onRequest("client/registerCapability", () => {}); conn.onRequest("client/unregisterCapability", () => {}); conn.onRequest("workspace/workspaceFolders", () => [{ name: "workspace", uri: pathToFileURL(root).href }]); handle.process.on("exit", () => { client.closed = true; this.clients.delete(k); }); handle.process.on("error", () => { client.closed = true; this.clients.delete(k); this.broken.add(k); }); conn.listen(); const initResult = await timeout( conn.sendRequest(InitializeRequest.method, { rootUri: pathToFileURL(root).href, rootPath: root, processId: process.pid, workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }], initializationOptions: handle.initOptions ?? {}, capabilities: { window: { workDoneProgress: true }, workspace: { configuration: true }, textDocument: { synchronization: { didSave: true, didOpen: true, didChange: true, didClose: true }, publishDiagnostics: { versionSupport: true }, diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false }, }, }, }), INIT_TIMEOUT_MS, `${config.id} init` ); client.capabilities = (initResult as { capabilities?: unknown })?.capabilities; conn.sendNotification(InitializedNotification.type, {}); if (handle.initOptions) { conn.sendNotification("workspace/didChangeConfiguration", { settings: handle.initOptions }); } return client; } catch { this.broken.add(k); return undefined; } } async getClientsForFile(filePath: string): Promise { const ext = path.extname(filePath); const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(this.cwd, filePath); const clients: LSPClient[] = []; for (const config of LSP_SERVERS) { if (!config.extensions.includes(ext)) continue; const root = config.findRoot(absPath, this.cwd); if (!root) continue; const k = this.key(config.id, root); if (this.broken.has(k)) continue; const existing = this.clients.get(k); if (existing) { clients.push(existing); continue; } if (!this.spawning.has(k)) { const p = this.initClient(config, root); this.spawning.set(k, p); p.finally(() => this.spawning.delete(k)); } const client = await this.spawning.get(k); if (client) { this.clients.set(k, client); clients.push(client); } } return clients; } private resolve(fp: string) { const abs = path.isAbsolute(fp) ? fp : path.resolve(this.cwd, fp); return normalizeFsPath(abs); } private langId(fp: string) { return LANGUAGE_IDS[path.extname(fp)] || "plaintext"; } private readFile(fp: string): string | null { try { return fs.readFileSync(fp, "utf-8"); } catch { return null; } } private explainNoLsp(absPath: string): string { const ext = path.extname(absPath); return `No LSP for ${ext}`; } private toPos(line: number, col: number) { return { line: Math.max(0, line - 1), character: Math.max(0, col - 1) }; } private normalizeLocs(result: Location | Location[] | LocationLink[] | null | undefined): Location[] { if (!result) return []; const items = Array.isArray(result) ? result : [result]; if (!items.length) return []; if ("uri" in items[0] && "range" in items[0]) return items as Location[]; return (items as LocationLink[]).map((l) => ({ uri: l.targetUri, range: l.targetSelectionRange ?? l.targetRange })); } private normalizeSymbols(result: DocumentSymbol[] | SymbolInformation[] | null | undefined): DocumentSymbol[] { if (!result?.length) return []; const first = result[0]; if ("location" in first) { return (result as SymbolInformation[]).map((s) => ({ name: s.name, kind: s.kind, range: s.location.range, selectionRange: s.location.range, detail: s.containerName, tags: s.tags, deprecated: s.deprecated, children: [], })); } return result as DocumentSymbol[]; } private async openOrUpdate(clients: LSPClient[], absPath: string, uri: string, langId: string, content: string, evict = true) { const now = Date.now(); for (const client of clients) { if (client.closed) continue; const state = client.openFiles.get(absPath); try { if (state) { const v = state.version + 1; client.openFiles.set(absPath, { version: v, lastAccess: now }); void client.connection .sendNotification(DidChangeTextDocumentNotification.type, { textDocument: { uri, version: v }, contentChanges: [{ text: content }], }) .catch(() => {}); } else { client.openFiles.set(absPath, { version: 1, lastAccess: now }); void client.connection .sendNotification(DidOpenTextDocumentNotification.type, { textDocument: { uri, languageId: langId, version: 0, text: content }, }) .catch(() => {}); void client.connection .sendNotification(DidChangeTextDocumentNotification.type, { textDocument: { uri, version: 1 }, contentChanges: [{ text: content }], }) .catch(() => {}); if (evict) this.evictLRU(client); } void client.connection .sendNotification(DidSaveTextDocumentNotification.type, { textDocument: { uri }, text: content, }) .catch(() => {}); } catch {} } } private async loadFile(filePath: string) { const absPath = this.resolve(filePath); const clients = await this.getClientsForFile(absPath); if (!clients.length) return null; const content = this.readFile(absPath); if (content === null) return null; return { clients, absPath, uri: pathToFileURL(absPath).href, langId: this.langId(absPath), content }; } private waitForDiagnostics(client: LSPClient, absPath: string, timeoutMs: number, isNew: boolean): Promise { return new Promise((resolve) => { if (client.closed) return resolve(false); let resolved = false; let settleTimer: NodeJS.Timeout | null = null; let listener: () => void = () => {}; const cleanupListener = () => { const listeners = client.listeners.get(absPath); if (!listeners) return; const idx = listeners.indexOf(listener); if (idx !== -1) listeners.splice(idx, 1); if (listeners.length === 0) client.listeners.delete(absPath); }; const finish = (value: boolean) => { if (resolved) return; resolved = true; if (settleTimer) clearTimeout(settleTimer); clearTimeout(timer); cleanupListener(); resolve(value); }; listener = () => { if (resolved) return; const current = client.diagnostics.get(absPath); if (current && current.length > 0) return finish(true); if (!isNew) return finish(true); if (settleTimer) clearTimeout(settleTimer); settleTimer = setTimeout(() => finish(true), 2500); (settleTimer as NodeJS.Timeout & { unref?: () => void }).unref?.(); }; const timer = setTimeout(() => finish(false), timeoutMs); (timer as NodeJS.Timeout & { unref?: () => void }).unref?.(); const listeners = client.listeners.get(absPath) || []; listeners.push(listener); client.listeners.set(absPath, listeners); }); } private async pullDiagnostics(client: LSPClient, absPath: string, uri: string): Promise<{ diagnostics: Diagnostic[]; responded: boolean }> { if (client.closed) return { diagnostics: [], responded: false }; if (!client.capabilities || !(client.capabilities as { diagnosticProvider?: unknown }).diagnosticProvider) { return { diagnostics: [], responded: false }; } try { const res = (await client.connection.sendRequest(DocumentDiagnosticRequest.method, { textDocument: { uri }, })) as { kind?: string; items?: Diagnostic[] }; if (res?.kind === DocumentDiagnosticReportKind.Full) { return { diagnostics: Array.isArray(res.items) ? res.items : [], responded: true }; } if (res?.kind === DocumentDiagnosticReportKind.Unchanged) { return { diagnostics: client.diagnostics.get(absPath) || [], responded: true }; } if (Array.isArray(res?.items)) { return { diagnostics: res.items, responded: true }; } return { diagnostics: [], responded: true }; } catch {} try { const res = (await client.connection.sendRequest(WorkspaceDiagnosticRequest.method, { previousResultIds: [], })) as { items?: Array<{ uri?: string; kind?: string; items?: Diagnostic[] }> }; const items = res?.items || []; const match = items.find((it) => it?.uri === uri); if (match?.kind === DocumentDiagnosticReportKind.Full) { return { diagnostics: Array.isArray(match.items) ? match.items : [], responded: true }; } if (Array.isArray(match?.items)) { return { diagnostics: match.items, responded: true }; } return { diagnostics: [], responded: true }; } catch { return { diagnostics: [], responded: false }; } } async touchFileAndWait(filePath: string, timeoutMs: number): Promise<{ diagnostics: Diagnostic[]; receivedResponse: boolean; unsupported?: boolean; error?: string }> { const absPath = this.resolve(filePath); if (!fs.existsSync(absPath)) { return { diagnostics: [], receivedResponse: false, unsupported: true, error: "File not found" }; } const clients = await this.getClientsForFile(absPath); if (!clients.length) { return { diagnostics: [], receivedResponse: false, unsupported: true, error: this.explainNoLsp(absPath) }; } const content = this.readFile(absPath); if (content === null) { return { diagnostics: [], receivedResponse: false, unsupported: true, error: "Could not read file" }; } const uri = pathToFileURL(absPath).href; const langId = this.langId(absPath); const isNew = clients.some((c) => !c.openFiles.has(absPath)); const waits = clients.map((c) => this.waitForDiagnostics(c, absPath, timeoutMs, isNew)); await this.openOrUpdate(clients, absPath, uri, langId, content); const results = await Promise.all(waits); let responded = results.some((r) => r); const diags: Diagnostic[] = []; for (const c of clients) { const d = c.diagnostics.get(absPath); if (d) diags.push(...d); } if (!responded && clients.some((c) => c.diagnostics.has(absPath))) responded = true; if (!responded || diags.length === 0) { const pulled = await Promise.all(clients.map((c) => this.pullDiagnostics(c, absPath, uri))); for (let i = 0; i < clients.length; i++) { const r = pulled[i]; if (r.responded) responded = true; if (r.diagnostics.length) { clients[i].diagnostics.set(absPath, r.diagnostics); diags.push(...r.diagnostics); } } } return { diagnostics: diags, receivedResponse: responded }; } async getDiagnosticsForFiles(files: string[], timeoutMs: number): Promise { const unique = [...new Set(files.map((f) => this.resolve(f)))]; const results: FileDiagnosticItem[] = []; const toClose: Map = new Map(); for (const absPath of unique) { if (!fs.existsSync(absPath)) { results.push({ file: absPath, diagnostics: [], status: "error", error: "File not found" }); continue; } let clients: LSPClient[]; try { clients = await this.getClientsForFile(absPath); } catch (e) { results.push({ file: absPath, diagnostics: [], status: "error", error: String(e) }); continue; } if (!clients.length) { results.push({ file: absPath, diagnostics: [], status: "unsupported", error: this.explainNoLsp(absPath) }); continue; } const content = this.readFile(absPath); if (!content) { results.push({ file: absPath, diagnostics: [], status: "error", error: "Could not read file" }); continue; } const uri = pathToFileURL(absPath).href; const langId = this.langId(absPath); const isNew = clients.some((c) => !c.openFiles.has(absPath)); for (const c of clients) { if (!c.openFiles.has(absPath)) { if (!toClose.has(c)) toClose.set(c, []); toClose.get(c)!.push(absPath); } } const waits = clients.map((c) => this.waitForDiagnostics(c, absPath, timeoutMs, isNew)); await this.openOrUpdate(clients, absPath, uri, langId, content, false); const waitResults = await Promise.all(waits); const diags: Diagnostic[] = []; for (const c of clients) { const d = c.diagnostics.get(absPath); if (d) diags.push(...d); } let responded = waitResults.some((r) => r) || diags.length > 0; if (!responded || diags.length === 0) { const pulled = await Promise.all(clients.map((c) => this.pullDiagnostics(c, absPath, uri))); for (let i = 0; i < clients.length; i++) { const r = pulled[i]; if (r.responded) responded = true; if (r.diagnostics.length) { clients[i].diagnostics.set(absPath, r.diagnostics); diags.push(...r.diagnostics); } } } if (!responded && !diags.length) { results.push({ file: absPath, diagnostics: [], status: "timeout", error: "LSP did not respond" }); } else { results.push({ file: absPath, diagnostics: diags, status: "ok" }); } } for (const [c, fps] of toClose) { for (const fp of fps) this.closeFile(c, fp); } for (const c of this.clients.values()) { while (c.openFiles.size > MAX_OPEN_FILES) this.evictLRU(c); } return { items: results }; } async getDefinition(fp: string, line: number, col: number): Promise { const l = await this.loadFile(fp); if (!l) return []; await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); const pos = this.toPos(line, col); const results = await Promise.all( l.clients.map(async (c) => { if (c.closed) return []; try { return this.normalizeLocs(await c.connection.sendRequest(DefinitionRequest.type, { textDocument: { uri: l.uri }, position: pos })); } catch { return []; } }) ); return results.flat(); } async getReferences(fp: string, line: number, col: number): Promise { const l = await this.loadFile(fp); if (!l) return []; await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); const pos = this.toPos(line, col); const results = await Promise.all( l.clients.map(async (c) => { if (c.closed) return []; try { return this.normalizeLocs(await c.connection.sendRequest(ReferencesRequest.type, { textDocument: { uri: l.uri }, position: pos, context: { includeDeclaration: true } })); } catch { return []; } }) ); return results.flat(); } async getHover(fp: string, line: number, col: number): Promise { const l = await this.loadFile(fp); if (!l) return null; await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); const pos = this.toPos(line, col); for (const c of l.clients) { if (c.closed) continue; try { const r = await c.connection.sendRequest(HoverRequest.type, { textDocument: { uri: l.uri }, position: pos }); if (r) return r; } catch {} } return null; } async getSignatureHelp(fp: string, line: number, col: number): Promise { const l = await this.loadFile(fp); if (!l) return null; await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); const pos = this.toPos(line, col); for (const c of l.clients) { if (c.closed) continue; try { const r = await c.connection.sendRequest(SignatureHelpRequest.type, { textDocument: { uri: l.uri }, position: pos }); if (r) return r; } catch {} } return null; } async getDocumentSymbols(fp: string): Promise { const l = await this.loadFile(fp); if (!l) return []; await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); const results = await Promise.all( l.clients.map(async (c) => { if (c.closed) return []; try { return this.normalizeSymbols(await c.connection.sendRequest(DocumentSymbolRequest.type, { textDocument: { uri: l.uri } })); } catch { return []; } }) ); return results.flat(); } async rename(fp: string, line: number, col: number, newName: string): Promise { const l = await this.loadFile(fp); if (!l) return null; await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); const pos = this.toPos(line, col); for (const c of l.clients) { if (c.closed) continue; try { const r = await c.connection.sendRequest(RenameRequest.type, { textDocument: { uri: l.uri }, position: pos, newName, }); if (r) return r; } catch {} } return null; } async getCodeActions(fp: string, startLine: number, startCol: number, endLine?: number, endCol?: number): Promise<(CodeAction | Command)[]> { const l = await this.loadFile(fp); if (!l) return []; await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content); const start = this.toPos(startLine, startCol); const end = this.toPos(endLine ?? startLine, endCol ?? startCol); const range = { start, end }; const diagnostics: Diagnostic[] = []; for (const c of l.clients) { const fileDiags = c.diagnostics.get(l.absPath) || []; for (const d of fileDiags) { if (this.rangesOverlap(d.range, range)) diagnostics.push(d); } } const results = await Promise.all( l.clients.map(async (c) => { if (c.closed) return []; try { const r = await c.connection.sendRequest(CodeActionRequest.type, { textDocument: { uri: l.uri }, range, context: { diagnostics, only: [CodeActionKind.QuickFix, CodeActionKind.Refactor, CodeActionKind.Source] }, }); return r || []; } catch { return []; } }) ); return results.flat(); } private rangesOverlap( a: { start: { line: number; character: number }; end: { line: number; character: number } }, b: { start: { line: number; character: number }; end: { line: number; character: number } } ): boolean { if (a.end.line < b.start.line || b.end.line < a.start.line) return false; if (a.end.line === b.start.line && a.end.character < b.start.character) return false; if (b.end.line === a.start.line && b.end.character < a.start.character) return false; return true; } async shutdown() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } const clients = Array.from(this.clients.values()); this.clients.clear(); for (const c of clients) { const wasClosed = c.closed; c.closed = true; if (!wasClosed) { try { await Promise.race([c.connection.sendRequest("shutdown"), new Promise((r) => setTimeout(r, 1000))]); } catch {} try { void c.connection.sendNotification("exit").catch(() => {}); } catch {} } try { c.connection.end(); } catch {} try { c.process.kill(); } catch {} } } } // Diagnostic Formatting export { DiagnosticSeverity }; export type SeverityFilter = "all" | "error" | "warning" | "info" | "hint"; export function formatDiagnostic(d: Diagnostic): string { const sev = ["", "ERROR", "WARN", "INFO", "HINT"][d.severity || 1]; return `${sev} [${d.range.start.line + 1}:${d.range.start.character + 1}] ${d.message}`; } export function filterDiagnosticsBySeverity(diags: Diagnostic[], filter: SeverityFilter): Diagnostic[] { if (filter === "all") return diags; const max = { error: 1, warning: 2, info: 3, hint: 4 }[filter]; return diags.filter((d) => (d.severity || 1) <= max); } // URI utilities export function uriToPath(uri: string): string { if (uri.startsWith("file://")) try { return fileURLToPath(uri); } catch {} return uri; } // Symbol search export function findSymbolPosition(symbols: DocumentSymbol[], query: string): { line: number; character: number } | null { const q = query.toLowerCase(); let exact: { line: number; character: number } | null = null; let partial: { line: number; character: number } | null = null; const visit = (items: DocumentSymbol[]) => { for (const sym of items) { const name = String(sym?.name ?? "").toLowerCase(); const pos = sym?.selectionRange?.start ?? sym?.range?.start; if (pos && typeof pos.line === "number" && typeof pos.character === "number") { if (!exact && name === q) exact = pos; if (!partial && name.includes(q)) partial = pos; } if (sym?.children?.length) visit(sym.children); } }; visit(symbols); return exact ?? partial; } export async function resolvePosition(manager: LSPManager, file: string, query: string): Promise<{ line: number; column: number } | null> { const symbols = await manager.getDocumentSymbols(file); const pos = findSymbolPosition(symbols, query); return pos ? { line: pos.line + 1, column: pos.character + 1 } : null; }