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/README.md +178 -0
- package/lsp-core.ts +1125 -0
- package/lsp-tool.ts +339 -0
- package/lsp.ts +575 -0
- package/package.json +46 -0
- package/tests/index.test.ts +235 -0
- package/tests/lsp-integration.test.ts +602 -0
- package/tests/lsp.test.ts +898 -0
- package/tsconfig.json +13 -0
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
|
+
}
|