lsp-pi 1.0.1

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