pi-lens 3.8.21 → 3.8.23
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/CHANGELOG.md +28 -0
- package/README.md +2 -0
- package/clients/dispatch/dispatcher.ts +75 -91
- package/clients/dispatch/fact-provider-types.ts +22 -0
- package/clients/dispatch/fact-rule-runner.ts +22 -0
- package/clients/dispatch/fact-runner.ts +28 -0
- package/clients/dispatch/fact-scheduler.ts +78 -0
- package/clients/dispatch/fact-store.ts +67 -0
- package/clients/dispatch/facts/comment-facts.ts +59 -0
- package/clients/dispatch/facts/file-content.ts +20 -0
- package/clients/dispatch/facts/function-facts.ts +177 -0
- package/clients/dispatch/facts/try-catch-facts.ts +80 -0
- package/clients/dispatch/integration.ts +130 -24
- package/clients/dispatch/priorities.ts +22 -0
- package/clients/dispatch/rules/async-noise.ts +43 -0
- package/clients/dispatch/rules/error-obscuring.ts +40 -0
- package/clients/dispatch/rules/error-swallowing.ts +35 -0
- package/clients/dispatch/rules/pass-through-wrappers.ts +52 -0
- package/clients/dispatch/rules/placeholder-comments.ts +47 -0
- package/clients/dispatch/runners/architect.ts +2 -1
- package/clients/dispatch/runners/ast-grep-napi.ts +2 -1
- package/clients/dispatch/runners/biome-check.ts +40 -8
- package/clients/dispatch/runners/biome.ts +2 -1
- package/clients/dispatch/runners/eslint.ts +34 -6
- package/clients/dispatch/runners/go-vet.ts +2 -1
- package/clients/dispatch/runners/golangci-lint.ts +2 -1
- package/clients/dispatch/runners/index.ts +29 -27
- package/clients/dispatch/runners/lsp.ts +60 -4
- package/clients/dispatch/runners/oxlint.ts +2 -1
- package/clients/dispatch/runners/pyright.ts +2 -1
- package/clients/dispatch/runners/python-slop.ts +2 -1
- package/clients/dispatch/runners/rubocop.ts +2 -1
- package/clients/dispatch/runners/ruff.ts +2 -1
- package/clients/dispatch/runners/rust-clippy.ts +2 -1
- package/clients/dispatch/runners/shellcheck.ts +2 -1
- package/clients/dispatch/runners/similarity.ts +2 -1
- package/clients/dispatch/runners/spellcheck.ts +2 -1
- package/clients/dispatch/runners/sqlfluff.ts +2 -1
- package/clients/dispatch/runners/tree-sitter.ts +469 -1
- package/clients/dispatch/runners/ts-lsp.ts +2 -1
- package/clients/dispatch/runners/type-safety.ts +2 -1
- package/clients/dispatch/runners/yamllint.ts +2 -1
- package/clients/dispatch/tool-profile.ts +40 -0
- package/clients/dispatch/types.ts +3 -13
- package/clients/lsp/client.ts +366 -12
- package/clients/lsp/index.ts +374 -76
- package/clients/lsp/launch.ts +42 -2
- package/clients/lsp/server.ts +186 -12
- package/clients/pipeline.ts +2 -2
- package/clients/runtime-context.ts +2 -2
- package/clients/runtime-session.ts +43 -5
- package/clients/session-summary.ts +21 -0
- package/clients/tree-sitter-client.ts +162 -0
- package/clients/tree-sitter-logger.ts +47 -0
- package/clients/tree-sitter-query-loader.ts +13 -2
- package/index.ts +67 -17
- package/package.json +3 -1
- package/rules/rule-catalog.json +64 -0
- package/rules/tree-sitter-queries/go/go-bare-error.yml +19 -7
- package/rules/tree-sitter-queries/go/go-command-injection.yml +55 -0
- package/rules/tree-sitter-queries/go/go-direct-panic.yml +45 -0
- package/rules/tree-sitter-queries/go/go-empty-if-err.yml +47 -0
- package/rules/tree-sitter-queries/go/go-goroutine-loop-capture.yml +49 -0
- package/rules/tree-sitter-queries/go/go-ignored-call-result.yml +51 -0
- package/rules/tree-sitter-queries/go/go-insecure-random.yml +51 -0
- package/rules/tree-sitter-queries/go/go-log-fatal.yml +49 -0
- package/rules/tree-sitter-queries/go/go-path-traversal.yml +51 -0
- package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +54 -0
- package/rules/tree-sitter-queries/go/go-sql-injection.yml +55 -0
- package/rules/tree-sitter-queries/go/go-weak-hash.yml +51 -0
- package/rules/tree-sitter-queries/python/python-command-injection.yml +63 -0
- package/rules/tree-sitter-queries/python/python-insecure-deserialization.yml +48 -0
- package/rules/tree-sitter-queries/python/python-insecure-random.yml +51 -0
- package/rules/tree-sitter-queries/python/python-path-traversal.yml +55 -0
- package/rules/tree-sitter-queries/python/python-sql-injection.yml +47 -0
- package/rules/tree-sitter-queries/python/python-ssrf.yml +50 -0
- package/rules/tree-sitter-queries/python/python-thread-global-write.yml +58 -0
- package/rules/tree-sitter-queries/python/python-weak-hash.yml +51 -0
- package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +56 -0
- package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +47 -0
- package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +54 -0
- package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +50 -0
- package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +59 -0
- package/rules/tree-sitter-queries/typescript/ts-command-injection.yml +60 -0
- package/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml +56 -0
- package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +54 -0
- package/rules/tree-sitter-queries/typescript/ts-ssrf.yml +53 -0
- package/rules/tree-sitter-queries/typescript/ts-weak-hash.yml +54 -0
- package/scripts/validate-rule-catalog.mjs +227 -0
- package/skills/lsp-navigation/SKILL.md +15 -3
- package/tools/lsp-navigation.js +466 -79
- package/tools/lsp-navigation.ts +587 -85
package/clients/lsp/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { LSPServerInfo } from "./server.js";
|
|
|
19
19
|
import { normalizeMapKey, uriToPath } from "../path-utils.js";
|
|
20
20
|
import { detectFileKind } from "../file-kinds.js";
|
|
21
21
|
import { detectProjectLanguageProfile } from "../language-profile.js";
|
|
22
|
+
import { logLatency } from "../latency-logger.js";
|
|
22
23
|
|
|
23
24
|
// --- Types ---
|
|
24
25
|
|
|
@@ -53,6 +54,9 @@ export interface SpawnedServer {
|
|
|
53
54
|
export class LSPService {
|
|
54
55
|
private state: LSPState;
|
|
55
56
|
private languagePolicyCache = new Map<string, { allowInstall: boolean; expiresAt: number }>();
|
|
57
|
+
private workspaceProbeLogged = new Set<string>();
|
|
58
|
+
private warmStartLogged = new Set<string>();
|
|
59
|
+
private emitConsoleLspErrors = process.env.PI_LENS_CONSOLE_LSP === "1";
|
|
56
60
|
|
|
57
61
|
constructor() {
|
|
58
62
|
this.state = {
|
|
@@ -73,61 +77,113 @@ export class LSPService {
|
|
|
73
77
|
|
|
74
78
|
// Try each matching server
|
|
75
79
|
for (const server of servers) {
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} catch {
|
|
90
|
-
/* ignore dead client shutdown errors */
|
|
91
|
-
}
|
|
92
|
-
this.state.clients.delete(key);
|
|
93
|
-
this.state.broken.delete(key);
|
|
94
|
-
} else {
|
|
95
|
-
return { client: existing, info: server };
|
|
96
|
-
}
|
|
80
|
+
const spawned = await this.ensureClientForServer(filePath, server);
|
|
81
|
+
if (spawned) {
|
|
82
|
+
logLatency({
|
|
83
|
+
type: "phase",
|
|
84
|
+
phase: "lsp_client_selected",
|
|
85
|
+
filePath,
|
|
86
|
+
durationMs: 0,
|
|
87
|
+
metadata: {
|
|
88
|
+
serverId: server.id,
|
|
89
|
+
candidateCount: servers.length,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
return spawned;
|
|
97
93
|
}
|
|
94
|
+
}
|
|
98
95
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
96
|
+
logLatency({
|
|
97
|
+
type: "phase",
|
|
98
|
+
phase: "lsp_client_unavailable",
|
|
99
|
+
filePath,
|
|
100
|
+
durationMs: 0,
|
|
101
|
+
metadata: {
|
|
102
|
+
candidateCount: servers.length,
|
|
103
|
+
servers: servers.map((server) => server.id),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (inFlight) {
|
|
111
|
-
// Wait for the existing spawn to complete
|
|
112
|
-
const result = await inFlight;
|
|
113
|
-
if (result) return result;
|
|
114
|
-
continue; // This server failed, try next
|
|
115
|
-
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
116
109
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Get or create ALL LSP clients that can serve a file.
|
|
112
|
+
* Used for diagnostics aggregation across complementary servers.
|
|
113
|
+
*/
|
|
114
|
+
async getClientsForFile(filePath: string): Promise<SpawnedServer[]> {
|
|
115
|
+
const servers = getServersForFileWithConfig(filePath);
|
|
116
|
+
if (servers.length === 0) return [];
|
|
120
117
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
118
|
+
const spawned = await Promise.all(
|
|
119
|
+
servers.map((server) => this.ensureClientForServer(filePath, server)),
|
|
120
|
+
);
|
|
121
|
+
return spawned.filter((entry): entry is SpawnedServer => Boolean(entry));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async ensureClientForServer(
|
|
125
|
+
filePath: string,
|
|
126
|
+
server: LSPServerInfo,
|
|
127
|
+
): Promise<SpawnedServer | undefined> {
|
|
128
|
+
const root = await server.root(filePath);
|
|
129
|
+
if (!root) return undefined;
|
|
130
|
+
const allowInstall = this.shouldAllowInstall(filePath, root);
|
|
131
|
+
|
|
132
|
+
const normalizedRoot = normalizeMapKey(root);
|
|
133
|
+
const key = `${server.id}:${normalizedRoot}`;
|
|
134
|
+
|
|
135
|
+
const existing = this.state.clients.get(key);
|
|
136
|
+
if (existing) {
|
|
137
|
+
if (!existing.isAlive()) {
|
|
138
|
+
try {
|
|
139
|
+
await existing.shutdown();
|
|
140
|
+
} catch {
|
|
141
|
+
/* ignore dead client shutdown errors */
|
|
142
|
+
}
|
|
143
|
+
this.state.clients.delete(key);
|
|
144
|
+
this.state.broken.delete(key);
|
|
145
|
+
} else {
|
|
146
|
+
if (!this.warmStartLogged.has(key)) {
|
|
147
|
+
logSessionStart(
|
|
148
|
+
`lsp warm-start ${server.id}: reused root=${root} file=${filePath}`,
|
|
149
|
+
);
|
|
150
|
+
this.warmStartLogged.add(key);
|
|
151
|
+
}
|
|
152
|
+
return { client: existing, info: server };
|
|
127
153
|
}
|
|
128
154
|
}
|
|
129
155
|
|
|
130
|
-
|
|
156
|
+
const brokenUntil = this.state.broken.get(key);
|
|
157
|
+
if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
|
|
158
|
+
logLatency({
|
|
159
|
+
type: "phase",
|
|
160
|
+
phase: "lsp_client_skipped_broken",
|
|
161
|
+
filePath,
|
|
162
|
+
durationMs: 0,
|
|
163
|
+
metadata: {
|
|
164
|
+
serverId: server.id,
|
|
165
|
+
retryInMs: Math.max(0, brokenUntil - Date.now()),
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
if (typeof brokenUntil === "number" && brokenUntil <= Date.now()) {
|
|
171
|
+
this.state.broken.delete(key);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const inFlight = this.state.inFlight.get(key);
|
|
175
|
+
if (inFlight) {
|
|
176
|
+
return inFlight;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const spawnPromise = this.spawnClient(server, root, key, filePath, allowInstall);
|
|
180
|
+
this.state.inFlight.set(key, spawnPromise);
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
return await spawnPromise;
|
|
184
|
+
} finally {
|
|
185
|
+
this.state.inFlight.delete(key);
|
|
186
|
+
}
|
|
131
187
|
}
|
|
132
188
|
|
|
133
189
|
private shouldAllowInstall(filePath: string, root: string): boolean {
|
|
@@ -192,31 +248,47 @@ export class LSPService {
|
|
|
192
248
|
root,
|
|
193
249
|
initialization: spawned.initialization,
|
|
194
250
|
});
|
|
251
|
+
const wsDiag =
|
|
252
|
+
typeof client.getWorkspaceDiagnosticsSupport === "function"
|
|
253
|
+
? client.getWorkspaceDiagnosticsSupport()
|
|
254
|
+
: {
|
|
255
|
+
advertised: false,
|
|
256
|
+
mode: "push-only" as const,
|
|
257
|
+
diagnosticProviderKind: "unavailable",
|
|
258
|
+
};
|
|
195
259
|
|
|
196
260
|
this.state.clients.set(key, client);
|
|
197
261
|
logSessionStart(
|
|
198
262
|
`lsp spawn ${server.id}: success source=${spawned.source ?? server.installPolicy ?? "unknown"} (${Date.now() - startedAt}ms)`,
|
|
199
263
|
);
|
|
264
|
+
if (!this.workspaceProbeLogged.has(key)) {
|
|
265
|
+
logSessionStart(
|
|
266
|
+
`lsp workspace-diag probe ${server.id}: advertised=${wsDiag.advertised} mode=${wsDiag.mode} provider=${wsDiag.diagnosticProviderKind}`,
|
|
267
|
+
);
|
|
268
|
+
this.workspaceProbeLogged.add(key);
|
|
269
|
+
}
|
|
200
270
|
return { client, info: server };
|
|
201
271
|
} catch (err) {
|
|
202
272
|
logSessionStart(
|
|
203
273
|
`lsp spawn ${server.id}: failed (${Date.now() - startedAt}ms) error=${err instanceof Error ? err.message : String(err)}`,
|
|
204
274
|
);
|
|
205
275
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
276
|
+
if (this.emitConsoleLspErrors) {
|
|
277
|
+
if (errorMsg.includes("Timeout")) {
|
|
278
|
+
console.error(
|
|
279
|
+
`[lsp] ${server.id} timed out during initialization (${errorMsg}). The server may be downloading or the project is large. Skipping.`,
|
|
280
|
+
);
|
|
281
|
+
} else if (errorMsg.includes("stream was destroyed")) {
|
|
282
|
+
console.error(
|
|
283
|
+
`[lsp] ${server.id} stream was destroyed. The server binary may be missing or crashed immediately. Try reinstalling: npm install -g ${server.id}-language-server`,
|
|
284
|
+
);
|
|
285
|
+
} else if (errorMsg.includes("exited immediately")) {
|
|
286
|
+
console.error(
|
|
287
|
+
`[lsp] ${server.id} ${errorMsg}. Try reinstalling: npm install -g ${server.id}-language-server`,
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
console.error(`[lsp] Failed to spawn ${server.id}:`, err);
|
|
291
|
+
}
|
|
220
292
|
}
|
|
221
293
|
this.state.broken.set(key, Date.now() + BROKEN_RETRY_COOLDOWN_MS);
|
|
222
294
|
return undefined;
|
|
@@ -244,17 +316,155 @@ export class LSPService {
|
|
|
244
316
|
await spawned.client.notify.change(filePath, content);
|
|
245
317
|
}
|
|
246
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Touch a file like OpenCode's LSP flow: ensure document is open/synced,
|
|
321
|
+
* and optionally wait briefly for diagnostics warm-up.
|
|
322
|
+
*/
|
|
323
|
+
async touchFile(
|
|
324
|
+
filePath: string,
|
|
325
|
+
content: string,
|
|
326
|
+
waitForDiagnostics = false,
|
|
327
|
+
source = "unknown",
|
|
328
|
+
useAllClients = false,
|
|
329
|
+
): Promise<void> {
|
|
330
|
+
const startedAt = Date.now();
|
|
331
|
+
const normalizedPath = normalizeMapKey(filePath);
|
|
332
|
+
const spawned = useAllClients
|
|
333
|
+
? await this.getClientsForFile(filePath)
|
|
334
|
+
: await this.getClientForFile(filePath).then((entry) =>
|
|
335
|
+
entry ? [entry] : [],
|
|
336
|
+
);
|
|
337
|
+
if (spawned.length === 0) {
|
|
338
|
+
logLatency({
|
|
339
|
+
type: "phase",
|
|
340
|
+
phase: "lsp_touch_file",
|
|
341
|
+
filePath: normalizedPath,
|
|
342
|
+
durationMs: Date.now() - startedAt,
|
|
343
|
+
metadata: {
|
|
344
|
+
serverCountReady: 0,
|
|
345
|
+
clientScope: useAllClients ? "all" : "primary",
|
|
346
|
+
failureKind: "no_clients",
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const languageId = getLanguageId(filePath) ?? "plaintext";
|
|
353
|
+
await Promise.all(
|
|
354
|
+
spawned.map((entry) => entry.client.notify.open(filePath, content, languageId)),
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
if (waitForDiagnostics) {
|
|
358
|
+
await Promise.all(
|
|
359
|
+
spawned.map((entry) =>
|
|
360
|
+
entry.client.waitForDiagnostics(filePath, 1200).catch(() => undefined),
|
|
361
|
+
),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
logLatency({
|
|
366
|
+
type: "phase",
|
|
367
|
+
phase: "lsp_touch_file",
|
|
368
|
+
filePath: normalizedPath,
|
|
369
|
+
durationMs: Date.now() - startedAt,
|
|
370
|
+
metadata: {
|
|
371
|
+
serverCountReady: spawned.length,
|
|
372
|
+
clientScope: useAllClients ? "all" : "primary",
|
|
373
|
+
waitForDiagnostics,
|
|
374
|
+
source,
|
|
375
|
+
failureKind: "success",
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
247
380
|
/**
|
|
248
381
|
* Get diagnostics for a file
|
|
249
382
|
*/
|
|
250
383
|
async getDiagnostics(
|
|
251
384
|
filePath: string,
|
|
252
385
|
): Promise<import("./client.js").LSPDiagnostic[]> {
|
|
253
|
-
const
|
|
254
|
-
|
|
386
|
+
const startedAt = Date.now();
|
|
387
|
+
const normalizedPath = normalizeMapKey(filePath);
|
|
388
|
+
const spawned = await this.getClientsForFile(filePath);
|
|
389
|
+
if (spawned.length === 0) {
|
|
390
|
+
logLatency({
|
|
391
|
+
type: "phase",
|
|
392
|
+
phase: "lsp_diagnostics_aggregate",
|
|
393
|
+
filePath: normalizedPath,
|
|
394
|
+
durationMs: Date.now() - startedAt,
|
|
395
|
+
metadata: {
|
|
396
|
+
serverCountAttempted: 0,
|
|
397
|
+
serverCountReady: 0,
|
|
398
|
+
mergedCount: 0,
|
|
399
|
+
dedupDroppedCount: 0,
|
|
400
|
+
failureKind: "no_clients",
|
|
401
|
+
health: "no_clients",
|
|
402
|
+
servers: [],
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const perServer = await Promise.all(
|
|
409
|
+
spawned.map(async (entry) => {
|
|
410
|
+
const waitStart = Date.now();
|
|
411
|
+
await entry.client.waitForDiagnostics(filePath, 3000);
|
|
412
|
+
const diagnostics = entry.client.getDiagnostics(filePath);
|
|
413
|
+
return {
|
|
414
|
+
serverId: entry.info.id,
|
|
415
|
+
waitMs: Date.now() - waitStart,
|
|
416
|
+
diagnosticCount: diagnostics.length,
|
|
417
|
+
diagnostics,
|
|
418
|
+
};
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const merged: import("./client.js").LSPDiagnostic[] = [];
|
|
423
|
+
const seen = new Set<string>();
|
|
424
|
+
for (const entry of perServer) {
|
|
425
|
+
for (const diagnostic of entry.diagnostics) {
|
|
426
|
+
const key = [
|
|
427
|
+
diagnostic.range.start.line,
|
|
428
|
+
diagnostic.range.start.character,
|
|
429
|
+
diagnostic.message,
|
|
430
|
+
].join(":");
|
|
431
|
+
if (seen.has(key)) continue;
|
|
432
|
+
seen.add(key);
|
|
433
|
+
merged.push(diagnostic);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const rawCount = perServer.reduce(
|
|
438
|
+
(sum, entry) => sum + entry.diagnosticCount,
|
|
439
|
+
0,
|
|
440
|
+
);
|
|
441
|
+
const serversWithDiagnostics = perServer.filter(
|
|
442
|
+
(entry) => entry.diagnosticCount > 0,
|
|
443
|
+
).length;
|
|
444
|
+
const failureKind = merged.length === 0 ? "empty_result" : "success";
|
|
445
|
+
|
|
446
|
+
logLatency({
|
|
447
|
+
type: "phase",
|
|
448
|
+
phase: "lsp_diagnostics_aggregate",
|
|
449
|
+
filePath: normalizedPath,
|
|
450
|
+
durationMs: Date.now() - startedAt,
|
|
451
|
+
metadata: {
|
|
452
|
+
serverCountAttempted: getServersForFileWithConfig(filePath).length,
|
|
453
|
+
serverCountReady: perServer.length,
|
|
454
|
+
serverCountWithDiagnostics: serversWithDiagnostics,
|
|
455
|
+
mergedCount: merged.length,
|
|
456
|
+
dedupDroppedCount: rawCount - merged.length,
|
|
457
|
+
failureKind,
|
|
458
|
+
health: failureKind === "success" ? "ok" : "empty_result",
|
|
459
|
+
servers: perServer.map((entry) => ({
|
|
460
|
+
id: entry.serverId,
|
|
461
|
+
waitMs: entry.waitMs,
|
|
462
|
+
diagnosticCount: entry.diagnosticCount,
|
|
463
|
+
})),
|
|
464
|
+
},
|
|
465
|
+
});
|
|
255
466
|
|
|
256
|
-
|
|
257
|
-
return spawned.client.getDiagnostics(filePath);
|
|
467
|
+
return merged;
|
|
258
468
|
}
|
|
259
469
|
|
|
260
470
|
/**
|
|
@@ -294,6 +504,15 @@ export class LSPService {
|
|
|
294
504
|
return spawned.client.hover(filePath, line, character);
|
|
295
505
|
}
|
|
296
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Navigation: signature help at cursor position
|
|
509
|
+
*/
|
|
510
|
+
async signatureHelp(filePath: string, line: number, character: number) {
|
|
511
|
+
const spawned = await this.getClientForFile(filePath);
|
|
512
|
+
if (!spawned) return null;
|
|
513
|
+
return spawned.client.signatureHelp(filePath, line, character);
|
|
514
|
+
}
|
|
515
|
+
|
|
297
516
|
/**
|
|
298
517
|
* Navigation: symbols in document
|
|
299
518
|
*/
|
|
@@ -306,13 +525,98 @@ export class LSPService {
|
|
|
306
525
|
/**
|
|
307
526
|
* Navigation: workspace-wide symbol search
|
|
308
527
|
*/
|
|
309
|
-
async workspaceSymbol(query: string) {
|
|
528
|
+
async workspaceSymbol(query: string, filePath?: string) {
|
|
529
|
+
if (filePath) {
|
|
530
|
+
const spawned = await this.getClientForFile(filePath);
|
|
531
|
+
if (!spawned) return [];
|
|
532
|
+
return spawned.client.workspaceSymbol(query);
|
|
533
|
+
}
|
|
534
|
+
|
|
310
535
|
// Use the first active client for workspace-level queries
|
|
311
536
|
const clients = Array.from(this.state.clients.values());
|
|
312
537
|
if (clients.length === 0) return [];
|
|
313
538
|
return clients[0].workspaceSymbol(query);
|
|
314
539
|
}
|
|
315
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Capability snapshot for LSP operations.
|
|
543
|
+
* If filePath is provided, probes that server; otherwise uses first active client.
|
|
544
|
+
*/
|
|
545
|
+
async getOperationSupport(filePath?: string): Promise<
|
|
546
|
+
import("./client.js").LSPOperationSupport | null
|
|
547
|
+
> {
|
|
548
|
+
if (filePath) {
|
|
549
|
+
const spawned = await this.getClientForFile(filePath);
|
|
550
|
+
if (!spawned) return null;
|
|
551
|
+
const getter = spawned.client.getOperationSupport;
|
|
552
|
+
if (typeof getter !== "function") return null;
|
|
553
|
+
return getter();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const first = this.state.clients.values().next().value;
|
|
557
|
+
if (!first) return null;
|
|
558
|
+
const getter = first.getOperationSupport;
|
|
559
|
+
if (typeof getter !== "function") return null;
|
|
560
|
+
return getter();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Capability snapshot for workspace diagnostics support.
|
|
565
|
+
* If filePath is provided, probes that server; otherwise uses first active client.
|
|
566
|
+
*/
|
|
567
|
+
async getWorkspaceDiagnosticsSupport(filePath?: string): Promise<
|
|
568
|
+
import("./client.js").LSPWorkspaceDiagnosticsSupport | null
|
|
569
|
+
> {
|
|
570
|
+
if (filePath) {
|
|
571
|
+
const spawned = await this.getClientForFile(filePath);
|
|
572
|
+
if (!spawned) return null;
|
|
573
|
+
const getter = spawned.client.getWorkspaceDiagnosticsSupport;
|
|
574
|
+
if (typeof getter !== "function") return null;
|
|
575
|
+
return getter();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const first = this.state.clients.values().next().value;
|
|
579
|
+
if (!first) return null;
|
|
580
|
+
const getter = first.getWorkspaceDiagnosticsSupport;
|
|
581
|
+
if (typeof getter !== "function") return null;
|
|
582
|
+
return getter();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Navigation: available code actions at position/range
|
|
587
|
+
*/
|
|
588
|
+
async codeAction(
|
|
589
|
+
filePath: string,
|
|
590
|
+
line: number,
|
|
591
|
+
character: number,
|
|
592
|
+
endLine: number,
|
|
593
|
+
endCharacter: number,
|
|
594
|
+
) {
|
|
595
|
+
const spawned = await this.getClientForFile(filePath);
|
|
596
|
+
if (!spawned) return [];
|
|
597
|
+
return spawned.client.codeAction(
|
|
598
|
+
filePath,
|
|
599
|
+
line,
|
|
600
|
+
character,
|
|
601
|
+
endLine,
|
|
602
|
+
endCharacter,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Navigation: rename symbol at position
|
|
608
|
+
*/
|
|
609
|
+
async rename(
|
|
610
|
+
filePath: string,
|
|
611
|
+
line: number,
|
|
612
|
+
character: number,
|
|
613
|
+
newName: string,
|
|
614
|
+
) {
|
|
615
|
+
const spawned = await this.getClientForFile(filePath);
|
|
616
|
+
if (!spawned) return null;
|
|
617
|
+
return spawned.client.rename(filePath, line, character, newName);
|
|
618
|
+
}
|
|
619
|
+
|
|
316
620
|
/**
|
|
317
621
|
* Navigation: go to implementation
|
|
318
622
|
*/
|
|
@@ -374,16 +678,8 @@ export class LSPService {
|
|
|
374
678
|
* Check if LSP is available for a file
|
|
375
679
|
*/
|
|
376
680
|
async hasLSP(filePath: string): Promise<boolean> {
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// Check if any server can provide a root
|
|
381
|
-
for (const server of servers) {
|
|
382
|
-
const root = await server.root(filePath);
|
|
383
|
-
if (root) return true;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return false;
|
|
681
|
+
const spawned = await this.getClientForFile(filePath);
|
|
682
|
+
return Boolean(spawned);
|
|
387
683
|
}
|
|
388
684
|
|
|
389
685
|
/**
|
|
@@ -402,6 +698,8 @@ export class LSPService {
|
|
|
402
698
|
}
|
|
403
699
|
this.state.clients.clear();
|
|
404
700
|
this.state.broken.clear();
|
|
701
|
+
this.workspaceProbeLogged.clear();
|
|
702
|
+
this.warmStartLogged.clear();
|
|
405
703
|
}
|
|
406
704
|
|
|
407
705
|
/**
|
package/clients/lsp/launch.ts
CHANGED
|
@@ -26,6 +26,38 @@ export interface LSPProcess {
|
|
|
26
26
|
|
|
27
27
|
const isWindows = process.platform === "win32";
|
|
28
28
|
|
|
29
|
+
function buildAugmentedPath(basePath?: string): string {
|
|
30
|
+
if (!isWindows) return basePath ?? "";
|
|
31
|
+
|
|
32
|
+
const userProfile = process.env.USERPROFILE;
|
|
33
|
+
const candidates: string[] = [];
|
|
34
|
+
if (userProfile) {
|
|
35
|
+
candidates.push(path.join(userProfile, ".cargo", "bin"));
|
|
36
|
+
candidates.push(path.join(userProfile, "go", "bin"));
|
|
37
|
+
}
|
|
38
|
+
candidates.push(path.join("C:\\", "Ruby34-x64", "bin"));
|
|
39
|
+
candidates.push(path.join("C:\\", "Ruby33-x64", "bin"));
|
|
40
|
+
|
|
41
|
+
const existing = new Set<string>();
|
|
42
|
+
for (const entry of (basePath ?? "").split(path.delimiter)) {
|
|
43
|
+
if (!entry) continue;
|
|
44
|
+
existing.add(path.normalize(entry).toLowerCase());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const toAppend: string[] = [];
|
|
48
|
+
for (const candidate of candidates) {
|
|
49
|
+
if (!candidate || !fs.existsSync(candidate)) continue;
|
|
50
|
+
const normalized = path.normalize(candidate).toLowerCase();
|
|
51
|
+
if (existing.has(normalized)) continue;
|
|
52
|
+
toAppend.push(candidate);
|
|
53
|
+
existing.add(normalized);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (toAppend.length === 0) return basePath ?? "";
|
|
57
|
+
if (!basePath) return toAppend.join(path.delimiter);
|
|
58
|
+
return `${basePath}${path.delimiter}${toAppend.join(path.delimiter)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
29
61
|
/**
|
|
30
62
|
* Find binary in npm global directory
|
|
31
63
|
* Works around PATH caching issue after npm install -g
|
|
@@ -169,7 +201,11 @@ export async function launchLSP(
|
|
|
169
201
|
options: SpawnOptions = {},
|
|
170
202
|
): Promise<LSPProcess> {
|
|
171
203
|
const cwd = String(options.cwd ?? process.cwd());
|
|
172
|
-
const
|
|
204
|
+
const mergedEnv = { ...process.env, ...options.env };
|
|
205
|
+
const env: NodeJS.ProcessEnv = {
|
|
206
|
+
...mergedEnv,
|
|
207
|
+
PATH: buildAugmentedPath(mergedEnv.PATH),
|
|
208
|
+
};
|
|
173
209
|
|
|
174
210
|
// Resolve command path
|
|
175
211
|
// - If already absolute, use as-is
|
|
@@ -332,7 +368,11 @@ export async function launchViaPackageManager(
|
|
|
332
368
|
const shellCommand = `npx --no ${packageName}${argsStr ? ` ${argsStr}` : ""}`;
|
|
333
369
|
|
|
334
370
|
const cwd = String(options.cwd ?? process.cwd());
|
|
335
|
-
const
|
|
371
|
+
const mergedEnv = { ...process.env, ...options.env };
|
|
372
|
+
const env: NodeJS.ProcessEnv = {
|
|
373
|
+
...mergedEnv,
|
|
374
|
+
PATH: buildAugmentedPath(mergedEnv.PATH),
|
|
375
|
+
};
|
|
336
376
|
|
|
337
377
|
const proc = nodeSpawn(shellCommand, [], {
|
|
338
378
|
cwd,
|