@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 +34 -0
- package/README.md +6 -0
- package/extensions/lsp/client.ts +181 -85
- package/extensions/lsp/config.ts +133 -111
- package/extensions/lsp/effects/command.ts +42 -0
- package/extensions/lsp/effects/filesystem.ts +50 -0
- package/extensions/lsp/effects/runtime.ts +21 -0
- package/extensions/lsp/errors.ts +113 -0
- package/extensions/lsp/formatting.ts +6 -3
- package/extensions/lsp/index.ts +24 -8
- package/extensions/lsp/retry.ts +39 -0
- package/extensions/lsp/tools/programs.ts +293 -0
- package/extensions/lsp/tools.ts +36 -238
- package/package.json +4 -1
- package/test/config.test.ts +34 -0
- package/test/effects.test.ts +107 -0
- package/test/formatting.test.ts +3 -3
- package/test/index.test.ts +3 -2
- package/test/mock-lsp-server.ts +20 -13
- package/test/retry.test.ts +79 -0
- package/test/tools.test.ts +97 -88
- package/test/typescript.integration.test.ts +18 -2
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`:
|
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 ────────────────────────────────────────────────────
|