@dreki-gg/pi-lsp 0.2.0 → 0.3.0

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 CHANGED
@@ -1,5 +1,43 @@
1
1
  # @dreki-gg/pi-lsp
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#43](https://github.com/dreki-gg/pi-extensions/pull/43) [`c8dc964`](https://github.com/dreki-gg/pi-extensions/commit/c8dc96448bd8cf1a594daf42a1ab0d56f932d629) Thanks [@jalbarrang](https://github.com/jalbarrang)! - feat(lsp): improve reliability and LLM guidance based on real-world usage feedback
8
+
9
+ - **documentSymbol now includes column positions** — output shows `line:col` instead of just `line`, eliminating the need for a follow-up `rg --column` to get positions for other LSP operations.
10
+ - **Retry with backoff during server indexing** — `hover`, `definition`, `references`, `implementation`, `documentSymbol`, and `workspaceSymbol` automatically retry (up to 2 times, 2s delay) when the server was recently initialized and returns empty results.
11
+ - **Send `didSave` notification** — the client now sends `textDocument/didSave` after opening or updating documents, fixing diagnostics for servers like `rust-analyzer` that require save events.
12
+ - **Improved diagnostics wait logic** — stale cached diagnostics are cleared on document re-sync, and the arbitrary 500ms delay is removed in favor of resolving immediately when fresh diagnostics arrive.
13
+ - **Rewritten `promptGuidelines`** — 9 targeted guidelines that teach LLMs (especially smaller models) how to use LSP tools effectively: prefer `hover` for quick inspection, position cursor in the middle of symbols, use compiler tools for diagnostics in compiled languages, and more.
14
+ - **Enhanced tool description** — includes tips section and notes that `incomingCalls`/`outgoingCalls` auto-prepare the call hierarchy.
15
+ - **Removed unused code** — `pathToUri` export, `capabilities` getter, and `closeDocument` method removed.
16
+
17
+ ### Patch Changes
18
+
19
+ - [`87baca4`](https://github.com/dreki-gg/pi-extensions/commit/87baca402f6afa0bba627a3c179bacf0bbbeacba) Thanks [@jalbarrang](https://github.com/jalbarrang)! - fix(lsp): Windows URI normalization and diagnostic waiter race conditions
20
+
21
+ - Add `normalizeUri()` to decode percent-encoded URIs (`%3A` → `:`) and uppercase Windows drive letters, fixing key mismatches between `pathToUri` and server responses.
22
+ - `pathToUri()` now always uppercases the drive letter for consistency.
23
+ - `uriToPath()` now applies `decodeURIComponent` so encoded URIs from the server produce valid file paths.
24
+ - Replace per-URI waiter arrays with a single `PendingDiagnostic` promise per URI, eliminating manual timer/splice management.
25
+ - Track `invalidatedUris` to distinguish first-open (empty diagnostics = clean file → resolve immediately) from re-open (empty diagnostics = server clearing stale state → wait for real results).
26
+
27
+ ## 0.2.1
28
+
29
+ ### Patch Changes
30
+
31
+ - [`32797ff`](https://github.com/dreki-gg/pi-extensions/commit/32797ff18d968e22c6c44e95c46e3393d8928cef) Thanks [@jalbarrang](https://github.com/jalbarrang)! - feat(plan-mode): add Windows compatibility — replace Unix shell commands with cross-platform Bun/Node APIs
32
+
33
+ Plan-mode no longer shells out to `cat`, `bash`, or `mkdir` via `pi.exec()`. File I/O now uses `Bun.file()` / `Bun.write()` and `node:fs/promises` `mkdir`, making the extension fully cross-platform. Destructive and safe command pattern lists now include Windows equivalents (`del`, `rd`, `copy`, `move`, `powershell`, `dir`, `where`, `tasklist`, etc.).
34
+
35
+ Also fixes Windows compatibility in three other packages:
36
+
37
+ - **browser-tools**: `spawn` now uses `shell: true` on Windows so `.cmd` wrappers resolve correctly; `shellEscape` uses double-quote style on Windows; install guidance is platform-aware (Homebrew shown only on macOS).
38
+ - **subagent**: `spawn` uses `shell: true` on Windows when the command is bare `pi`, allowing `pi.cmd` resolution.
39
+ - **lsp**: `globalConfigPath()` now uses `os.homedir()` on Windows instead of the unreliable `process.env.HOME`.
40
+
3
41
  ## 0.2.0
4
42
 
5
43
  ### Minor Changes
@@ -25,20 +25,41 @@ import type {
25
25
  ResolvedServerConfig,
26
26
  SymbolInformation,
27
27
  } from './types';
28
+ import { withRetry } from './retry';
28
29
 
29
30
  // ── Helpers ─────────────────────────────────────────────────────────────────
30
31
 
31
- export function pathToUri(filePath: string): string {
32
+ /**
33
+ * Normalize a file URI for consistent map-key comparison.
34
+ *
35
+ * Some LSP servers (e.g. typescript-language-server on Windows) return URIs with
36
+ * percent-encoded colons (`%3A`) and lowercase drive letters. We decode and
37
+ * uppercase so that `file:///d%3A/…` and `file:///D:/…` resolve to the same key.
38
+ */
39
+ function normalizeUri(uri: string): string {
40
+ let normalized = decodeURIComponent(uri);
41
+ // Uppercase Windows drive letter: file:///d:/… → file:///D:/…
42
+ normalized = normalized.replace(
43
+ /^file:\/\/\/([a-z]):/,
44
+ (_, letter: string) => `file:///${letter.toUpperCase()}:`,
45
+ );
46
+ return normalized;
47
+ }
48
+
49
+ function pathToUri(filePath: string): string {
32
50
  const abs = resolve(filePath);
33
51
  const normalized = abs.replace(/\\/g, '/');
34
- // Windows paths need file:///C:/... (three slashes)
35
- if (/^[A-Za-z]:/.test(normalized)) return `file:///${normalized}`;
52
+ // Windows paths need file:///C:/... (three slashes), always uppercase drive letter
53
+ if (/^[A-Za-z]:/.test(normalized)) {
54
+ return `file:///${normalized[0].toUpperCase()}${normalized.slice(1)}`;
55
+ }
36
56
  return `file://${normalized}`;
37
57
  }
38
58
 
39
59
  export function uriToPath(uri: string): string {
40
60
  if (!uri.startsWith('file://')) return uri;
41
- const path = uri.slice(7);
61
+ const decoded = decodeURIComponent(uri);
62
+ const path = decoded.slice(7);
42
63
  // Remove leading slash before Windows drive letter: /C:/... → C:/...
43
64
  if (/^\/[A-Za-z]:/.test(path)) return path.slice(1);
44
65
  return path;
@@ -86,9 +107,18 @@ interface OpenDocument {
86
107
  languageId: string;
87
108
  }
88
109
 
89
- interface DiagnosticWaiter {
110
+ /**
111
+ * A single pending diagnostic request per URI.
112
+ *
113
+ * Instead of maintaining an array of waiters with individual timers, we keep one
114
+ * pending promise per URI. Non-empty `publishDiagnostics` notifications resolve
115
+ * it immediately; empty ones are stored but do NOT resolve the pending — this
116
+ * avoids the "empty-then-real" race where the server clears stale diagnostics
117
+ * before sending real results. A timeout passed via `Promise.race` in
118
+ * `waitForDiagnostics` acts as a safety net for genuinely clean files.
119
+ */
120
+ interface PendingDiagnostic {
90
121
  resolve: () => void;
91
- timer: ReturnType<typeof setTimeout>;
92
122
  }
93
123
 
94
124
  // ── Client ──────────────────────────────────────────────────────────────────
@@ -99,9 +129,12 @@ export class LspClient {
99
129
  private initializePromise: Promise<void> | null = null;
100
130
  private openDocs = new Map<string, OpenDocument>();
101
131
  private diagnosticStore = new Map<string, Diagnostic[]>();
102
- private diagnosticWaiters = new Map<string, DiagnosticWaiter[]>();
132
+ private pendingDiagnostics = new Map<string, PendingDiagnostic>();
133
+ /** URIs where we just sent didChange and haven't yet received non-empty diagnostics. */
134
+ private invalidatedUris = new Set<string>();
103
135
  private serverCapabilities: Record<string, unknown> = {};
104
136
  private stderrLog: string[] = [];
137
+ private initTimestamp = 0;
105
138
 
106
139
  readonly config: ResolvedServerConfig;
107
140
  private rootPath: string;
@@ -123,15 +156,26 @@ export class LspClient {
123
156
  conn.setNotificationHandler((method, params) => {
124
157
  if (method === 'textDocument/publishDiagnostics') {
125
158
  const { uri, diagnostics } = params as PublishDiagnosticsParams;
126
- this.diagnosticStore.set(uri, diagnostics);
159
+ const normalized = normalizeUri(uri);
160
+ this.diagnosticStore.set(normalized, diagnostics);
161
+
162
+ // Decide whether to resolve the pending promise:
163
+ // • Non-empty diagnostics always resolve (real errors found).
164
+ // • Empty diagnostics resolve ONLY when the URI was NOT recently
165
+ // invalidated by a didChange — this avoids the "empty-then-real"
166
+ // race where servers clear stale diagnostics before sending real ones.
167
+ const shouldResolve = diagnostics.length > 0 || !this.invalidatedUris.has(normalized);
168
+
169
+ if (diagnostics.length > 0) {
170
+ this.invalidatedUris.delete(normalized);
171
+ }
127
172
 
128
- const waiters = this.diagnosticWaiters.get(uri);
129
- if (waiters?.length) {
130
- for (const w of waiters) {
131
- clearTimeout(w.timer);
132
- w.resolve();
173
+ if (shouldResolve) {
174
+ const pending = this.pendingDiagnostics.get(normalized);
175
+ if (pending) {
176
+ pending.resolve();
177
+ this.pendingDiagnostics.delete(normalized);
133
178
  }
134
- this.diagnosticWaiters.delete(uri);
135
179
  }
136
180
  }
137
181
  });
@@ -209,6 +253,24 @@ export class LspClient {
209
253
  this.serverCapabilities = result?.capabilities ?? {};
210
254
  this.connection.sendNotification('initialized', {});
211
255
  this.initialized = true;
256
+ this.initTimestamp = Date.now();
257
+ }
258
+
259
+ /** Whether the server was initialized recently (within windowMs). */
260
+ private isRecentlyInitialized(windowMs = 30_000): boolean {
261
+ return this.initTimestamp > 0 && Date.now() - this.initTimestamp < windowMs;
262
+ }
263
+
264
+ /**
265
+ * Wrap an LSP operation with retry logic when the server was recently initialized.
266
+ * During indexing, servers may return empty results that resolve after a short wait.
267
+ */
268
+ private async retryIfIndexing<T>(
269
+ operation: () => Promise<T>,
270
+ isEmpty: (result: T) => boolean,
271
+ ): Promise<T> {
272
+ if (!this.isRecentlyInitialized()) return operation();
273
+ return withRetry(operation, isEmpty, { maxRetries: 2, delayMs: 2000 });
212
274
  }
213
275
 
214
276
  async shutdown(): Promise<void> {
@@ -228,17 +290,14 @@ export class LspClient {
228
290
  this.initializePromise = null;
229
291
  this.openDocs.clear();
230
292
  this.diagnosticStore.clear();
231
- this.clearAllWaiters();
293
+ this.invalidatedUris.clear();
294
+ this.clearAllPending();
232
295
  }
233
296
 
234
297
  get isInitialized(): boolean {
235
298
  return this.initialized;
236
299
  }
237
300
 
238
- get capabilities(): Record<string, unknown> {
239
- return this.serverCapabilities;
240
- }
241
-
242
301
  /** Check if the server advertised a specific capability. */
243
302
  hasCapability(name: string): boolean {
244
303
  return this.serverCapabilities[name] !== undefined && this.serverCapabilities[name] !== false;
@@ -258,6 +317,8 @@ export class LspClient {
258
317
 
259
318
  if (existing) {
260
319
  existing.version++;
320
+ this.diagnosticStore.delete(uri); // Clear stale diagnostics before re-sync
321
+ this.invalidatedUris.add(uri); // Mark as invalidated until real diagnostics arrive
261
322
  this.connection.sendNotification('textDocument/didChange', {
262
323
  textDocument: { uri, version: existing.version },
263
324
  contentChanges: [{ text }],
@@ -270,19 +331,14 @@ export class LspClient {
270
331
  this.openDocs.set(uri, { uri, version, languageId });
271
332
  }
272
333
 
273
- return uri;
274
- }
275
-
276
- async closeDocument(filePath: string): Promise<void> {
277
- const uri = pathToUri(resolve(this.rootPath, filePath));
278
- const doc = this.openDocs.get(uri);
279
- if (!doc) return;
280
-
281
- this.connection.sendNotification('textDocument/didClose', {
334
+ // Notify the server that the file was saved — some servers (e.g. rust-analyzer)
335
+ // only generate diagnostics after a didSave notification.
336
+ this.connection.sendNotification('textDocument/didSave', {
282
337
  textDocument: { uri },
338
+ text,
283
339
  });
284
- this.openDocs.delete(uri);
285
- this.diagnosticStore.delete(uri);
340
+
341
+ return uri;
286
342
  }
287
343
 
288
344
  // ── Diagnostics ───────────────────────────────────────────────────────
@@ -293,103 +349,143 @@ export class LspClient {
293
349
  return this.diagnosticStore.get(uri) ?? [];
294
350
  }
295
351
 
296
- private waitForDiagnostics(uri: string, timeoutMs: number): Promise<void> {
297
- return new Promise<void>((resolveWait) => {
298
- const existing = this.diagnosticStore.get(uri);
299
- if (existing !== undefined) {
300
- setTimeout(resolveWait, 500);
301
- return;
302
- }
352
+ /**
353
+ * Wait until non-empty diagnostics arrive for `uri`, or until `timeoutMs` elapses.
354
+ *
355
+ * If non-empty diagnostics are already in the store (e.g. from a previous
356
+ * notification), resolves immediately. Otherwise sets up a single pending
357
+ * promise that the notification handler will resolve when real diagnostics
358
+ * arrive. `Promise.race` against a timeout ensures we don't wait forever
359
+ * for genuinely clean files.
360
+ */
361
+ private async waitForDiagnostics(uri: string, timeoutMs: number): Promise<void> {
362
+ // Fast path: diagnostics already present and meaningful.
363
+ // • Non-empty → file has errors, return immediately.
364
+ // • Empty + not invalidated → genuinely clean file (e.g. first open), return.
365
+ const existing = this.diagnosticStore.get(uri);
366
+ if (existing !== undefined) {
367
+ if (existing.length > 0 || !this.invalidatedUris.has(uri)) return;
368
+ }
303
369
 
304
- const timer = setTimeout(() => {
305
- const waiters = this.diagnosticWaiters.get(uri);
306
- if (waiters) {
307
- const idx = waiters.findIndex((w) => w.resolve === resolveWait);
308
- if (idx !== -1) waiters.splice(idx, 1);
309
- if (waiters.length === 0) this.diagnosticWaiters.delete(uri);
310
- }
311
- resolveWait();
312
- }, timeoutMs);
370
+ // Resolve any previous pending for this URI so it doesn't leak.
371
+ const prev = this.pendingDiagnostics.get(uri);
372
+ if (prev) prev.resolve();
313
373
 
314
- const waiters = this.diagnosticWaiters.get(uri) ?? [];
315
- waiters.push({ resolve: resolveWait, timer });
316
- this.diagnosticWaiters.set(uri, waiters);
374
+ const { promise, resolve } = Promise.withResolvers<void>();
375
+ this.pendingDiagnostics.set(uri, { resolve });
376
+
377
+ // Safety-net timeout: resolves the race for files with zero errors.
378
+ let timer: ReturnType<typeof setTimeout>;
379
+ const timeout = new Promise<void>((r) => {
380
+ timer = setTimeout(r, timeoutMs);
317
381
  });
382
+
383
+ await Promise.race([promise, timeout]);
384
+
385
+ clearTimeout(timer!);
386
+ this.pendingDiagnostics.delete(uri);
318
387
  }
319
388
 
320
- private clearAllWaiters(): void {
321
- for (const [, waiters] of this.diagnosticWaiters) {
322
- for (const w of waiters) {
323
- clearTimeout(w.timer);
324
- w.resolve();
325
- }
389
+ private clearAllPending(): void {
390
+ for (const pending of this.pendingDiagnostics.values()) {
391
+ pending.resolve();
326
392
  }
327
- this.diagnosticWaiters.clear();
393
+ this.pendingDiagnostics.clear();
328
394
  }
329
395
 
330
396
  // ── Hover ─────────────────────────────────────────────────────────────
331
397
 
332
398
  async hover(filePath: string, position: Position): Promise<Hover | null> {
333
399
  const uri = await this.openDocument(filePath);
334
- const result = await this.connection.sendRequest('textDocument/hover', {
335
- textDocument: { uri },
336
- position,
337
- });
338
- return (result as Hover) ?? null;
400
+ return this.retryIfIndexing(
401
+ async () => {
402
+ const result = await this.connection.sendRequest('textDocument/hover', {
403
+ textDocument: { uri },
404
+ position,
405
+ });
406
+ return (result as Hover) ?? null;
407
+ },
408
+ (result) => result === null,
409
+ );
339
410
  }
340
411
 
341
412
  // ── Definition ────────────────────────────────────────────────────────
342
413
 
343
414
  async definition(filePath: string, position: Position): Promise<Location[]> {
344
415
  const uri = await this.openDocument(filePath);
345
- const result = await this.connection.sendRequest('textDocument/definition', {
346
- textDocument: { uri },
347
- position,
348
- });
349
- return normalizeLocations(result);
416
+ return this.retryIfIndexing(
417
+ async () => {
418
+ const result = await this.connection.sendRequest('textDocument/definition', {
419
+ textDocument: { uri },
420
+ position,
421
+ });
422
+ return normalizeLocations(result);
423
+ },
424
+ (result) => result.length === 0,
425
+ );
350
426
  }
351
427
 
352
428
  // ── References ────────────────────────────────────────────────────────
353
429
 
354
430
  async references(filePath: string, position: Position): Promise<Location[]> {
355
431
  const uri = await this.openDocument(filePath);
356
- const result = await this.connection.sendRequest('textDocument/references', {
357
- textDocument: { uri },
358
- position,
359
- context: { includeDeclaration: true },
360
- });
361
- return normalizeLocations(result);
432
+ return this.retryIfIndexing(
433
+ async () => {
434
+ const result = await this.connection.sendRequest('textDocument/references', {
435
+ textDocument: { uri },
436
+ position,
437
+ context: { includeDeclaration: true },
438
+ });
439
+ return normalizeLocations(result);
440
+ },
441
+ (result) => result.length === 0,
442
+ );
362
443
  }
363
444
 
364
445
  // ── Implementation ────────────────────────────────────────────────────
365
446
 
366
447
  async implementation(filePath: string, position: Position): Promise<Location[]> {
367
448
  const uri = await this.openDocument(filePath);
368
- const result = await this.connection.sendRequest('textDocument/implementation', {
369
- textDocument: { uri },
370
- position,
371
- });
372
- return normalizeLocations(result);
449
+ return this.retryIfIndexing(
450
+ async () => {
451
+ const result = await this.connection.sendRequest('textDocument/implementation', {
452
+ textDocument: { uri },
453
+ position,
454
+ });
455
+ return normalizeLocations(result);
456
+ },
457
+ (result) => result.length === 0,
458
+ );
373
459
  }
374
460
 
375
461
  // ── Document Symbols ──────────────────────────────────────────────────
376
462
 
377
463
  async documentSymbol(filePath: string): Promise<DocumentSymbol[] | SymbolInformation[]> {
378
464
  const uri = await this.openDocument(filePath);
379
- const result = await this.connection.sendRequest('textDocument/documentSymbol', {
380
- textDocument: { uri },
381
- });
382
- if (!Array.isArray(result)) return [];
383
- return result as DocumentSymbol[] | SymbolInformation[];
465
+ return this.retryIfIndexing(
466
+ async () => {
467
+ const result = await this.connection.sendRequest('textDocument/documentSymbol', {
468
+ textDocument: { uri },
469
+ });
470
+ if (!Array.isArray(result)) return [];
471
+ return result as DocumentSymbol[] | SymbolInformation[];
472
+ },
473
+ (result) => result.length === 0,
474
+ );
384
475
  }
385
476
 
386
477
  // ── Workspace Symbols ─────────────────────────────────────────────────
387
478
 
388
479
  async workspaceSymbol(query: string): Promise<SymbolInformation[]> {
389
480
  await this.ensureInitialized();
390
- const result = await this.connection.sendRequest('workspace/symbol', { query });
391
- if (!Array.isArray(result)) return [];
392
- return result as SymbolInformation[];
481
+ return this.retryIfIndexing(
482
+ async () => {
483
+ const result = await this.connection.sendRequest('workspace/symbol', { query });
484
+ if (!Array.isArray(result)) return [];
485
+ return result as SymbolInformation[];
486
+ },
487
+ (result) => result.length === 0,
488
+ );
393
489
  }
394
490
 
395
491
  // ── Call Hierarchy ────────────────────────────────────────────────────
@@ -21,7 +21,8 @@ import type { LspConfigFile, LspServerUserConfig, ResolvedServerConfig } from '.
21
21
  // ── Paths ───────────────────────────────────────────────────────────────────
22
22
 
23
23
  function globalConfigPath(): string {
24
- return join(process.env.HOME ?? homedir(), '.pi', 'agent', 'extensions', 'lsp', 'config.json');
24
+ const home = process.platform === 'win32' ? homedir() : (process.env.HOME ?? homedir());
25
+ return join(home, '.pi', 'agent', 'extensions', 'lsp', 'config.json');
25
26
  }
26
27
 
27
28
  function projectConfigPath(cwd: string): string {
@@ -186,8 +186,9 @@ function formatDocSymbolTree(symbols: DocumentSymbol[], indent: number): string[
186
186
  for (const sym of symbols) {
187
187
  const kind = symbolKindLabel(sym.kind);
188
188
  const line = sym.selectionRange.start.line + 1;
189
+ const col = sym.selectionRange.start.character + 1;
189
190
  const detail = sym.detail ? ` — ${sym.detail}` : '';
190
- lines.push(`${prefix}${sym.name} (${kind}) line ${line}${detail}`);
191
+ lines.push(`${prefix}${sym.name} (${kind}) line ${line}:${col}${detail}`);
191
192
  if (sym.children?.length) {
192
193
  lines.push(...formatDocSymbolTree(sym.children, indent + 1));
193
194
  }
@@ -212,8 +213,9 @@ export function formatDocumentSymbols(
212
213
  const kind = symbolKindLabel(sym.kind);
213
214
  const p = relativePath(sym.location.uri, rootPath);
214
215
  const line = sym.location.range.start.line + 1;
216
+ const col = sym.location.range.start.character + 1;
215
217
  const container = sym.containerName ? ` in ${sym.containerName}` : '';
216
- return `${i + 1}. ${sym.name} (${kind}) ${p}:${line}${container}`;
218
+ return `${i + 1}. ${sym.name} (${kind}) ${p}:${line}:${col}${container}`;
217
219
  });
218
220
 
219
221
  return `Symbols in ${filePath} (${symbols.length}):\n\n${formatted.join('\n')}`;
@@ -232,8 +234,9 @@ export function formatWorkspaceSymbols(
232
234
  const kind = symbolKindLabel(sym.kind);
233
235
  const p = relativePath(sym.location.uri, rootPath);
234
236
  const line = sym.location.range.start.line + 1;
237
+ const col = sym.location.range.start.character + 1;
235
238
  const container = sym.containerName ? ` in ${sym.containerName}` : '';
236
- return `${i + 1}. ${sym.name} (${kind}) ${p}:${line}${container}`;
239
+ return `${i + 1}. ${sym.name} (${kind}) ${p}:${line}:${col}${container}`;
237
240
  });
238
241
 
239
242
  const truncated = symbols.length > 50 ? `\n\n(showing 50 of ${symbols.length})` : '';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Retry utility for LSP operations that may return empty results during server indexing.
3
+ *
4
+ * Only retries when the server was recently initialized (within a configurable window),
5
+ * avoiding unnecessary delays for servers that are already warmed up.
6
+ */
7
+
8
+ export interface RetryOptions {
9
+ /** Maximum number of retry attempts. Default: 2 */
10
+ maxRetries?: number;
11
+ /** Delay between retries in milliseconds. Default: 2000 */
12
+ delayMs?: number;
13
+ }
14
+
15
+ /**
16
+ * Retry an async operation if the result is considered "empty".
17
+ *
18
+ * Useful for LSP operations that return empty results while the server is still indexing
19
+ * (e.g. workspaceSymbol, hover, definition, references).
20
+ */
21
+ export async function withRetry<T>(
22
+ operation: () => Promise<T>,
23
+ isEmpty: (result: T) => boolean,
24
+ options?: RetryOptions,
25
+ ): Promise<T> {
26
+ const maxRetries = options?.maxRetries ?? 2;
27
+ const delayMs = options?.delayMs ?? 2000;
28
+
29
+ let result = await operation();
30
+ let attempt = 0;
31
+
32
+ while (isEmpty(result) && attempt < maxRetries) {
33
+ attempt++;
34
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
35
+ result = await operation();
36
+ }
37
+
38
+ return result;
39
+ }
@@ -103,12 +103,12 @@ export function registerLspTool(pi: ExtensionAPI, mgr: ServerManager) {
103
103
  ' findReferences — find all references to a symbol',
104
104
  ' hover — get type info and documentation for a symbol',
105
105
  ' diagnostics — get type errors and lint warnings for a file',
106
- ' documentSymbol — get all symbols in a file',
106
+ ' documentSymbol — get all symbols in a file (with line:column positions)',
107
107
  ' workspaceSymbol — search for symbols across the workspace',
108
108
  ' goToImplementation — find implementations of an interface/abstract method',
109
109
  ' prepareCallHierarchy — get call hierarchy item at a position',
110
- ' incomingCalls — find callers of a function/method',
111
- ' outgoingCalls — find callees of a function/method',
110
+ ' incomingCalls — find callers of a function/method (auto-prepares hierarchy)',
111
+ ' outgoingCalls — find callees of a function/method (auto-prepares hierarchy)',
112
112
  ' codeActions — get quick fixes and refactoring suggestions',
113
113
  '',
114
114
  'Parameters:',
@@ -117,14 +117,24 @@ export function registerLspTool(pi: ExtensionAPI, mgr: ServerManager) {
117
117
  ' line — line number, 1-indexed (required for position-based operations)',
118
118
  ' character — column number, 1-indexed (required for position-based operations)',
119
119
  ' query — search string (required for workspaceSymbol)',
120
+ '',
121
+ 'Tips:',
122
+ ' — Position the character in the middle of the symbol name for best results.',
123
+ ' — Use hover before goToDefinition to quickly check signatures and docs.',
124
+ ' — workspaceSymbol may need a retry if the server is still indexing.',
120
125
  ].join('\n'),
121
126
  promptSnippet:
122
127
  'Interact with LSP servers for code intelligence: definitions, references, hover, diagnostics, symbols, call hierarchy, code actions',
123
128
  promptGuidelines: [
124
- 'Use `diagnostics` after editing files to check for type errors and lint issues.',
125
- 'Use `hover` to understand types, `goToDefinition` to navigate, `findReferences` before refactoring.',
126
- 'Line and character are 1-indexed — use the line numbers shown by the read tool.',
127
- 'LSP servers are auto-detected by file extension. Use /lsp to check status.',
129
+ 'lsp line and character params are 1-indexed use the values from the read tool or rg output directly.',
130
+ 'lsp `hover` is the fastest way to get a function signature, type params, and doc comment — prefer it over `goToDefinition` for quick type inspection.',
131
+ 'lsp `documentSymbol` returns line:column positions for each symbol — use those values directly for follow-up lsp operations.',
132
+ 'For lsp position-based operations, place the character in the **middle** of the symbol name, not at the first character.',
133
+ 'lsp `incomingCalls` and `outgoingCalls` automatically prepare the call hierarchy — no need to call `prepareCallHierarchy` first.',
134
+ 'lsp `workspaceSymbol` may return empty results while the LSP server is still indexing. If it returns nothing, wait a few seconds and retry.',
135
+ 'lsp `diagnostics` relies on server-pushed notifications which may be slow for some servers. For compiled languages (Rust, Go, C++), prefer running the compiler directly (e.g. `cargo check`, `go build`) for reliable error checking.',
136
+ 'Use lsp for type info, macro-generated symbols, and cross-module navigation. Use rg for simple text search and file discovery — it is faster and needs no server.',
137
+ 'lsp servers are auto-detected by file extension. Use /lsp to check status.',
128
138
  ],
129
139
  parameters: Type.Object({
130
140
  operation: StringEnum(LSP_OPERATIONS),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-lsp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Language-agnostic LSP code intelligence for pi — diagnostics, hover, definitions, references, symbols, and call hierarchy",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -96,8 +96,8 @@ describe('formatting', () => {
96
96
  '/repo',
97
97
  );
98
98
  expect(docSymbols).toContain('Symbols in src/foo.ts');
99
- expect(docSymbols).toContain('Foo (class)');
100
- expect(docSymbols).toContain('bar (method)');
99
+ expect(docSymbols).toContain('Foo (class) line 1:7');
100
+ expect(docSymbols).toContain('bar (method) line 3:3');
101
101
 
102
102
  const wsSymbols = formatWorkspaceSymbols(
103
103
  [
@@ -115,7 +115,7 @@ describe('formatting', () => {
115
115
  '/repo',
116
116
  );
117
117
  expect(wsSymbols).toContain('Workspace symbols matching "Foo"');
118
- expect(wsSymbols).toContain('FooService (class) src/service.ts:12 in services');
118
+ expect(wsSymbols).toContain('FooService (class) src/service.ts:12:1 in services');
119
119
  });
120
120
 
121
121
  test('formats call hierarchy and code actions', () => {
@@ -243,20 +243,33 @@ function handleRequest(req: JsonRpcRequest) {
243
243
  }
244
244
  }
245
245
 
246
- function handleNotification(msg: JsonRpcNotification) {
247
- if (msg.method === 'textDocument/didOpen' || msg.method === 'textDocument/didChange') {
248
- const textDocument =
249
- msg.method === 'textDocument/didOpen' ? msg.params?.textDocument : msg.params?.textDocument;
250
- const text =
251
- msg.method === 'textDocument/didOpen'
252
- ? (msg.params?.textDocument?.text ?? '')
253
- : (msg.params?.contentChanges?.[0]?.text ?? '');
254
- lastUri = textDocument?.uri ?? lastUri;
255
- sendDiagnostics(lastUri, text);
256
- }
246
+ function handleDocSync(uri: string, text: string) {
247
+ lastUri = uri;
248
+ sendDiagnostics(lastUri, text);
249
+ }
257
250
 
258
- if (msg.method === 'exit') {
259
- process.exit(0);
251
+ function handleNotification(msg: JsonRpcNotification) {
252
+ switch (msg.method) {
253
+ case 'textDocument/didOpen':
254
+ handleDocSync(
255
+ msg.params?.textDocument?.uri ?? lastUri,
256
+ msg.params?.textDocument?.text ?? '',
257
+ );
258
+ break;
259
+ case 'textDocument/didChange':
260
+ handleDocSync(
261
+ msg.params?.textDocument?.uri ?? lastUri,
262
+ msg.params?.contentChanges?.[0]?.text ?? '',
263
+ );
264
+ break;
265
+ case 'textDocument/didSave':
266
+ handleDocSync(
267
+ msg.params?.textDocument?.uri ?? lastUri,
268
+ msg.params?.text ?? '',
269
+ );
270
+ break;
271
+ case 'exit':
272
+ process.exit(0);
260
273
  }
261
274
  }
262
275
 
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { withRetry } from '../extensions/lsp/retry';
4
+
5
+ describe('withRetry', () => {
6
+ test('returns immediately when result is not empty', async () => {
7
+ let callCount = 0;
8
+ const result = await withRetry(
9
+ async () => {
10
+ callCount++;
11
+ return [1, 2, 3];
12
+ },
13
+ (r) => r.length === 0,
14
+ { maxRetries: 2, delayMs: 10 },
15
+ );
16
+
17
+ expect(result).toEqual([1, 2, 3]);
18
+ expect(callCount).toBe(1);
19
+ });
20
+
21
+ test('retries until result is not empty', async () => {
22
+ let callCount = 0;
23
+ const result = await withRetry(
24
+ async () => {
25
+ callCount++;
26
+ return callCount >= 3 ? ['found'] : [];
27
+ },
28
+ (r) => r.length === 0,
29
+ { maxRetries: 3, delayMs: 10 },
30
+ );
31
+
32
+ expect(result).toEqual(['found']);
33
+ expect(callCount).toBe(3);
34
+ });
35
+
36
+ test('stops at maxRetries and returns last empty result', async () => {
37
+ let callCount = 0;
38
+ const result = await withRetry(
39
+ async () => {
40
+ callCount++;
41
+ return [];
42
+ },
43
+ (r) => r.length === 0,
44
+ { maxRetries: 2, delayMs: 10 },
45
+ );
46
+
47
+ expect(result).toEqual([]);
48
+ expect(callCount).toBe(3); // 1 initial + 2 retries
49
+ });
50
+
51
+ test('works with null isEmpty check', async () => {
52
+ let callCount = 0;
53
+ const result = await withRetry(
54
+ async () => {
55
+ callCount++;
56
+ return callCount >= 2 ? { value: 'ok' } : null;
57
+ },
58
+ (r) => r === null,
59
+ { maxRetries: 3, delayMs: 10 },
60
+ );
61
+
62
+ expect(result).toEqual({ value: 'ok' });
63
+ expect(callCount).toBe(2);
64
+ });
65
+
66
+ test('uses default options when not provided', async () => {
67
+ let callCount = 0;
68
+ const result = await withRetry(
69
+ async () => {
70
+ callCount++;
71
+ return 'immediate';
72
+ },
73
+ (r) => r === '',
74
+ );
75
+
76
+ expect(result).toBe('immediate');
77
+ expect(callCount).toBe(1);
78
+ });
79
+ });
@@ -3,6 +3,16 @@ import { describe, expect, test } from 'bun:test';
3
3
  import { registerLspTool } from '../extensions/lsp/tools';
4
4
  import type { ToolDefinition } from '@earendil-works/pi-coding-agent';
5
5
 
6
+ function emptyManager(overrides?: Record<string, any>) {
7
+ return {
8
+ clientsForFile: () => [],
9
+ clientForFileWithCapability: () => null,
10
+ anyClient: () => null,
11
+ getRootPath: () => '/repo',
12
+ ...overrides,
13
+ };
14
+ }
15
+
6
16
  function captureTool() {
7
17
  let tool: ToolDefinition<any, any> | null = null;
8
18
  const fakePi = {
@@ -20,14 +30,22 @@ function captureTool() {
20
30
  }
21
31
 
22
32
  describe('unified lsp tool dispatch', () => {
33
+ test('all promptGuidelines mention lsp tool by name', () => {
34
+ const { register } = captureTool();
35
+ const tool = register(emptyManager());
36
+
37
+ const guidelines = (tool as any).promptGuidelines as string[] | undefined;
38
+ expect(guidelines).toBeTruthy();
39
+ expect(guidelines!.length).toBeGreaterThan(0);
40
+
41
+ for (const guideline of guidelines!) {
42
+ expect(guideline.toLowerCase()).toContain('lsp');
43
+ }
44
+ });
45
+
23
46
  test('validates required params by operation', async () => {
24
47
  const { register } = captureTool();
25
- const tool = register({
26
- clientsForFile: () => [],
27
- clientForFileWithCapability: () => null,
28
- anyClient: () => null,
29
- getRootPath: () => '/repo',
30
- });
48
+ const tool = register(emptyManager());
31
49
 
32
50
  await expect(
33
51
  tool.execute('1', { operation: 'hover' }, undefined as any, undefined, {} as any),
@@ -40,35 +58,34 @@ describe('unified lsp tool dispatch', () => {
40
58
 
41
59
  test('aggregates diagnostics from all matching clients', async () => {
42
60
  const { register } = captureTool();
43
- const tool = register({
44
- clientsForFile: () => [
45
- {
46
- config: { name: 'ts' },
47
- getDiagnostics: async () => [
48
- {
49
- range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } },
50
- severity: 1,
51
- source: 'ts',
52
- message: 'Type error',
53
- },
54
- ],
55
- },
56
- {
57
- config: { name: 'eslint' },
58
- getDiagnostics: async () => [
59
- {
60
- range: { start: { line: 1, character: 0 }, end: { line: 1, character: 3 } },
61
- severity: 2,
62
- source: 'eslint',
63
- message: 'Lint warning',
64
- },
65
- ],
66
- },
67
- ],
68
- clientForFileWithCapability: () => null,
69
- anyClient: () => null,
70
- getRootPath: () => '/repo',
71
- });
61
+ const tool = register(
62
+ emptyManager({
63
+ clientsForFile: () => [
64
+ {
65
+ config: { name: 'ts' },
66
+ getDiagnostics: async () => [
67
+ {
68
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } },
69
+ severity: 1,
70
+ source: 'ts',
71
+ message: 'Type error',
72
+ },
73
+ ],
74
+ },
75
+ {
76
+ config: { name: 'eslint' },
77
+ getDiagnostics: async () => [
78
+ {
79
+ range: { start: { line: 1, character: 0 }, end: { line: 1, character: 3 } },
80
+ severity: 2,
81
+ source: 'eslint',
82
+ message: 'Lint warning',
83
+ },
84
+ ],
85
+ },
86
+ ],
87
+ }),
88
+ );
72
89
 
73
90
  const result = await tool.execute(
74
91
  '1',
@@ -91,17 +108,16 @@ describe('unified lsp tool dispatch', () => {
91
108
  test('routes hover to first capable server and converts positions to zero-indexed', async () => {
92
109
  const calls: any[] = [];
93
110
  const { register } = captureTool();
94
- const tool = register({
95
- clientsForFile: () => [],
96
- clientForFileWithCapability: () => ({
97
- hover: async (filePath: string, pos: { line: number; character: number }) => {
98
- calls.push({ filePath, pos });
99
- return { contents: { kind: 'markdown', value: 'mock hover' } };
100
- },
111
+ const tool = register(
112
+ emptyManager({
113
+ clientForFileWithCapability: () => ({
114
+ hover: async (filePath: string, pos: { line: number; character: number }) => {
115
+ calls.push({ filePath, pos });
116
+ return { contents: { kind: 'markdown', value: 'mock hover' } };
117
+ },
118
+ }),
101
119
  }),
102
- anyClient: () => null,
103
- getRootPath: () => '/repo',
104
- });
120
+ );
105
121
 
106
122
  const result = await tool.execute(
107
123
  '1',
@@ -119,11 +135,10 @@ describe('unified lsp tool dispatch', () => {
119
135
 
120
136
  test('routes workspaceSymbol through anyClient', async () => {
121
137
  const { register } = captureTool();
122
- const tool = register({
123
- clientsForFile: () => [],
124
- clientForFileWithCapability: () => null,
125
- anyClient: () => ({
126
- workspaceSymbol: async (query: string) => [
138
+ const tool = register(
139
+ emptyManager({
140
+ anyClient: () => ({
141
+ workspaceSymbol: async (query: string) => [
127
142
  {
128
143
  name: `${query}Service`,
129
144
  kind: 5,
@@ -135,8 +150,8 @@ describe('unified lsp tool dispatch', () => {
135
150
  },
136
151
  ],
137
152
  }),
138
- getRootPath: () => '/repo',
139
- });
153
+ }),
154
+ );
140
155
 
141
156
  const result = await tool.execute(
142
157
  '1',
@@ -154,12 +169,7 @@ describe('unified lsp tool dispatch', () => {
154
169
 
155
170
  test('errors when no capable server is found', async () => {
156
171
  const { register } = captureTool();
157
- const tool = register({
158
- clientsForFile: () => [],
159
- clientForFileWithCapability: () => null,
160
- anyClient: () => null,
161
- getRootPath: () => '/repo',
162
- });
172
+ const tool = register(emptyManager());
163
173
 
164
174
  await expect(
165
175
  tool.execute(
@@ -175,29 +185,28 @@ describe('unified lsp tool dispatch', () => {
175
185
  test('codeActions filters diagnostics to the requested line', async () => {
176
186
  const { register } = captureTool();
177
187
  const seenContexts: any[] = [];
178
- const tool = register({
179
- clientsForFile: () => [],
180
- clientForFileWithCapability: () => ({
181
- getDiagnostics: async () => [
182
- {
183
- range: { start: { line: 2, character: 0 }, end: { line: 2, character: 10 } },
184
- severity: 1,
185
- message: 'line 3 issue',
186
- },
187
- {
188
- range: { start: { line: 5, character: 0 }, end: { line: 5, character: 10 } },
189
- severity: 1,
190
- message: 'line 6 issue',
188
+ const tool = register(
189
+ emptyManager({
190
+ clientForFileWithCapability: () => ({
191
+ getDiagnostics: async () => [
192
+ {
193
+ range: { start: { line: 2, character: 0 }, end: { line: 2, character: 10 } },
194
+ severity: 1,
195
+ message: 'line 3 issue',
196
+ },
197
+ {
198
+ range: { start: { line: 5, character: 0 }, end: { line: 5, character: 10 } },
199
+ severity: 1,
200
+ message: 'line 6 issue',
201
+ },
202
+ ],
203
+ codeActions: async (_filePath: string, range: any, context: any) => {
204
+ seenContexts.push({ range, context });
205
+ return [{ title: 'Fix it', kind: 'quickfix' }];
191
206
  },
192
- ],
193
- codeActions: async (_filePath: string, range: any, context: any) => {
194
- seenContexts.push({ range, context });
195
- return [{ title: 'Fix it', kind: 'quickfix' }];
196
- },
207
+ }),
197
208
  }),
198
- anyClient: () => null,
199
- getRootPath: () => '/repo',
200
- });
209
+ );
201
210
 
202
211
  const result = await tool.execute(
203
212
  '1',
@@ -6,6 +6,19 @@ import { fileURLToPath } from 'node:url';
6
6
 
7
7
  import { LspClient } from '../extensions/lsp/client';
8
8
 
9
+ /** Resolve a package bin using Node/Bun module resolution (handles hoisting). */
10
+ function resolveBin(pkg: string): string | null {
11
+ try {
12
+ const pkgJsonPath = fileURLToPath(import.meta.resolve(`${pkg}/package.json`));
13
+ const pkgJson = require(pkgJsonPath);
14
+ const bin = typeof pkgJson.bin === 'string' ? pkgJson.bin : pkgJson.bin?.[pkg];
15
+ if (!bin) return null;
16
+ return resolve(dirname(pkgJsonPath), bin);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
9
22
  const cleanup: string[] = [];
10
23
 
11
24
  async function makeFixtureWorkspace() {
@@ -80,8 +93,11 @@ afterEach(async () => {
80
93
  describe('real TypeScript integration', () => {
81
94
  test('typescript-language-server resolves diagnostics and navigation in a fixture workspace', async () => {
82
95
  const workspace = await makeFixtureWorkspace();
83
- const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
84
- const tsServerBin = join(packageRoot, 'node_modules', '.bin', 'typescript-language-server');
96
+ const tsServerBin = resolveBin('typescript-language-server');
97
+ if (!tsServerBin) {
98
+ console.log('Skipping: typescript-language-server not found');
99
+ return;
100
+ }
85
101
 
86
102
  const client = new LspClient(
87
103
  {