@dreki-gg/pi-lsp 0.2.1 → 0.4.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,39 @@
1
1
  # @dreki-gg/pi-lsp
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a5e800f: refactor(lsp): adopt Effect and stop enabling TypeScript by default
8
+
9
+ - **Effect-based architecture** — config loading, scaffolding, and the unified `lsp` tool now run as Effect programs against injectable services (`FileSystem`, `CommandResolver`, `ServerManager`). Failures are modeled as `Data.TaggedError` types (`ConfigReadError`, `ConfigWriteError`, `LspValidationError`, `NoCapableServerError`, `NoServerAvailableError`, `LspOperationError`), mirroring the firestore package's conventions. Promise-returning wrappers keep the public API stable.
10
+ - **TypeScript is no longer a default server** — the scaffolded starter config now ships TypeScript, Python, Rust, and Go as `disabled` examples. No language server is spawned until the user explicitly opts in, so the extension never auto-starts `typescript-language-server` on a fresh setup.
11
+ - New service-injection tests cover config resolution (global/npx/unavailable command paths) and scaffolding without touching disk or the shell.
12
+
13
+ ## 0.3.0
14
+
15
+ ### Minor Changes
16
+
17
+ - [#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
18
+
19
+ - **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.
20
+ - **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.
21
+ - **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.
22
+ - **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.
23
+ - **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.
24
+ - **Enhanced tool description** — includes tips section and notes that `incomingCalls`/`outgoingCalls` auto-prepare the call hierarchy.
25
+ - **Removed unused code** — `pathToUri` export, `capabilities` getter, and `closeDocument` method removed.
26
+
27
+ ### Patch Changes
28
+
29
+ - [`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
30
+
31
+ - Add `normalizeUri()` to decode percent-encoded URIs (`%3A` → `:`) and uppercase Windows drive letters, fixing key mismatches between `pathToUri` and server responses.
32
+ - `pathToUri()` now always uppercases the drive letter for consistency.
33
+ - `uriToPath()` now applies `decodeURIComponent` so encoded URIs from the server produce valid file paths.
34
+ - Replace per-URI waiter arrays with a single `PendingDiagnostic` promise per URI, eliminating manual timer/splice management.
35
+ - 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).
36
+
3
37
  ## 0.2.1
4
38
 
5
39
  ### Patch Changes
package/README.md CHANGED
@@ -38,6 +38,12 @@ Servers are configured via two config files (project overrides global):
38
38
  | `~/.pi/agent/extensions/lsp/config.json` | Global defaults |
39
39
  | `.pi/lsp.json` | Project-local overrides |
40
40
 
41
+ On first run a starter `config.json` is scaffolded with example servers
42
+ (TypeScript, Python, Rust, Go) — **all `disabled` by default**. No language
43
+ server is enabled out of the box; flip `"disabled": false` (or remove the flag)
44
+ on the ones you want. This keeps the extension from spawning a server you never
45
+ asked for.
46
+
41
47
  ### Example: TypeScript + oxlint
42
48
 
43
49
  `.pi/lsp.json`:
@@ -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 ────────────────────────────────────────────────────