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.
Files changed (92) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +2 -0
  3. package/clients/dispatch/dispatcher.ts +75 -91
  4. package/clients/dispatch/fact-provider-types.ts +22 -0
  5. package/clients/dispatch/fact-rule-runner.ts +22 -0
  6. package/clients/dispatch/fact-runner.ts +28 -0
  7. package/clients/dispatch/fact-scheduler.ts +78 -0
  8. package/clients/dispatch/fact-store.ts +67 -0
  9. package/clients/dispatch/facts/comment-facts.ts +59 -0
  10. package/clients/dispatch/facts/file-content.ts +20 -0
  11. package/clients/dispatch/facts/function-facts.ts +177 -0
  12. package/clients/dispatch/facts/try-catch-facts.ts +80 -0
  13. package/clients/dispatch/integration.ts +130 -24
  14. package/clients/dispatch/priorities.ts +22 -0
  15. package/clients/dispatch/rules/async-noise.ts +43 -0
  16. package/clients/dispatch/rules/error-obscuring.ts +40 -0
  17. package/clients/dispatch/rules/error-swallowing.ts +35 -0
  18. package/clients/dispatch/rules/pass-through-wrappers.ts +52 -0
  19. package/clients/dispatch/rules/placeholder-comments.ts +47 -0
  20. package/clients/dispatch/runners/architect.ts +2 -1
  21. package/clients/dispatch/runners/ast-grep-napi.ts +2 -1
  22. package/clients/dispatch/runners/biome-check.ts +40 -8
  23. package/clients/dispatch/runners/biome.ts +2 -1
  24. package/clients/dispatch/runners/eslint.ts +34 -6
  25. package/clients/dispatch/runners/go-vet.ts +2 -1
  26. package/clients/dispatch/runners/golangci-lint.ts +2 -1
  27. package/clients/dispatch/runners/index.ts +29 -27
  28. package/clients/dispatch/runners/lsp.ts +60 -4
  29. package/clients/dispatch/runners/oxlint.ts +2 -1
  30. package/clients/dispatch/runners/pyright.ts +2 -1
  31. package/clients/dispatch/runners/python-slop.ts +2 -1
  32. package/clients/dispatch/runners/rubocop.ts +2 -1
  33. package/clients/dispatch/runners/ruff.ts +2 -1
  34. package/clients/dispatch/runners/rust-clippy.ts +2 -1
  35. package/clients/dispatch/runners/shellcheck.ts +2 -1
  36. package/clients/dispatch/runners/similarity.ts +2 -1
  37. package/clients/dispatch/runners/spellcheck.ts +2 -1
  38. package/clients/dispatch/runners/sqlfluff.ts +2 -1
  39. package/clients/dispatch/runners/tree-sitter.ts +469 -1
  40. package/clients/dispatch/runners/ts-lsp.ts +2 -1
  41. package/clients/dispatch/runners/type-safety.ts +2 -1
  42. package/clients/dispatch/runners/yamllint.ts +2 -1
  43. package/clients/dispatch/tool-profile.ts +40 -0
  44. package/clients/dispatch/types.ts +3 -13
  45. package/clients/lsp/client.ts +366 -12
  46. package/clients/lsp/index.ts +374 -76
  47. package/clients/lsp/launch.ts +42 -2
  48. package/clients/lsp/server.ts +186 -12
  49. package/clients/pipeline.ts +2 -2
  50. package/clients/runtime-context.ts +2 -2
  51. package/clients/runtime-session.ts +43 -5
  52. package/clients/session-summary.ts +21 -0
  53. package/clients/tree-sitter-client.ts +162 -0
  54. package/clients/tree-sitter-logger.ts +47 -0
  55. package/clients/tree-sitter-query-loader.ts +13 -2
  56. package/index.ts +67 -17
  57. package/package.json +3 -1
  58. package/rules/rule-catalog.json +64 -0
  59. package/rules/tree-sitter-queries/go/go-bare-error.yml +19 -7
  60. package/rules/tree-sitter-queries/go/go-command-injection.yml +55 -0
  61. package/rules/tree-sitter-queries/go/go-direct-panic.yml +45 -0
  62. package/rules/tree-sitter-queries/go/go-empty-if-err.yml +47 -0
  63. package/rules/tree-sitter-queries/go/go-goroutine-loop-capture.yml +49 -0
  64. package/rules/tree-sitter-queries/go/go-ignored-call-result.yml +51 -0
  65. package/rules/tree-sitter-queries/go/go-insecure-random.yml +51 -0
  66. package/rules/tree-sitter-queries/go/go-log-fatal.yml +49 -0
  67. package/rules/tree-sitter-queries/go/go-path-traversal.yml +51 -0
  68. package/rules/tree-sitter-queries/go/go-shared-map-write-goroutine.yml +54 -0
  69. package/rules/tree-sitter-queries/go/go-sql-injection.yml +55 -0
  70. package/rules/tree-sitter-queries/go/go-weak-hash.yml +51 -0
  71. package/rules/tree-sitter-queries/python/python-command-injection.yml +63 -0
  72. package/rules/tree-sitter-queries/python/python-insecure-deserialization.yml +48 -0
  73. package/rules/tree-sitter-queries/python/python-insecure-random.yml +51 -0
  74. package/rules/tree-sitter-queries/python/python-path-traversal.yml +55 -0
  75. package/rules/tree-sitter-queries/python/python-sql-injection.yml +47 -0
  76. package/rules/tree-sitter-queries/python/python-ssrf.yml +50 -0
  77. package/rules/tree-sitter-queries/python/python-thread-global-write.yml +58 -0
  78. package/rules/tree-sitter-queries/python/python-weak-hash.yml +51 -0
  79. package/rules/tree-sitter-queries/ruby/ruby-command-injection.yml +56 -0
  80. package/rules/tree-sitter-queries/ruby/ruby-insecure-deserialization.yml +47 -0
  81. package/rules/tree-sitter-queries/ruby/ruby-insecure-random.yml +54 -0
  82. package/rules/tree-sitter-queries/ruby/ruby-weak-hash.yml +50 -0
  83. package/rules/tree-sitter-queries/rust/rust-lock-held-across-await.yml +59 -0
  84. package/rules/tree-sitter-queries/typescript/ts-command-injection.yml +60 -0
  85. package/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml +56 -0
  86. package/rules/tree-sitter-queries/typescript/ts-insecure-random.yml +54 -0
  87. package/rules/tree-sitter-queries/typescript/ts-ssrf.yml +53 -0
  88. package/rules/tree-sitter-queries/typescript/ts-weak-hash.yml +54 -0
  89. package/scripts/validate-rule-catalog.mjs +227 -0
  90. package/skills/lsp-navigation/SKILL.md +15 -3
  91. package/tools/lsp-navigation.js +466 -79
  92. package/tools/lsp-navigation.ts +587 -85
@@ -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 root = await server.root(filePath);
77
- if (!root) continue;
78
- const allowInstall = this.shouldAllowInstall(filePath, root);
79
-
80
- const normalizedRoot = normalizeMapKey(root);
81
- const key = `${server.id}:${normalizedRoot}`;
82
-
83
- // Check cache first (fast path)
84
- const existing = this.state.clients.get(key);
85
- if (existing) {
86
- if (!existing.isAlive()) {
87
- try {
88
- await existing.shutdown();
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
- // Check if broken
100
- const brokenUntil = this.state.broken.get(key);
101
- if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
102
- continue;
103
- }
104
- if (typeof brokenUntil === "number" && brokenUntil <= Date.now()) {
105
- this.state.broken.delete(key);
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
- // Check if there's already an in-flight spawn for this key
109
- const inFlight = this.state.inFlight.get(key);
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
- // Create the spawn promise and store it
118
- const spawnPromise = this.spawnClient(server, root, key, filePath, allowInstall);
119
- this.state.inFlight.set(key, spawnPromise);
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
- try {
122
- const result = await spawnPromise;
123
- if (result) return result;
124
- } finally {
125
- // Clean up in-flight tracking
126
- this.state.inFlight.delete(key);
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
- return undefined;
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 (errorMsg.includes("Timeout")) {
207
- console.error(
208
- `[lsp] ${server.id} timed out during initialization (${errorMsg}). The server may be downloading or the project is large. Skipping.`,
209
- );
210
- } else if (errorMsg.includes("stream was destroyed")) {
211
- console.error(
212
- `[lsp] ${server.id} stream was destroyed. The server binary may be missing or crashed immediately. Try reinstalling: npm install -g ${server.id}-language-server`,
213
- );
214
- } else if (errorMsg.includes("exited immediately")) {
215
- console.error(
216
- `[lsp] ${server.id} ${errorMsg}. Try reinstalling: npm install -g ${server.id}-language-server`,
217
- );
218
- } else {
219
- console.error(`[lsp] Failed to spawn ${server.id}:`, err);
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 spawned = await this.getClientForFile(filePath);
254
- if (!spawned) return [];
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
- await spawned.client.waitForDiagnostics(filePath, 3000);
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 servers = getServersForFileWithConfig(filePath);
378
- if (servers.length === 0) return false;
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
  /**
@@ -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 env = { ...process.env, ...options.env };
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 env = { ...process.env, ...options.env };
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,