@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 +38 -0
- package/extensions/lsp/client.ts +181 -85
- package/extensions/lsp/config.ts +2 -1
- package/extensions/lsp/formatting.ts +6 -3
- package/extensions/lsp/retry.ts +39 -0
- package/extensions/lsp/tools.ts +17 -7
- package/package.json +1 -1
- package/test/formatting.test.ts +3 -3
- package/test/mock-lsp-server.ts +26 -13
- package/test/retry.test.ts +79 -0
- package/test/tools.test.ts +88 -79
- package/test/typescript.integration.test.ts +18 -2
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
|
package/extensions/lsp/client.ts
CHANGED
|
@@ -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
|
-
|
|
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))
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
321
|
-
for (const
|
|
322
|
-
|
|
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.
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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 ────────────────────────────────────────────────────
|
package/extensions/lsp/config.ts
CHANGED
|
@@ -21,7 +21,8 @@ import type { LspConfigFile, LspServerUserConfig, ResolvedServerConfig } from '.
|
|
|
21
21
|
// ── Paths ───────────────────────────────────────────────────────────────────
|
|
22
22
|
|
|
23
23
|
function globalConfigPath(): string {
|
|
24
|
-
|
|
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
|
+
}
|
package/extensions/lsp/tools.ts
CHANGED
|
@@ -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
|
-
'
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
'
|
|
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
package/test/formatting.test.ts
CHANGED
|
@@ -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', () => {
|
package/test/mock-lsp-server.ts
CHANGED
|
@@ -243,20 +243,33 @@ function handleRequest(req: JsonRpcRequest) {
|
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
function
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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
|
+
});
|
package/test/tools.test.ts
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
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
|
{
|