pi-lsp-adapter 0.1.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.
@@ -0,0 +1,594 @@
1
+ import type {
2
+ Definition,
3
+ Diagnostic,
4
+ DocumentSymbol,
5
+ Hover,
6
+ Location,
7
+ LocationLink,
8
+ MarkupContent,
9
+ Range,
10
+ SymbolInformation,
11
+ WorkspaceSymbol,
12
+ } from "vscode-languageserver-protocol";
13
+ import { URI } from "vscode-uri";
14
+ import { messageFromError as baseMessageFromError } from "../util/helpers.js";
15
+ import type { LspDiagnosticsResult } from "../lsp/client.js";
16
+ import type { LspRuntimeFileResult, LspWorkspaceSymbolsResult } from "../lsp/runtimeManager.js";
17
+ import type { LspResultCache } from "./resultCache.js";
18
+
19
+ export interface LspToolResult<TDetails> {
20
+ content: [{ type: "text"; text: string }];
21
+ details: TDetails;
22
+ }
23
+
24
+ export interface FormatOptions {
25
+ pageSize?: number;
26
+ }
27
+
28
+ export interface PageMetadata<TItem> {
29
+ ok: true;
30
+ kind: string;
31
+ total: number;
32
+ shown: number;
33
+ page: { start: number; end: number };
34
+ hasMore: boolean;
35
+ omitted: number;
36
+ items: TItem[];
37
+ serverId?: string;
38
+ rootDir?: string;
39
+ filePath?: string;
40
+ resultId?: string;
41
+ }
42
+
43
+ export interface PositionInput {
44
+ line: number;
45
+ column: number;
46
+ }
47
+
48
+ export interface LspPosition {
49
+ line: number;
50
+ character: number;
51
+ }
52
+
53
+ export interface DisplayPosition {
54
+ line: number;
55
+ column: number;
56
+ }
57
+
58
+ export interface DisplayRange {
59
+ start: DisplayPosition;
60
+ end: DisplayPosition;
61
+ }
62
+
63
+ export interface DisplayLocation {
64
+ uri: string;
65
+ filePath?: string;
66
+ range?: DisplayRange;
67
+ }
68
+
69
+ export interface NormalizedDiagnostic {
70
+ severity: string;
71
+ message: string;
72
+ range: DisplayRange;
73
+ source?: string;
74
+ code?: string | number;
75
+ }
76
+
77
+ export interface NormalizedSymbol {
78
+ name: string;
79
+ kind: string;
80
+ range?: DisplayRange;
81
+ selectionRange?: DisplayRange;
82
+ location?: DisplayLocation;
83
+ containerName?: string;
84
+ depth?: number;
85
+ }
86
+
87
+ export function toLspPosition(input: PositionInput): LspPosition {
88
+ return {
89
+ line: Math.max(0, Math.trunc(input.line) - 1),
90
+ character: Math.max(0, Math.trunc(input.column) - 1),
91
+ };
92
+ }
93
+
94
+ export function success<TDetails>(text: string, details: TDetails): LspToolResult<TDetails> {
95
+ return { content: [{ type: "text", text }], details };
96
+ }
97
+
98
+ export function failure(tool: string, error: unknown): LspToolResult<{ ok: false; tool: string; error: string }> {
99
+ return success(`${tool} failed: ${messageFromError(error)}`, { ok: false, tool, error: messageFromError(error) });
100
+ }
101
+
102
+ export function formatDiagnostics(
103
+ result: LspDiagnosticsResult,
104
+ cache?: LspResultCache,
105
+ options: FormatOptions = {},
106
+ ): LspToolResult<PageMetadata<NormalizedDiagnostic>> {
107
+ const diagnostics = result.diagnostics.map(normalizeDiagnostic).sort(compareDiagnostics);
108
+ return paginateItems({
109
+ kind: "diagnostics",
110
+ title: `LSP diagnostics for ${result.filePath} (${result.serverId}):`,
111
+ emptyText: `No LSP diagnostics for ${result.filePath}.`,
112
+ items: diagnostics,
113
+ pageSize: options.pageSize ?? 50,
114
+ cache,
115
+ baseDetails: {
116
+ serverId: result.serverId,
117
+ rootDir: result.rootDir,
118
+ filePath: result.filePath,
119
+ },
120
+ formatItem: (diagnostic) =>
121
+ `- ${diagnostic.severity} ${formatRangeStart(diagnostic.range)} ${diagnostic.message}${formatSource(diagnostic)}`,
122
+ });
123
+ }
124
+
125
+ export function formatHover(result: LspRuntimeFileResult<Hover | null>): LspToolResult<{
126
+ ok: true;
127
+ serverId: string;
128
+ rootDir: string;
129
+ filePath: string;
130
+ hover: string | null;
131
+ range?: DisplayRange;
132
+ }> {
133
+ const hoverText = result.result ? hoverToText(result.result) : "";
134
+ const range = result.result?.range ? displayRange(result.result.range) : undefined;
135
+ return success(
136
+ hoverText ? `LSP hover for ${result.filePath}:\n${hoverText}` : `No LSP hover result for ${result.filePath}.`,
137
+ {
138
+ ok: true,
139
+ serverId: result.serverId,
140
+ rootDir: result.rootDir,
141
+ filePath: result.filePath,
142
+ hover: hoverText || null,
143
+ ...(range ? { range } : {}),
144
+ },
145
+ );
146
+ }
147
+
148
+ export function formatDefinition(
149
+ result: LspRuntimeFileResult<Definition | LocationLink[] | null>,
150
+ cache?: LspResultCache,
151
+ options: FormatOptions = {},
152
+ ): LspToolResult<PageMetadata<DisplayLocation>> {
153
+ const locations = normalizeDefinition(result.result).sort(compareLocations(result.filePath));
154
+ return formatPaginatedLocations("definition", result, locations, cache, options.pageSize ?? 25);
155
+ }
156
+
157
+ export function formatReferences(
158
+ result: LspRuntimeFileResult<Location[] | null>,
159
+ cache?: LspResultCache,
160
+ options: FormatOptions = {},
161
+ ): LspToolResult<PageMetadata<DisplayLocation>> {
162
+ const locations = (result.result ?? []).map(normalizeLocation).sort(compareLocations(result.filePath));
163
+ return formatPaginatedLocations("reference", result, locations, cache, options.pageSize ?? 25);
164
+ }
165
+
166
+ export function formatDocumentSymbols(
167
+ result: LspRuntimeFileResult<DocumentSymbol[] | SymbolInformation[] | null>,
168
+ cache?: LspResultCache,
169
+ options: FormatOptions = {},
170
+ ): LspToolResult<PageMetadata<NormalizedSymbol>> {
171
+ const symbols = normalizeDocumentSymbols(result.result ?? []);
172
+ return paginateItems({
173
+ kind: "document_symbols",
174
+ title: `LSP document symbols for ${result.filePath} (${result.serverId}):`,
175
+ emptyText: `No LSP document symbols for ${result.filePath}.`,
176
+ items: symbols,
177
+ pageSize: options.pageSize ?? 80,
178
+ cache,
179
+ baseDetails: {
180
+ serverId: result.serverId,
181
+ rootDir: result.rootDir,
182
+ filePath: result.filePath,
183
+ },
184
+ formatItem: (symbol) => {
185
+ const indent = " ".repeat(symbol.depth ?? 0);
186
+ const location = symbol.selectionRange ?? symbol.range;
187
+ return `${indent}- ${symbol.name} (${symbol.kind})${location ? ` ${formatRangeStart(location)}` : ""}`;
188
+ },
189
+ });
190
+ }
191
+
192
+ export function formatWorkspaceSymbols(
193
+ results: LspWorkspaceSymbolsResult[],
194
+ cache?: LspResultCache,
195
+ options: FormatOptions & { query?: string } = {},
196
+ ): LspToolResult<PageMetadata<NormalizedSymbol & { serverId: string; rootDir: string }>> {
197
+ const symbols = results.flatMap((entry) =>
198
+ normalizeWorkspaceSymbols(entry.result ?? []).map((symbol) => ({
199
+ ...symbol,
200
+ serverId: entry.serverId,
201
+ rootDir: entry.rootDir,
202
+ })),
203
+ );
204
+ const ranked = symbols.sort(compareWorkspaceSymbols(options.query ?? ""));
205
+
206
+ return paginateItems({
207
+ kind: "workspace_symbols",
208
+ title: `LSP workspace symbols (${ranked.length}):`,
209
+ emptyText: "No LSP workspace symbols found.",
210
+ items: ranked,
211
+ pageSize: options.pageSize ?? 50,
212
+ cache,
213
+ baseDetails: {},
214
+ formatItem: (symbol) => {
215
+ const where = symbol.location?.range ? ` ${formatRangeStart(symbol.location.range)}` : "";
216
+ const path = symbol.location?.filePath ?? symbol.location?.uri;
217
+ return `- ${symbol.name} (${symbol.kind}) ${symbol.serverId}${path ? ` ${path}` : ""}${where}`;
218
+ },
219
+ });
220
+ }
221
+
222
+ function paginateItems<TItem>(input: {
223
+ kind: string;
224
+ title: string;
225
+ emptyText: string;
226
+ items: TItem[];
227
+ pageSize: number;
228
+ cache?: LspResultCache;
229
+ baseDetails: Pick<PageMetadata<TItem>, "serverId" | "rootDir" | "filePath">;
230
+ formatItem: (item: TItem) => string;
231
+ }): LspToolResult<PageMetadata<TItem>> {
232
+ if (input.items.length === 0) {
233
+ return success(input.emptyText, {
234
+ ...input.baseDetails,
235
+ ok: true,
236
+ kind: input.kind,
237
+ total: 0,
238
+ shown: 0,
239
+ page: { start: 0, end: 0 },
240
+ hasMore: false,
241
+ omitted: 0,
242
+ items: [],
243
+ });
244
+ }
245
+
246
+ const pageSize = Math.max(1, Math.trunc(input.pageSize));
247
+ const pages = chunk(input.items, pageSize).map((items, index) => {
248
+ const start = index * pageSize + 1;
249
+ const end = start + items.length - 1;
250
+ const omitted = input.items.length - end;
251
+ const details: PageMetadata<TItem> = {
252
+ ...input.baseDetails,
253
+ ok: true,
254
+ kind: input.kind,
255
+ total: input.items.length,
256
+ shown: items.length,
257
+ page: { start, end },
258
+ hasMore: false,
259
+ omitted,
260
+ items,
261
+ };
262
+ const lines = [input.title, `Showing ${start}-${end} of ${input.items.length}.`];
263
+ for (const item of items) lines.push(input.formatItem(item));
264
+ return { text: lines.join("\n"), details };
265
+ });
266
+
267
+ const resultId = input.cache?.store({ label: input.kind, pages });
268
+ if (resultId) {
269
+ for (const page of pages) {
270
+ if (page.details.omitted <= 0) continue;
271
+ page.details.resultId = resultId;
272
+ page.details.hasMore = true;
273
+ page.text = `${page.text}\nMore available: call lsp_more with resultId: ${resultId}`;
274
+ }
275
+ } else if (pages.length > 1) {
276
+ const first = pages[0]!;
277
+ first.text = `${first.text}\nAdditional results omitted because the LSP pagination cache could not store this result set.`;
278
+ }
279
+
280
+ const first = pages[0]!;
281
+ return success(first.text, first.details);
282
+ }
283
+
284
+ function formatPaginatedLocations(
285
+ label: "definition" | "reference",
286
+ result: LspRuntimeFileResult<unknown>,
287
+ locations: DisplayLocation[],
288
+ cache: LspResultCache | undefined,
289
+ pageSize: number,
290
+ ): LspToolResult<PageMetadata<DisplayLocation>> {
291
+ const plural = locations.length === 1 ? label : `${label}s`;
292
+ return paginateItems({
293
+ kind: plural,
294
+ title: `LSP ${plural} for ${result.filePath} (${result.serverId}):`,
295
+ emptyText: `No LSP ${label} locations for ${result.filePath}.`,
296
+ items: locations,
297
+ pageSize,
298
+ cache,
299
+ baseDetails: {
300
+ serverId: result.serverId,
301
+ rootDir: result.rootDir,
302
+ filePath: result.filePath,
303
+ },
304
+ formatItem: (location) => {
305
+ const where = location.range ? ` ${formatRangeStart(location.range)}` : "";
306
+ return `- ${location.filePath ?? location.uri}${where}`;
307
+ },
308
+ });
309
+ }
310
+
311
+ function chunk<T>(items: T[], size: number): T[][] {
312
+ const pages: T[][] = [];
313
+ for (let index = 0; index < items.length; index += size) pages.push(items.slice(index, index + size));
314
+ return pages;
315
+ }
316
+
317
+ function normalizeDiagnostic(diagnostic: Diagnostic): NormalizedDiagnostic {
318
+ return {
319
+ severity: diagnosticSeverityName(diagnostic.severity),
320
+ message: diagnostic.message,
321
+ range: displayRange(diagnostic.range),
322
+ ...(diagnostic.source ? { source: diagnostic.source } : {}),
323
+ ...(diagnostic.code !== undefined ? { code: diagnostic.code } : {}),
324
+ };
325
+ }
326
+
327
+ function normalizeDefinition(value: Definition | LocationLink[] | null): DisplayLocation[] {
328
+ if (!value) return [];
329
+ const values = Array.isArray(value) ? value : [value];
330
+ return values.map((entry) => (isLocationLink(entry) ? normalizeLocationLink(entry) : normalizeLocation(entry)));
331
+ }
332
+
333
+ function normalizeDocumentSymbols(values: DocumentSymbol[] | SymbolInformation[]): NormalizedSymbol[] {
334
+ const symbols: NormalizedSymbol[] = [];
335
+ for (const value of values) {
336
+ if (isDocumentSymbol(value)) appendDocumentSymbol(symbols, value, 0);
337
+ else symbols.push(normalizeSymbolInformation(value));
338
+ }
339
+ return symbols;
340
+ }
341
+
342
+ function normalizeWorkspaceSymbols(values: Array<SymbolInformation | WorkspaceSymbol>): NormalizedSymbol[] {
343
+ return values.map((symbol) => ({
344
+ name: symbol.name,
345
+ kind: symbolKindName(symbol.kind),
346
+ location: normalizeWorkspaceSymbolLocation(symbol.location),
347
+ ...(symbol.containerName ? { containerName: symbol.containerName } : {}),
348
+ }));
349
+ }
350
+
351
+ function appendDocumentSymbol(output: NormalizedSymbol[], symbol: DocumentSymbol, depth: number): void {
352
+ output.push({
353
+ name: symbol.name,
354
+ kind: symbolKindName(symbol.kind),
355
+ range: displayRange(symbol.range),
356
+ selectionRange: displayRange(symbol.selectionRange),
357
+ depth,
358
+ });
359
+ for (const child of symbol.children ?? []) appendDocumentSymbol(output, child, depth + 1);
360
+ }
361
+
362
+ function normalizeSymbolInformation(symbol: SymbolInformation): NormalizedSymbol {
363
+ return {
364
+ name: symbol.name,
365
+ kind: symbolKindName(symbol.kind),
366
+ location: normalizeLocation(symbol.location),
367
+ ...(symbol.containerName ? { containerName: symbol.containerName } : {}),
368
+ };
369
+ }
370
+
371
+ function normalizeLocation(location: Location): DisplayLocation {
372
+ return { uri: location.uri, filePath: uriToFilePath(location.uri), range: displayRange(location.range) };
373
+ }
374
+
375
+ function normalizeLocationLink(link: LocationLink): DisplayLocation {
376
+ return { uri: link.targetUri, filePath: uriToFilePath(link.targetUri), range: displayRange(link.targetRange) };
377
+ }
378
+
379
+ function normalizeWorkspaceSymbolLocation(location: WorkspaceSymbol["location"]): DisplayLocation {
380
+ if ("range" in location) return normalizeLocation(location);
381
+ return { uri: location.uri, filePath: uriToFilePath(location.uri) };
382
+ }
383
+
384
+ function hoverToText(hover: Hover): string {
385
+ return markedContentToText(hover.contents).trim();
386
+ }
387
+
388
+ function markedContentToText(value: unknown): string {
389
+ if (typeof value === "string") return value;
390
+ if (Array.isArray(value)) return value.map(markedContentToText).filter(Boolean).join("\n\n");
391
+
392
+ const objectValue = value as MarkupContent | { language?: unknown; value?: unknown };
393
+ if ("kind" in objectValue && typeof objectValue.value === "string") return objectValue.value;
394
+ const language = "language" in objectValue ? objectValue.language : undefined;
395
+ if (typeof language === "string" && typeof objectValue.value === "string") {
396
+ return `\`\`\`${language}\n${objectValue.value}\n\`\`\``;
397
+ }
398
+ return "";
399
+ }
400
+
401
+ function displayRange(range: Range): DisplayRange {
402
+ return {
403
+ start: { line: range.start.line + 1, column: range.start.character + 1 },
404
+ end: { line: range.end.line + 1, column: range.end.character + 1 },
405
+ };
406
+ }
407
+
408
+ function formatRangeStart(range: DisplayRange): string {
409
+ return `${range.start.line}:${range.start.column}`;
410
+ }
411
+
412
+ function formatSource(diagnostic: NormalizedDiagnostic): string {
413
+ const parts = [diagnostic.source, diagnostic.code === undefined ? undefined : String(diagnostic.code)].filter(
414
+ Boolean,
415
+ );
416
+ return parts.length === 0 ? "" : ` [${parts.join("/")}]`;
417
+ }
418
+
419
+ function compareDiagnostics(a: NormalizedDiagnostic, b: NormalizedDiagnostic): number {
420
+ const severity = diagnosticSeverityRank(a.severity) - diagnosticSeverityRank(b.severity);
421
+ if (severity !== 0) return severity;
422
+ return a.range.start.line - b.range.start.line || a.range.start.column - b.range.start.column;
423
+ }
424
+
425
+ function diagnosticSeverityName(severity: Diagnostic["severity"]): string {
426
+ switch (severity) {
427
+ case 1:
428
+ return "error";
429
+ case 2:
430
+ return "warning";
431
+ case 3:
432
+ return "information";
433
+ case 4:
434
+ return "hint";
435
+ default:
436
+ return "diagnostic";
437
+ }
438
+ }
439
+
440
+ function diagnosticSeverityRank(severity: string): number {
441
+ switch (severity) {
442
+ case "error":
443
+ return 0;
444
+ case "warning":
445
+ return 1;
446
+ case "information":
447
+ return 2;
448
+ case "hint":
449
+ return 3;
450
+ default:
451
+ return 4;
452
+ }
453
+ }
454
+
455
+ function compareLocations(anchorFilePath: string): (a: DisplayLocation, b: DisplayLocation) => number {
456
+ return (a, b) =>
457
+ locationScore(anchorFilePath, a) - locationScore(anchorFilePath, b) ||
458
+ (a.filePath ?? a.uri).localeCompare(b.filePath ?? b.uri) ||
459
+ (a.range?.start.line ?? 0) - (b.range?.start.line ?? 0) ||
460
+ (a.range?.start.column ?? 0) - (b.range?.start.column ?? 0);
461
+ }
462
+
463
+ function locationScore(anchorFilePath: string, location: DisplayLocation): number {
464
+ const path = location.filePath ?? location.uri;
465
+ let score = 0;
466
+ if (path !== anchorFilePath) score += 10;
467
+ if (isLowSignalPath(path)) score += 25;
468
+ return score;
469
+ }
470
+
471
+ function compareWorkspaceSymbols(query: string): (a: NormalizedSymbol, b: NormalizedSymbol) => number {
472
+ return (a, b) =>
473
+ workspaceSymbolScore(query, a) - workspaceSymbolScore(query, b) ||
474
+ a.name.localeCompare(b.name) ||
475
+ (a.location?.filePath ?? a.location?.uri ?? "").localeCompare(b.location?.filePath ?? b.location?.uri ?? "");
476
+ }
477
+
478
+ function workspaceSymbolScore(query: string, symbol: NormalizedSymbol): number {
479
+ const normalizedQuery = query.toLowerCase();
480
+ const normalizedName = symbol.name.toLowerCase();
481
+ let score = 0;
482
+ if (normalizedQuery) {
483
+ if (normalizedName === normalizedQuery) score -= 100;
484
+ else if (normalizedName.startsWith(normalizedQuery)) score -= 60;
485
+ else if (normalizedName.includes(normalizedQuery)) score -= 20;
486
+ }
487
+
488
+ const path = symbol.location?.filePath ?? symbol.location?.uri ?? "";
489
+ if (isLowSignalPath(path)) score += 25;
490
+ return score;
491
+ }
492
+
493
+ function isLowSignalPath(path: string): boolean {
494
+ return /(?:^|\/)(node_modules|dist|build|coverage|fixtures|\.next|generated)(?:\/|$)/u.test(path);
495
+ }
496
+
497
+ function symbolKindName(kind: number): string {
498
+ return SYMBOL_KIND_NAMES[kind] ?? `kind-${kind}`;
499
+ }
500
+
501
+ function isLocationLink(value: Location | LocationLink): value is LocationLink {
502
+ return "targetUri" in value;
503
+ }
504
+
505
+ function isDocumentSymbol(value: DocumentSymbol | SymbolInformation): value is DocumentSymbol {
506
+ return "selectionRange" in value;
507
+ }
508
+
509
+ function uriToFilePath(uri: string): string | undefined {
510
+ try {
511
+ const parsed = URI.parse(uri);
512
+ return parsed.scheme === "file" ? parsed.fsPath : undefined;
513
+ } catch {
514
+ return undefined;
515
+ }
516
+ }
517
+
518
+ function messageFromError(error: unknown): string {
519
+ return conciseExpectedError(baseMessageFromError(error));
520
+ }
521
+
522
+ function conciseExpectedError(message: string): string {
523
+ const trimmed = message.trim();
524
+ const firstLine = firstMeaningfulLine(trimmed);
525
+
526
+ const missingFile = /ENOENT: no such file or directory, open '([^']+)'/u.exec(trimmed);
527
+ if (missingFile) {
528
+ return `File not found: ${missingFile[1]}. Check filePath and try again.`;
529
+ }
530
+
531
+ if (/Bad line number|lineStarts\.length/iu.test(trimmed)) {
532
+ return "Position is outside the file. Use a valid 1-based line/column from the file and place the column on an identifier token.";
533
+ }
534
+
535
+ if (/outside .*: (line|column)|Invalid LSP position/iu.test(trimmed)) {
536
+ return firstLine;
537
+ }
538
+
539
+ if (/No LSP filetype detected/iu.test(trimmed)) {
540
+ return `${firstLine} Use a supported source file path.`;
541
+ }
542
+
543
+ if (/outside workspace/iu.test(trimmed)) {
544
+ return `${firstLine} Use a file under the current workspace.`;
545
+ }
546
+
547
+ if (/timed out after \d+ms/iu.test(trimmed)) {
548
+ return `${firstLine} Retry the request, or run /lsp restart if the language server stays stuck.`;
549
+ }
550
+
551
+ if (/does not support LSP/iu.test(trimmed)) {
552
+ return `${firstLine} Try a different LSP tool or server for this file.`;
553
+ }
554
+
555
+ if (/Request .* failed with message:/u.test(trimmed) && trimmed.includes("\n")) {
556
+ return firstLine;
557
+ }
558
+
559
+ return firstLine;
560
+ }
561
+
562
+ function firstMeaningfulLine(message: string): string {
563
+ const [firstLine = message] = message.split(/\r?\n/u).filter((line) => line.trim().length > 0);
564
+ return firstLine.trim().replace(/\.$/u, "");
565
+ }
566
+
567
+ const SYMBOL_KIND_NAMES: Record<number, string> = {
568
+ 1: "file",
569
+ 2: "module",
570
+ 3: "namespace",
571
+ 4: "package",
572
+ 5: "class",
573
+ 6: "method",
574
+ 7: "property",
575
+ 8: "field",
576
+ 9: "constructor",
577
+ 10: "enum",
578
+ 11: "interface",
579
+ 12: "function",
580
+ 13: "variable",
581
+ 14: "constant",
582
+ 15: "string",
583
+ 16: "number",
584
+ 17: "boolean",
585
+ 18: "array",
586
+ 19: "object",
587
+ 20: "key",
588
+ 21: "null",
589
+ 22: "enumMember",
590
+ 23: "struct",
591
+ 24: "event",
592
+ 25: "operator",
593
+ 26: "typeParameter",
594
+ };