flake-update-20260505
1/**
2 * LSP Core - Language Server Protocol client management
3 */
4import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
5import * as path from "node:path";
6import * as fs from "node:fs";
7import * as os from "node:os";
8import { pathToFileURL, fileURLToPath } from "node:url";
9import {
10 createMessageConnection,
11 StreamMessageReader,
12 StreamMessageWriter,
13 type MessageConnection,
14 InitializeRequest,
15 InitializedNotification,
16 DidOpenTextDocumentNotification,
17 DidChangeTextDocumentNotification,
18 DidCloseTextDocumentNotification,
19 DidSaveTextDocumentNotification,
20 PublishDiagnosticsNotification,
21 DocumentDiagnosticRequest,
22 WorkspaceDiagnosticRequest,
23 DefinitionRequest,
24 ReferencesRequest,
25 HoverRequest,
26 SignatureHelpRequest,
27 DocumentSymbolRequest,
28 RenameRequest,
29 CodeActionRequest,
30} from "vscode-languageserver-protocol/node.js";
31import {
32 type Diagnostic,
33 type Location,
34 type LocationLink,
35 type DocumentSymbol,
36 type SymbolInformation,
37 type Hover,
38 type SignatureHelp,
39 type WorkspaceEdit,
40 type CodeAction,
41 type Command,
42 DiagnosticSeverity,
43 CodeActionKind,
44 DocumentDiagnosticReportKind,
45} from "vscode-languageserver-protocol";
46
47// Config
48const INIT_TIMEOUT_MS = 30000;
49const MAX_OPEN_FILES = 30;
50const IDLE_TIMEOUT_MS = 60_000;
51const CLEANUP_INTERVAL_MS = 30_000;
52
53export const LANGUAGE_IDS: Record<string, string> = {
54 ".dart": "dart", ".ts": "typescript", ".tsx": "typescriptreact",
55 ".js": "javascript", ".jsx": "javascriptreact", ".mjs": "javascript",
56 ".cjs": "javascript", ".mts": "typescript", ".cts": "typescript",
57 ".vue": "vue", ".svelte": "svelte", ".astro": "astro",
58 ".py": "python", ".pyi": "python", ".go": "go", ".rs": "rust",
59 ".kt": "kotlin", ".kts": "kotlin",
60 ".swift": "swift",
61 ".nix": "nix",
62};
63
64// Types
65interface LSPServerConfig {
66 id: string;
67 extensions: string[];
68 findRoot: (file: string, cwd: string) => string | undefined;
69 spawn: (root: string) => Promise<{ process: ChildProcessWithoutNullStreams; initOptions?: Record<string, unknown> } | undefined>;
70}
71
72interface OpenFile { version: number; lastAccess: number; }
73
74interface LSPClient {
75 connection: MessageConnection;
76 process: ChildProcessWithoutNullStreams;
77 diagnostics: Map<string, Diagnostic[]>;
78 openFiles: Map<string, OpenFile>;
79 listeners: Map<string, Array<() => void>>;
80 stderr: string[];
81 capabilities?: any;
82 root: string;
83 closed: boolean;
84}
85
86export interface FileDiagnosticItem {
87 file: string;
88 diagnostics: Diagnostic[];
89 status: 'ok' | 'timeout' | 'error' | 'unsupported';
90 error?: string;
91}
92
93export interface FileDiagnosticsResult { items: FileDiagnosticItem[]; }
94
95// Utilities
96const SEARCH_PATHS = [
97 ...(process.env.PATH?.split(path.delimiter) || []),
98 "/usr/local/bin", "/opt/homebrew/bin",
99 `${process.env.HOME}/.pub-cache/bin`, `${process.env.HOME}/fvm/default/bin`,
100 `${process.env.HOME}/go/bin`, `${process.env.HOME}/.cargo/bin`,
101];
102
103function which(cmd: string): string | undefined {
104 const ext = process.platform === "win32" ? ".exe" : "";
105 for (const dir of SEARCH_PATHS) {
106 const full = path.join(dir, cmd + ext);
107 try { if (fs.existsSync(full) && fs.statSync(full).isFile()) return full; } catch {}
108 }
109}
110
111function normalizeFsPath(p: string): string {
112 try {
113 // realpathSync.native is faster on some platforms, but not always present
114 const fn: any = (fs as any).realpathSync?.native || fs.realpathSync;
115 return fn(p);
116 } catch {
117 return p;
118 }
119}
120
121function findNearestFile(startDir: string, targets: string[], stopDir: string): string | undefined {
122 let current = path.resolve(startDir);
123 const stop = path.resolve(stopDir);
124 while (current.length >= stop.length) {
125 for (const t of targets) {
126 const candidate = path.join(current, t);
127 if (fs.existsSync(candidate)) return candidate;
128 }
129 const parent = path.dirname(current);
130 if (parent === current) break;
131 current = parent;
132 }
133}
134
135function findRoot(file: string, cwd: string, markers: string[]): string | undefined {
136 const found = findNearestFile(path.dirname(file), markers, cwd);
137 return found ? path.dirname(found) : undefined;
138}
139
140function timeout<T>(promise: Promise<T>, ms: number, name: string): Promise<T> {
141 return new Promise((resolve, reject) => {
142 const timer = setTimeout(() => reject(new Error(`${name} timed out`)), ms);
143 promise.then(r => { clearTimeout(timer); resolve(r); }, e => { clearTimeout(timer); reject(e); });
144 });
145}
146
147function simpleSpawn(bin: string, args: string[] = ["--stdio"]) {
148 return async (root: string) => {
149 const cmd = which(bin);
150 if (!cmd) return undefined;
151 return { process: spawn(cmd, args, { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
152 };
153}
154
155async function spawnChecked(cmd: string, args: string[], cwd: string): Promise<ChildProcessWithoutNullStreams | undefined> {
156 try {
157 const child = spawn(cmd, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
158
159 // If the process exits immediately (e.g. unsupported flag), treat it as a failure
160 return await new Promise((resolve) => {
161 let settled = false;
162
163 const cleanup = () => {
164 child.removeListener("exit", onExit);
165 child.removeListener("error", onError);
166 };
167
168 let timer: NodeJS.Timeout | null = null;
169
170 const finish = (value: ChildProcessWithoutNullStreams | undefined) => {
171 if (settled) return;
172 settled = true;
173 if (timer) clearTimeout(timer);
174 cleanup();
175 resolve(value);
176 };
177
178 const onExit = () => finish(undefined);
179 const onError = () => finish(undefined);
180
181 child.once("exit", onExit);
182 child.once("error", onError);
183
184 timer = setTimeout(() => finish(child), 200);
185 (timer as any).unref?.();
186 });
187 } catch {
188 return undefined;
189 }
190}
191
192async function spawnWithFallback(cmd: string, argsVariants: string[][], cwd: string): Promise<ChildProcessWithoutNullStreams | undefined> {
193 for (const args of argsVariants) {
194 const child = await spawnChecked(cmd, args, cwd);
195 if (child) return child;
196 }
197 return undefined;
198}
199
200function findRootKotlin(file: string, cwd: string): string | undefined {
201 // Prefer Gradle settings root for multi-module projects
202 const gradleRoot = findRoot(file, cwd, ["settings.gradle.kts", "settings.gradle"]);
203 if (gradleRoot) return gradleRoot;
204
205 // Fallbacks for single-module Gradle or Maven builds
206 return findRoot(file, cwd, [
207 "build.gradle.kts",
208 "build.gradle",
209 "gradlew",
210 "gradlew.bat",
211 "gradle.properties",
212 "pom.xml",
213 ]);
214}
215
216function dirContainsNestedProjectFile(dir: string, dirSuffix: string, markerFile: string): boolean {
217 try {
218 const entries = fs.readdirSync(dir, { withFileTypes: true });
219 for (const e of entries) {
220 if (!e.isDirectory()) continue;
221 if (!e.name.endsWith(dirSuffix)) continue;
222 if (fs.existsSync(path.join(dir, e.name, markerFile))) return true;
223 }
224 } catch {
225 // ignore
226 }
227 return false;
228}
229
230function findRootSwift(file: string, cwd: string): string | undefined {
231 let current = path.resolve(path.dirname(file));
232 const stop = path.resolve(cwd);
233
234 while (current.length >= stop.length) {
235 if (fs.existsSync(path.join(current, "Package.swift"))) return current;
236
237 // Xcode projects/workspaces store their marker files *inside* a directory
238 if (dirContainsNestedProjectFile(current, ".xcodeproj", "project.pbxproj")) return current;
239 if (dirContainsNestedProjectFile(current, ".xcworkspace", "contents.xcworkspacedata")) return current;
240
241 const parent = path.dirname(current);
242 if (parent === current) break;
243 current = parent;
244 }
245
246 return undefined;
247}
248
249async function runCommand(cmd: string, args: string[], cwd: string): Promise<boolean> {
250 return await new Promise((resolve) => {
251 try {
252 const p = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
253 p.on("error", () => resolve(false));
254 p.on("exit", (code) => resolve(code === 0));
255 } catch {
256 resolve(false);
257 }
258 });
259}
260
261async function ensureJetBrainsKotlinLspInstalled(): Promise<string | undefined> {
262 // Opt-in download (to avoid surprising network activity)
263 const allowDownload = process.env.PI_LSP_AUTO_DOWNLOAD_KOTLIN_LSP === "1" || process.env.PI_LSP_AUTO_DOWNLOAD_KOTLIN_LSP === "true";
264 const installDir = path.join(os.homedir(), ".pi", "agent", "lsp", "kotlin-ls");
265 const launcher = process.platform === "win32"
266 ? path.join(installDir, "kotlin-lsp.cmd")
267 : path.join(installDir, "kotlin-lsp.sh");
268
269 if (fs.existsSync(launcher)) return launcher;
270 if (!allowDownload) return undefined;
271
272 const curl = which("curl");
273 const unzip = which("unzip");
274 if (!curl || !unzip) return undefined;
275
276 try {
277 // Determine latest version
278 const res = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest", {
279 headers: { "User-Agent": "pi-lsp" },
280 });
281 if (!res.ok) return undefined;
282 const release: any = await res.json();
283 const versionRaw = (release?.name || release?.tag_name || "").toString();
284 const version = versionRaw.replace(/^v/, "");
285 if (!version) return undefined;
286
287 // Map platform/arch to JetBrains naming
288 const platform = process.platform;
289 const arch = process.arch;
290
291 let kotlinArch: string = arch;
292 if (arch === "arm64") kotlinArch = "aarch64";
293 else if (arch === "x64") kotlinArch = "x64";
294
295 let kotlinPlatform: string = platform;
296 if (platform === "darwin") kotlinPlatform = "mac";
297 else if (platform === "linux") kotlinPlatform = "linux";
298 else if (platform === "win32") kotlinPlatform = "win";
299
300 const supportedCombos = new Set(["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"]);
301 const combo = `${kotlinPlatform}-${kotlinArch}`;
302 if (!supportedCombos.has(combo)) return undefined;
303
304 const assetName = `kotlin-lsp-${version}-${kotlinPlatform}-${kotlinArch}.zip`;
305 const url = `https://download-cdn.jetbrains.com/kotlin-lsp/${version}/${assetName}`;
306
307 fs.mkdirSync(installDir, { recursive: true });
308 const zipPath = path.join(installDir, "kotlin-lsp.zip");
309
310 const okDownload = await runCommand(curl, ["-L", "-o", zipPath, url], installDir);
311 if (!okDownload || !fs.existsSync(zipPath)) return undefined;
312
313 const okUnzip = await runCommand(unzip, ["-o", zipPath, "-d", installDir], installDir);
314 try { fs.rmSync(zipPath, { force: true }); } catch {}
315 if (!okUnzip) return undefined;
316
317 if (process.platform !== "win32") {
318 try { fs.chmodSync(launcher, 0o755); } catch {}
319 }
320
321 return fs.existsSync(launcher) ? launcher : undefined;
322 } catch {
323 return undefined;
324 }
325}
326
327async function spawnKotlinLanguageServer(root: string): Promise<ChildProcessWithoutNullStreams | undefined> {
328 // Prefer JetBrains Kotlin LSP (Kotlin/kotlin-lsp) – better diagnostics for Gradle/Android projects.
329 const explicit = process.env.PI_LSP_KOTLIN_LSP_PATH;
330 if (explicit && fs.existsSync(explicit)) {
331 return spawnWithFallback(explicit, [["--stdio"]], root);
332 }
333
334 const jetbrains = which("kotlin-lsp") || which("kotlin-lsp.sh") || which("kotlin-lsp.cmd") || await ensureJetBrainsKotlinLspInstalled();
335 if (jetbrains) {
336 return spawnWithFallback(jetbrains, [["--stdio"]], root);
337 }
338
339 // Fallback: org.javacs/kotlin-language-server (often lacks diagnostics without full classpath)
340 const kls = which("kotlin-language-server");
341 if (!kls) return undefined;
342 return spawnWithFallback(kls, [[]], root);
343}
344
345async function spawnSourcekitLsp(root: string): Promise<ChildProcessWithoutNullStreams | undefined> {
346 const direct = which("sourcekit-lsp");
347 if (direct) return spawnWithFallback(direct, [[], ["--stdio"]], root);
348
349 // macOS/Xcode: sourcekit-lsp is often available via xcrun
350 const xcrun = which("xcrun");
351 if (!xcrun) return undefined;
352 return spawnWithFallback(xcrun, [["sourcekit-lsp"], ["sourcekit-lsp", "--stdio"]], root);
353}
354
355// Server Configs
356export const LSP_SERVERS: LSPServerConfig[] = [
357 {
358 id: "dart", extensions: [".dart"],
359 findRoot: (f, cwd) => findRoot(f, cwd, ["pubspec.yaml", "analysis_options.yaml"]),
360 spawn: async (root) => {
361 let dart = which("dart");
362 const pubspec = path.join(root, "pubspec.yaml");
363 if (fs.existsSync(pubspec)) {
364 try {
365 const content = fs.readFileSync(pubspec, "utf-8");
366 if (content.includes("flutter:") || content.includes("sdk: flutter")) {
367 const flutter = which("flutter");
368 if (flutter) {
369 const dir = path.dirname(fs.realpathSync(flutter));
370 for (const p of ["cache/dart-sdk/bin/dart", "../cache/dart-sdk/bin/dart"]) {
371 const c = path.join(dir, p);
372 if (fs.existsSync(c)) { dart = c; break; }
373 }
374 }
375 }
376 } catch {}
377 }
378 if (!dart) return undefined;
379 return { process: spawn(dart, ["language-server", "--protocol=lsp"], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
380 },
381 },
382 {
383 id: "typescript", extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
384 findRoot: (f, cwd) => {
385 if (findNearestFile(path.dirname(f), ["deno.json", "deno.jsonc"], cwd)) return undefined;
386 return findRoot(f, cwd, ["package.json", "tsconfig.json", "jsconfig.json"]);
387 },
388 spawn: async (root) => {
389 const local = path.join(root, "node_modules/.bin/typescript-language-server");
390 const cmd = fs.existsSync(local) ? local : which("typescript-language-server");
391 if (!cmd) return undefined;
392 return { process: spawn(cmd, ["--stdio"], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
393 },
394 },
395 { id: "vue", extensions: [".vue"], findRoot: (f, cwd) => findRoot(f, cwd, ["package.json", "vite.config.ts", "vite.config.js"]), spawn: simpleSpawn("vue-language-server") },
396 { id: "svelte", extensions: [".svelte"], findRoot: (f, cwd) => findRoot(f, cwd, ["package.json", "svelte.config.js"]), spawn: simpleSpawn("svelteserver") },
397 { id: "pyright", extensions: [".py", ".pyi"], findRoot: (f, cwd) => findRoot(f, cwd, ["pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"]), spawn: simpleSpawn("pyright-langserver") },
398 { id: "gopls", extensions: [".go"], findRoot: (f, cwd) => findRoot(f, cwd, ["go.work"]) || findRoot(f, cwd, ["go.mod"]), spawn: simpleSpawn("gopls", []) },
399 {
400 id: "kotlin", extensions: [".kt", ".kts"],
401 findRoot: (f, cwd) => findRootKotlin(f, cwd),
402 spawn: async (root) => {
403 const proc = await spawnKotlinLanguageServer(root);
404 if (!proc) return undefined;
405 return { process: proc };
406 },
407 },
408 {
409 id: "swift", extensions: [".swift"],
410 findRoot: (f, cwd) => findRootSwift(f, cwd),
411 spawn: async (root) => {
412 const proc = await spawnSourcekitLsp(root);
413 if (!proc) return undefined;
414 return { process: proc };
415 },
416 },
417 { id: "rust-analyzer", extensions: [".rs"], findRoot: (f, cwd) => findRoot(f, cwd, ["Cargo.toml"]), spawn: simpleSpawn("rust-analyzer", []) },
418 {
419 id: "nil", extensions: [".nix"],
420 findRoot: (f, cwd) => findRoot(f, cwd, ["flake.nix", "default.nix", "shell.nix"]),
421 spawn: async (root) => {
422 // Prefer nil, fall back to nixd
423 const nil = which("nil");
424 if (nil) return { process: spawn(nil, [], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
425 const nixd = which("nixd");
426 if (nixd) return { process: spawn(nixd, [], { cwd: root, stdio: ["pipe", "pipe", "pipe"] }) };
427 return undefined;
428 },
429 },
430];
431
432// Singleton Manager
433let sharedManager: LSPManager | null = null;
434let managerCwd: string | null = null;
435
436export function getOrCreateManager(cwd: string): LSPManager {
437 if (!sharedManager || managerCwd !== cwd) {
438 sharedManager?.shutdown().catch(() => {});
439 sharedManager = new LSPManager(cwd);
440 managerCwd = cwd;
441 }
442 return sharedManager;
443}
444
445export function getManager(): LSPManager | null { return sharedManager; }
446
447export async function shutdownManager(): Promise<void> {
448 const manager = sharedManager;
449 if (!manager) return;
450
451 // Clear singleton pointers first so new requests never receive a manager
452 // that's currently being shut down.
453 sharedManager = null;
454 managerCwd = null;
455
456 await manager.shutdown();
457}
458
459// LSP Manager
460export class LSPManager {
461 private clients = new Map<string, LSPClient>();
462 private spawning = new Map<string, Promise<LSPClient | undefined>>();
463 private broken = new Set<string>();
464 private cwd: string;
465 private cleanupTimer: NodeJS.Timeout | null = null;
466
467 constructor(cwd: string) {
468 this.cwd = cwd;
469 this.cleanupTimer = setInterval(() => this.cleanupIdleFiles(), CLEANUP_INTERVAL_MS);
470 this.cleanupTimer.unref();
471 }
472
473 private cleanupIdleFiles() {
474 const now = Date.now();
475 for (const client of this.clients.values()) {
476 for (const [fp, state] of client.openFiles) {
477 if (now - state.lastAccess > IDLE_TIMEOUT_MS) this.closeFile(client, fp);
478 }
479 }
480 }
481
482 private closeFile(client: LSPClient, absPath: string) {
483 if (!client.openFiles.has(absPath)) return;
484 client.openFiles.delete(absPath);
485 if (client.closed) return;
486 try {
487 void client.connection.sendNotification(DidCloseTextDocumentNotification.type, {
488 textDocument: { uri: pathToFileURL(absPath).href },
489 }).catch(() => {});
490 } catch {}
491 }
492
493 private evictLRU(client: LSPClient) {
494 if (client.openFiles.size <= MAX_OPEN_FILES) return;
495 let oldest: { path: string; time: number } | null = null;
496 for (const [fp, s] of client.openFiles) {
497 if (!oldest || s.lastAccess < oldest.time) oldest = { path: fp, time: s.lastAccess };
498 }
499 if (oldest) this.closeFile(client, oldest.path);
500 }
501
502 private key(id: string, root: string) { return `${id}:${root}`; }
503
504 private async initClient(config: LSPServerConfig, root: string): Promise<LSPClient | undefined> {
505 const k = this.key(config.id, root);
506 try {
507 const handle = await config.spawn(root);
508 if (!handle) { this.broken.add(k); return undefined; }
509
510 const reader = new StreamMessageReader(handle.process.stdout!);
511 const writer = new StreamMessageWriter(handle.process.stdin!);
512 const conn = createMessageConnection(reader, writer);
513
514 // Prevent crashes from stream errors
515 handle.process.stdin?.on("error", () => {});
516 handle.process.stdout?.on("error", () => {});
517
518 const stderr: string[] = [];
519 const MAX_STDERR_LINES = 200;
520 handle.process.stderr?.on("data", (chunk: Buffer) => {
521 try {
522 const text = chunk.toString("utf-8");
523 for (const line of text.split(/\r?\n/)) {
524 if (!line.trim()) continue;
525 stderr.push(line);
526 if (stderr.length > MAX_STDERR_LINES) stderr.splice(0, stderr.length - MAX_STDERR_LINES);
527 }
528 } catch {
529 // ignore
530 }
531 });
532 handle.process.stderr?.on("error", () => {});
533
534 const client: LSPClient = {
535 connection: conn,
536 process: handle.process,
537 diagnostics: new Map(),
538 openFiles: new Map(),
539 listeners: new Map(),
540 stderr,
541 root,
542 closed: false,
543 };
544
545 conn.onNotification("textDocument/publishDiagnostics", (params: { uri: string; diagnostics: Diagnostic[] }) => {
546 const fpRaw = decodeURIComponent(new URL(params.uri).pathname);
547 const fp = normalizeFsPath(fpRaw);
548
549 client.diagnostics.set(fp, params.diagnostics);
550 // Notify both raw and normalized paths (macOS often reports /private/var vs /var)
551 const listeners1 = client.listeners.get(fp);
552 const listeners2 = fp !== fpRaw ? client.listeners.get(fpRaw) : undefined;
553
554 listeners1?.slice().forEach(fn => { try { fn(); } catch { /* listener error */ } });
555 listeners2?.slice().forEach(fn => { try { fn(); } catch { /* listener error */ } });
556 });
557
558 // Handle errors to prevent crashes
559 conn.onError(() => {});
560 conn.onClose(() => { client.closed = true; this.clients.delete(k); });
561
562 conn.onRequest("workspace/configuration", () => [handle.initOptions ?? {}]);
563 conn.onRequest("window/workDoneProgress/create", () => null);
564 conn.onRequest("client/registerCapability", () => {});
565 conn.onRequest("client/unregisterCapability", () => {});
566 conn.onRequest("workspace/workspaceFolders", () => [{ name: "workspace", uri: pathToFileURL(root).href }]);
567
568 handle.process.on("exit", () => { client.closed = true; this.clients.delete(k); });
569 handle.process.on("error", () => { client.closed = true; this.clients.delete(k); this.broken.add(k); });
570
571 conn.listen();
572
573 const initResult = await timeout(conn.sendRequest(InitializeRequest.method, {
574 rootUri: pathToFileURL(root).href,
575 rootPath: root,
576 processId: process.pid,
577 workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }],
578 initializationOptions: handle.initOptions ?? {},
579 capabilities: {
580 window: { workDoneProgress: true },
581 workspace: { configuration: true },
582 textDocument: {
583 synchronization: { didSave: true, didOpen: true, didChange: true, didClose: true },
584 publishDiagnostics: { versionSupport: true },
585 diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false },
586 },
587 },
588 }), INIT_TIMEOUT_MS, `${config.id} init`);
589
590 client.capabilities = (initResult as any)?.capabilities;
591
592 conn.sendNotification(InitializedNotification.type, {});
593 if (handle.initOptions) {
594 conn.sendNotification("workspace/didChangeConfiguration", { settings: handle.initOptions });
595 }
596 return client;
597 } catch { this.broken.add(k); return undefined; }
598 }
599
600 async getClientsForFile(filePath: string): Promise<LSPClient[]> {
601 const ext = path.extname(filePath);
602 const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(this.cwd, filePath);
603 const clients: LSPClient[] = [];
604
605 for (const config of LSP_SERVERS) {
606 if (!config.extensions.includes(ext)) continue;
607 const root = config.findRoot(absPath, this.cwd);
608 if (!root) continue;
609 const k = this.key(config.id, root);
610 if (this.broken.has(k)) continue;
611
612 const existing = this.clients.get(k);
613 if (existing) { clients.push(existing); continue; }
614
615 if (!this.spawning.has(k)) {
616 const p = this.initClient(config, root);
617 this.spawning.set(k, p);
618 p.finally(() => this.spawning.delete(k));
619 }
620 const client = await this.spawning.get(k);
621 if (client) { this.clients.set(k, client); clients.push(client); }
622 }
623 return clients;
624 }
625
626 private resolve(fp: string) {
627 const abs = path.isAbsolute(fp) ? fp : path.resolve(this.cwd, fp);
628 return normalizeFsPath(abs);
629 }
630 private langId(fp: string) { return LANGUAGE_IDS[path.extname(fp)] || "plaintext"; }
631 private readFile(fp: string): string | null { try { return fs.readFileSync(fp, "utf-8"); } catch { return null; } }
632
633 private explainNoLsp(absPath: string): string {
634 const ext = path.extname(absPath);
635
636 if (ext === ".kt" || ext === ".kts") {
637 const root = findRootKotlin(absPath, this.cwd);
638 if (!root) return `No Kotlin project root detected (looked for settings.gradle(.kts), build.gradle(.kts), gradlew, pom.xml under cwd)`;
639
640 const hasJetbrains = !!(which("kotlin-lsp") || which("kotlin-lsp.sh") || which("kotlin-lsp.cmd") || process.env.PI_LSP_KOTLIN_LSP_PATH);
641 const hasKls = !!which("kotlin-language-server");
642
643 if (!hasJetbrains && !hasKls) {
644 return "No Kotlin LSP binary found. Install Kotlin/kotlin-lsp (recommended) or org.javacs/kotlin-language-server.";
645 }
646
647 const k = this.key("kotlin", root);
648 if (this.broken.has(k)) return `Kotlin LSP failed to initialize for root: ${root}`;
649
650 if (!hasJetbrains && hasKls) {
651 return "Kotlin LSP is running via kotlin-language-server, but that server often does not produce diagnostics for Gradle/Android projects. Prefer Kotlin/kotlin-lsp.";
652 }
653
654 return `Kotlin LSP unavailable for root: ${root}`;
655 }
656
657 if (ext === ".swift") {
658 const root = findRootSwift(absPath, this.cwd);
659 if (!root) return `No Swift project root detected (looked for Package.swift, *.xcodeproj, *.xcworkspace under cwd)`;
660 if (!which("sourcekit-lsp") && !which("xcrun")) return "sourcekit-lsp not found (and xcrun missing)";
661 const k = this.key("swift", root);
662 if (this.broken.has(k)) return `sourcekit-lsp failed to initialize for root: ${root}`;
663 return `Swift LSP unavailable for root: ${root}`;
664 }
665
666 return `No LSP for ${ext}`;
667 }
668
669 private toPos(line: number, col: number) { return { line: Math.max(0, line - 1), character: Math.max(0, col - 1) }; }
670
671 private normalizeLocs(result: Location | Location[] | LocationLink[] | null | undefined): Location[] {
672 if (!result) return [];
673 const items = Array.isArray(result) ? result : [result];
674 if (!items.length) return [];
675 if ("uri" in items[0] && "range" in items[0]) return items as Location[];
676 return (items as LocationLink[]).map(l => ({ uri: l.targetUri, range: l.targetSelectionRange ?? l.targetRange }));
677 }
678
679 private normalizeSymbols(result: DocumentSymbol[] | SymbolInformation[] | null | undefined): DocumentSymbol[] {
680 if (!result?.length) return [];
681 const first = result[0];
682 if ("location" in first) {
683 return (result as SymbolInformation[]).map(s => ({
684 name: s.name, kind: s.kind, range: s.location.range, selectionRange: s.location.range,
685 detail: s.containerName, tags: s.tags, deprecated: s.deprecated, children: [],
686 }));
687 }
688 return result as DocumentSymbol[];
689 }
690
691 private async openOrUpdate(clients: LSPClient[], absPath: string, uri: string, langId: string, content: string, evict = true) {
692 const now = Date.now();
693 for (const client of clients) {
694 if (client.closed) continue;
695 const state = client.openFiles.get(absPath);
696 try {
697 if (state) {
698 const v = state.version + 1;
699 client.openFiles.set(absPath, { version: v, lastAccess: now });
700 void client.connection.sendNotification(DidChangeTextDocumentNotification.type, {
701 textDocument: { uri, version: v }, contentChanges: [{ text: content }],
702 }).catch(() => {});
703 } else {
704 // For some servers (e.g. kotlin-language-server), diagnostics only start flowing after a didChange.
705 // We open at version 0, then immediately send a full-content didChange at version 1.
706 client.openFiles.set(absPath, { version: 1, lastAccess: now });
707 void client.connection.sendNotification(DidOpenTextDocumentNotification.type, {
708 textDocument: { uri, languageId: langId, version: 0, text: content },
709 }).catch(() => {});
710 void client.connection.sendNotification(DidChangeTextDocumentNotification.type, {
711 textDocument: { uri, version: 1 }, contentChanges: [{ text: content }],
712 }).catch(() => {});
713 if (evict) this.evictLRU(client);
714 }
715 // Send didSave to trigger analysis (important for TypeScript)
716 void client.connection.sendNotification(DidSaveTextDocumentNotification.type, {
717 textDocument: { uri }, text: content,
718 }).catch(() => {});
719 } catch {}
720 }
721 }
722
723 private async loadFile(filePath: string) {
724 const absPath = this.resolve(filePath);
725 const clients = await this.getClientsForFile(absPath);
726 if (!clients.length) return null;
727 const content = this.readFile(absPath);
728 if (content === null) return null;
729 return { clients, absPath, uri: pathToFileURL(absPath).href, langId: this.langId(absPath), content };
730 }
731
732 private waitForDiagnostics(client: LSPClient, absPath: string, timeoutMs: number, isNew: boolean): Promise<boolean> {
733 return new Promise(resolve => {
734 if (client.closed) return resolve(false);
735
736 let resolved = false;
737 let settleTimer: NodeJS.Timeout | null = null;
738 let listener: () => void = () => {};
739
740 const cleanupListener = () => {
741 const listeners = client.listeners.get(absPath);
742 if (!listeners) return;
743 const idx = listeners.indexOf(listener);
744 if (idx !== -1) listeners.splice(idx, 1);
745 if (listeners.length === 0) client.listeners.delete(absPath);
746 };
747
748 const finish = (value: boolean) => {
749 if (resolved) return;
750 resolved = true;
751 if (settleTimer) clearTimeout(settleTimer);
752 clearTimeout(timer);
753 cleanupListener();
754 resolve(value);
755 };
756
757 // Some servers publish diagnostics multiple times (often empty first, then real results).
758 // For new documents, if diagnostics are still empty, debounce a bit.
759 listener = () => {
760 if (resolved) return;
761
762 const current = client.diagnostics.get(absPath);
763 if (current && current.length > 0) return finish(true);
764
765 if (!isNew) return finish(true);
766
767 if (settleTimer) clearTimeout(settleTimer);
768 settleTimer = setTimeout(() => finish(true), 2500);
769 (settleTimer as any).unref?.();
770 };
771
772 const timer = setTimeout(() => finish(false), timeoutMs);
773 (timer as any).unref?.();
774
775 const listeners = client.listeners.get(absPath) || [];
776 listeners.push(listener);
777 client.listeners.set(absPath, listeners);
778 });
779 }
780
781 private async pullDiagnostics(client: LSPClient, absPath: string, uri: string): Promise<{ diagnostics: Diagnostic[]; responded: boolean }> {
782 if (client.closed) return { diagnostics: [], responded: false };
783
784 // Only attempt Pull Diagnostics if the server advertises support.
785 // (Some servers throw and log noisy errors if we call these methods.)
786 if (!client.capabilities || !(client.capabilities as any).diagnosticProvider) {
787 return { diagnostics: [], responded: false };
788 }
789
790 // Prefer new Pull Diagnostics if supported by the server
791 try {
792 const res: any = await client.connection.sendRequest(DocumentDiagnosticRequest.method, {
793 textDocument: { uri },
794 });
795
796 if (res?.kind === DocumentDiagnosticReportKind.Full) {
797 return { diagnostics: Array.isArray(res.items) ? res.items : [], responded: true };
798 }
799 if (res?.kind === DocumentDiagnosticReportKind.Unchanged) {
800 return { diagnostics: client.diagnostics.get(absPath) || [], responded: true };
801 }
802 if (Array.isArray(res?.items)) {
803 return { diagnostics: res.items, responded: true };
804 }
805 return { diagnostics: [], responded: true };
806 } catch {
807 // ignore
808 }
809
810 // Fallback: some servers only support WorkspaceDiagnosticRequest
811 try {
812 const res: any = await client.connection.sendRequest(WorkspaceDiagnosticRequest.method, {
813 previousResultIds: [],
814 });
815
816 const items: any[] = res?.items || [];
817 const match = items.find((it: any) => it?.uri === uri);
818 if (match?.kind === DocumentDiagnosticReportKind.Full) {
819 return { diagnostics: Array.isArray(match.items) ? match.items : [], responded: true };
820 }
821 if (Array.isArray(match?.items)) {
822 return { diagnostics: match.items, responded: true };
823 }
824 return { diagnostics: [], responded: true };
825 } catch {
826 return { diagnostics: [], responded: false };
827 }
828 }
829
830 async touchFileAndWait(filePath: string, timeoutMs: number): Promise<{ diagnostics: Diagnostic[]; receivedResponse: boolean; unsupported?: boolean; error?: string }> {
831 const absPath = this.resolve(filePath);
832
833 if (!fs.existsSync(absPath)) {
834 return { diagnostics: [], receivedResponse: false, unsupported: true, error: "File not found" };
835 }
836
837 const clients = await this.getClientsForFile(absPath);
838 if (!clients.length) {
839 return { diagnostics: [], receivedResponse: false, unsupported: true, error: this.explainNoLsp(absPath) };
840 }
841
842 const content = this.readFile(absPath);
843 if (content === null) {
844 return { diagnostics: [], receivedResponse: false, unsupported: true, error: "Could not read file" };
845 }
846
847 const uri = pathToFileURL(absPath).href;
848 const langId = this.langId(absPath);
849 const isNew = clients.some(c => !c.openFiles.has(absPath));
850
851 const waits = clients.map(c => this.waitForDiagnostics(c, absPath, timeoutMs, isNew));
852 await this.openOrUpdate(clients, absPath, uri, langId, content);
853 const results = await Promise.all(waits);
854
855 let responded = results.some(r => r);
856 const diags: Diagnostic[] = [];
857 for (const c of clients) {
858 const d = c.diagnostics.get(absPath);
859 if (d) diags.push(...d);
860 }
861 if (!responded && clients.some(c => c.diagnostics.has(absPath))) responded = true;
862
863 // If we didn't get pushed diagnostics (common for some servers), try pull diagnostics.
864 if (!responded || diags.length === 0) {
865 const pulled = await Promise.all(clients.map(c => this.pullDiagnostics(c, absPath, uri)));
866 for (let i = 0; i < clients.length; i++) {
867 const r = pulled[i];
868 if (r.responded) responded = true;
869 if (r.diagnostics.length) {
870 clients[i].diagnostics.set(absPath, r.diagnostics);
871 diags.push(...r.diagnostics);
872 }
873 }
874 }
875
876 return { diagnostics: diags, receivedResponse: responded };
877 }
878
879 async getDiagnosticsForFiles(files: string[], timeoutMs: number): Promise<FileDiagnosticsResult> {
880 const unique = [...new Set(files.map(f => this.resolve(f)))];
881 const results: FileDiagnosticItem[] = [];
882 const toClose: Map<LSPClient, string[]> = new Map();
883
884 for (const absPath of unique) {
885 if (!fs.existsSync(absPath)) {
886 results.push({ file: absPath, diagnostics: [], status: 'error', error: 'File not found' });
887 continue;
888 }
889
890 let clients: LSPClient[];
891 try { clients = await this.getClientsForFile(absPath); }
892 catch (e) { results.push({ file: absPath, diagnostics: [], status: 'error', error: String(e) }); continue; }
893
894 if (!clients.length) {
895 results.push({ file: absPath, diagnostics: [], status: 'unsupported', error: this.explainNoLsp(absPath) });
896 continue;
897 }
898
899 const content = this.readFile(absPath);
900 if (!content) {
901 results.push({ file: absPath, diagnostics: [], status: 'error', error: 'Could not read file' });
902 continue;
903 }
904
905 const uri = pathToFileURL(absPath).href;
906 const langId = this.langId(absPath);
907 const isNew = clients.some(c => !c.openFiles.has(absPath));
908
909 for (const c of clients) {
910 if (!c.openFiles.has(absPath)) {
911 if (!toClose.has(c)) toClose.set(c, []);
912 toClose.get(c)!.push(absPath);
913 }
914 }
915
916 const waits = clients.map(c => this.waitForDiagnostics(c, absPath, timeoutMs, isNew));
917 await this.openOrUpdate(clients, absPath, uri, langId, content, false);
918 const waitResults = await Promise.all(waits);
919
920 const diags: Diagnostic[] = [];
921 for (const c of clients) { const d = c.diagnostics.get(absPath); if (d) diags.push(...d); }
922
923 let responded = waitResults.some(r => r) || diags.length > 0;
924
925 if (!responded || diags.length === 0) {
926 const pulled = await Promise.all(clients.map(c => this.pullDiagnostics(c, absPath, uri)));
927 for (let i = 0; i < clients.length; i++) {
928 const r = pulled[i];
929 if (r.responded) responded = true;
930 if (r.diagnostics.length) {
931 clients[i].diagnostics.set(absPath, r.diagnostics);
932 diags.push(...r.diagnostics);
933 }
934 }
935 }
936
937 if (!responded && !diags.length) {
938 results.push({ file: absPath, diagnostics: [], status: 'timeout', error: 'LSP did not respond' });
939 } else {
940 results.push({ file: absPath, diagnostics: diags, status: 'ok' });
941 }
942 }
943
944 // Cleanup opened files
945 for (const [c, fps] of toClose) { for (const fp of fps) this.closeFile(c, fp); }
946 for (const c of this.clients.values()) { while (c.openFiles.size > MAX_OPEN_FILES) this.evictLRU(c); }
947
948 return { items: results };
949 }
950
951 async getDefinition(fp: string, line: number, col: number): Promise<Location[]> {
952 const l = await this.loadFile(fp);
953 if (!l) return [];
954 await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
955 const pos = this.toPos(line, col);
956 const results = await Promise.all(l.clients.map(async c => {
957 if (c.closed) return [];
958 try { return this.normalizeLocs(await c.connection.sendRequest(DefinitionRequest.type, { textDocument: { uri: l.uri }, position: pos })); }
959 catch { return []; }
960 }));
961 return results.flat();
962 }
963
964 async getReferences(fp: string, line: number, col: number): Promise<Location[]> {
965 const l = await this.loadFile(fp);
966 if (!l) return [];
967 await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
968 const pos = this.toPos(line, col);
969 const results = await Promise.all(l.clients.map(async c => {
970 if (c.closed) return [];
971 try { return this.normalizeLocs(await c.connection.sendRequest(ReferencesRequest.type, { textDocument: { uri: l.uri }, position: pos, context: { includeDeclaration: true } })); }
972 catch { return []; }
973 }));
974 return results.flat();
975 }
976
977 async getHover(fp: string, line: number, col: number): Promise<Hover | null> {
978 const l = await this.loadFile(fp);
979 if (!l) return null;
980 await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
981 const pos = this.toPos(line, col);
982 for (const c of l.clients) {
983 if (c.closed) continue;
984 try { const r = await c.connection.sendRequest(HoverRequest.type, { textDocument: { uri: l.uri }, position: pos }); if (r) return r; }
985 catch {}
986 }
987 return null;
988 }
989
990 async getSignatureHelp(fp: string, line: number, col: number): Promise<SignatureHelp | null> {
991 const l = await this.loadFile(fp);
992 if (!l) return null;
993 await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
994 const pos = this.toPos(line, col);
995 for (const c of l.clients) {
996 if (c.closed) continue;
997 try { const r = await c.connection.sendRequest(SignatureHelpRequest.type, { textDocument: { uri: l.uri }, position: pos }); if (r) return r; }
998 catch {}
999 }
1000 return null;
1001 }
1002
1003 async getDocumentSymbols(fp: string): Promise<DocumentSymbol[]> {
1004 const l = await this.loadFile(fp);
1005 if (!l) return [];
1006 await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
1007 const results = await Promise.all(l.clients.map(async c => {
1008 if (c.closed) return [];
1009 try { return this.normalizeSymbols(await c.connection.sendRequest(DocumentSymbolRequest.type, { textDocument: { uri: l.uri } })); }
1010 catch { return []; }
1011 }));
1012 return results.flat();
1013 }
1014
1015 async rename(fp: string, line: number, col: number, newName: string): Promise<WorkspaceEdit | null> {
1016 const l = await this.loadFile(fp);
1017 if (!l) return null;
1018 await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
1019 const pos = this.toPos(line, col);
1020 for (const c of l.clients) {
1021 if (c.closed) continue;
1022 try {
1023 const r = await c.connection.sendRequest(RenameRequest.type, {
1024 textDocument: { uri: l.uri },
1025 position: pos,
1026 newName,
1027 });
1028 if (r) return r;
1029 } catch {}
1030 }
1031 return null;
1032 }
1033
1034 async getCodeActions(fp: string, startLine: number, startCol: number, endLine?: number, endCol?: number): Promise<(CodeAction | Command)[]> {
1035 const l = await this.loadFile(fp);
1036 if (!l) return [];
1037 await this.openOrUpdate(l.clients, l.absPath, l.uri, l.langId, l.content);
1038
1039 const start = this.toPos(startLine, startCol);
1040 const end = this.toPos(endLine ?? startLine, endCol ?? startCol);
1041 const range = { start, end };
1042
1043 // Get diagnostics for this range to include in context
1044 const diagnostics: Diagnostic[] = [];
1045 for (const c of l.clients) {
1046 const fileDiags = c.diagnostics.get(l.absPath) || [];
1047 for (const d of fileDiags) {
1048 if (this.rangesOverlap(d.range, range)) diagnostics.push(d);
1049 }
1050 }
1051
1052 const results = await Promise.all(l.clients.map(async c => {
1053 if (c.closed) return [];
1054 try {
1055 const r = await c.connection.sendRequest(CodeActionRequest.type, {
1056 textDocument: { uri: l.uri },
1057 range,
1058 context: { diagnostics, only: [CodeActionKind.QuickFix, CodeActionKind.Refactor, CodeActionKind.Source] },
1059 });
1060 return r || [];
1061 } catch { return []; }
1062 }));
1063 return results.flat();
1064 }
1065
1066 private rangesOverlap(a: { start: { line: number; character: number }; end: { line: number; character: number } },
1067 b: { start: { line: number; character: number }; end: { line: number; character: number } }): boolean {
1068 if (a.end.line < b.start.line || b.end.line < a.start.line) return false;
1069 if (a.end.line === b.start.line && a.end.character < b.start.character) return false;
1070 if (b.end.line === a.start.line && b.end.character < a.start.character) return false;
1071 return true;
1072 }
1073
1074 async shutdown() {
1075 if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; }
1076 const clients = Array.from(this.clients.values());
1077 this.clients.clear();
1078 for (const c of clients) {
1079 const wasClosed = c.closed;
1080 c.closed = true;
1081 if (!wasClosed) {
1082 try {
1083 await Promise.race([
1084 c.connection.sendRequest("shutdown"),
1085 new Promise(r => setTimeout(r, 1000))
1086 ]);
1087 } catch {}
1088 try { void c.connection.sendNotification("exit").catch(() => {}); } catch {}
1089 }
1090 try { c.connection.end(); } catch {}
1091 try { c.process.kill(); } catch {}
1092 }
1093 }
1094}
1095
1096// Diagnostic Formatting
1097export { DiagnosticSeverity };
1098export type SeverityFilter = "all" | "error" | "warning" | "info" | "hint";
1099
1100export function formatDiagnostic(d: Diagnostic): string {
1101 const sev = ["", "ERROR", "WARN", "INFO", "HINT"][d.severity || 1];
1102 return `${sev} [${d.range.start.line + 1}:${d.range.start.character + 1}] ${d.message}`;
1103}
1104
1105export function filterDiagnosticsBySeverity(diags: Diagnostic[], filter: SeverityFilter): Diagnostic[] {
1106 if (filter === "all") return diags;
1107 const max = { error: 1, warning: 2, info: 3, hint: 4 }[filter];
1108 return diags.filter(d => (d.severity || 1) <= max);
1109}
1110
1111// URI utilities
1112export function uriToPath(uri: string): string {
1113 if (uri.startsWith("file://")) try { return fileURLToPath(uri); } catch {}
1114 return uri;
1115}
1116
1117// Symbol search
1118export function findSymbolPosition(symbols: DocumentSymbol[], query: string): { line: number; character: number } | null {
1119 const q = query.toLowerCase();
1120 let exact: { line: number; character: number } | null = null;
1121 let partial: { line: number; character: number } | null = null;
1122
1123 const visit = (items: DocumentSymbol[]) => {
1124 for (const sym of items) {
1125 const name = String(sym?.name ?? "").toLowerCase();
1126 const pos = sym?.selectionRange?.start ?? sym?.range?.start;
1127 if (pos && typeof pos.line === "number" && typeof pos.character === "number") {
1128 if (!exact && name === q) exact = pos;
1129 if (!partial && name.includes(q)) partial = pos;
1130 }
1131 if (sym?.children?.length) visit(sym.children);
1132 }
1133 };
1134 visit(symbols);
1135 return exact ?? partial;
1136}
1137
1138export async function resolvePosition(manager: LSPManager, file: string, query: string): Promise<{ line: number; column: number } | null> {
1139 const symbols = await manager.getDocumentSymbols(file);
1140 const pos = findSymbolPosition(symbols, query);
1141 return pos ? { line: pos.line + 1, column: pos.character + 1 } : null;
1142}