Files
dotfiles/pi/files/agent/extensions/lsp/lsp-core.ts
T
Thomas G. Lopes cc43b56c9a lsp tool
2026-03-03 17:32:49 +00:00

1083 lines
35 KiB
TypeScript

/**
* 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<string, string> = {
".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<string, unknown> } | undefined>;
}
interface OpenFile {
version: number;
lastAccess: number;
}
interface LSPClient {
connection: MessageConnection;
process: ChildProcessWithoutNullStreams;
diagnostics: Map<string, Diagnostic[]>;
openFiles: Map<string, OpenFile>;
listeners: Map<string, Array<() => 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<T>(promise: Promise<T>, ms: number, name: string): Promise<T> {
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<ChildProcessWithoutNullStreams | undefined> {
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<ChildProcessWithoutNullStreams | undefined> {
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<void> {
const manager = sharedManager;
if (!manager) return;
sharedManager = null;
managerCwd = null;
await manager.shutdown();
}
// LSP Manager
export class LSPManager {
private clients = new Map<string, LSPClient>();
private spawning = new Map<string, Promise<LSPClient | undefined>>();
private broken = new Set<string>();
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<LSPClient | undefined> {
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<LSPClient[]> {
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<boolean> {
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<FileDiagnosticsResult> {
const unique = [...new Set(files.map((f) => this.resolve(f)))];
const results: FileDiagnosticItem[] = [];
const toClose: Map<LSPClient, string[]> = 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<Location[]> {
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<Location[]> {
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<Hover | null> {
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<SignatureHelp | null> {
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<DocumentSymbol[]> {
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<WorkspaceEdit | null> {
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;
}