lsp-pi 1.0.1
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/README.md +178 -0
- package/lsp-core.ts +1125 -0
- package/lsp-tool.ts +339 -0
- package/lsp.ts +575 -0
- package/package.json +46 -0
- package/tests/index.test.ts +235 -0
- package/tests/lsp-integration.test.ts +602 -0
- package/tests/lsp.test.ts +898 -0
- package/tsconfig.json +13 -0
package/lsp-tool.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP Tool Extension for pi-coding-agent
|
|
3
|
+
*
|
|
4
|
+
* Provides Language Server Protocol tool for:
|
|
5
|
+
* - definitions, references, hover, signature help
|
|
6
|
+
* - document symbols, diagnostics, workspace diagnostics
|
|
7
|
+
* - rename, code actions
|
|
8
|
+
*
|
|
9
|
+
* Supported languages:
|
|
10
|
+
* - Dart/Flutter (dart language-server)
|
|
11
|
+
* - TypeScript/JavaScript (typescript-language-server)
|
|
12
|
+
* - Vue (vue-language-server)
|
|
13
|
+
* - Svelte (svelteserver)
|
|
14
|
+
* - Python (pyright-langserver)
|
|
15
|
+
* - Go (gopls)
|
|
16
|
+
* - Kotlin (kotlin-ls)
|
|
17
|
+
* - Swift (sourcekit-lsp)
|
|
18
|
+
* - Rust (rust-analyzer)
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* pi --extension ./lsp-tool.ts
|
|
22
|
+
*
|
|
23
|
+
* Or use the combined lsp.ts extension for both hook and tool functionality.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
import { Type, type Static } from "@sinclair/typebox";
|
|
28
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
29
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
30
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
31
|
+
import { getOrCreateManager, formatDiagnostic, filterDiagnosticsBySeverity, uriToPath, resolvePosition, type SeverityFilter } from "./lsp-core.js";
|
|
32
|
+
|
|
33
|
+
const PREVIEW_LINES = 10;
|
|
34
|
+
|
|
35
|
+
const DIAGNOSTICS_WAIT_MS_DEFAULT = 3000;
|
|
36
|
+
|
|
37
|
+
function diagnosticsWaitMsForFile(filePath: string): number {
|
|
38
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
39
|
+
if (ext === ".kt" || ext === ".kts") return 30000;
|
|
40
|
+
if (ext === ".swift") return 20000;
|
|
41
|
+
if (ext === ".rs") return 20000;
|
|
42
|
+
return DIAGNOSTICS_WAIT_MS_DEFAULT;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ACTIONS = ["definition", "references", "hover", "symbols", "diagnostics", "workspace-diagnostics", "signature", "rename", "codeAction"] as const;
|
|
46
|
+
const SEVERITY_FILTERS = ["all", "error", "warning", "info", "hint"] as const;
|
|
47
|
+
|
|
48
|
+
const LspParams = Type.Object({
|
|
49
|
+
action: StringEnum(ACTIONS),
|
|
50
|
+
file: Type.Optional(Type.String({ description: "File path (required for most actions)" })),
|
|
51
|
+
files: Type.Optional(Type.Array(Type.String(), { description: "File paths for workspace-diagnostics" })),
|
|
52
|
+
line: Type.Optional(Type.Number({ description: "Line (1-indexed). Required for position-based actions unless query provided." })),
|
|
53
|
+
column: Type.Optional(Type.Number({ description: "Column (1-indexed). Required for position-based actions unless query provided." })),
|
|
54
|
+
endLine: Type.Optional(Type.Number({ description: "End line for range-based actions (codeAction)" })),
|
|
55
|
+
endColumn: Type.Optional(Type.Number({ description: "End column for range-based actions (codeAction)" })),
|
|
56
|
+
query: Type.Optional(Type.String({ description: "Symbol name filter (for symbols) or to resolve position (for definition/references/hover/signature)" })),
|
|
57
|
+
newName: Type.Optional(Type.String({ description: "New name for rename action" })),
|
|
58
|
+
severity: Type.Optional(StringEnum(SEVERITY_FILTERS, { description: 'Filter diagnostics: "all"|"error"|"warning"|"info"|"hint"' })),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
type LspParamsType = Static<typeof LspParams>;
|
|
62
|
+
|
|
63
|
+
function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
64
|
+
if (!signal) return promise;
|
|
65
|
+
if (signal.aborted) return Promise.reject(new Error("aborted"));
|
|
66
|
+
|
|
67
|
+
return new Promise<T>((resolve, reject) => {
|
|
68
|
+
const onAbort = () => {
|
|
69
|
+
cleanup();
|
|
70
|
+
reject(new Error("aborted"));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const cleanup = () => {
|
|
74
|
+
signal.removeEventListener("abort", onAbort);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
78
|
+
|
|
79
|
+
promise.then(
|
|
80
|
+
(value) => {
|
|
81
|
+
cleanup();
|
|
82
|
+
resolve(value);
|
|
83
|
+
},
|
|
84
|
+
(err) => {
|
|
85
|
+
cleanup();
|
|
86
|
+
reject(err);
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isAbortedError(e: unknown): boolean {
|
|
93
|
+
return e instanceof Error && e.message === "aborted";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cancelledToolResult() {
|
|
97
|
+
return {
|
|
98
|
+
content: [{ type: "text" as const, text: "Cancelled" }],
|
|
99
|
+
details: { cancelled: true },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatLocation(loc: { uri: string; range?: { start?: { line: number; character: number } } }, cwd?: string): string {
|
|
104
|
+
const abs = uriToPath(loc.uri);
|
|
105
|
+
const display = cwd && path.isAbsolute(abs) ? path.relative(cwd, abs) : abs;
|
|
106
|
+
const { line, character: col } = loc.range?.start ?? {};
|
|
107
|
+
return typeof line === "number" && typeof col === "number" ? `${display}:${line + 1}:${col + 1}` : display;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatHover(contents: unknown): string {
|
|
111
|
+
if (typeof contents === "string") return contents;
|
|
112
|
+
if (Array.isArray(contents)) return contents.map(c => typeof c === "string" ? c : (c as any)?.value ?? "").filter(Boolean).join("\n\n");
|
|
113
|
+
if (contents && typeof contents === "object" && "value" in contents) return String((contents as any).value);
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatSignature(help: any): string {
|
|
118
|
+
if (!help?.signatures?.length) return "No signature help available.";
|
|
119
|
+
const sig = help.signatures[help.activeSignature ?? 0] ?? help.signatures[0];
|
|
120
|
+
let text = sig.label ?? "Signature";
|
|
121
|
+
if (sig.documentation) text += `\n${typeof sig.documentation === "string" ? sig.documentation : sig.documentation?.value ?? ""}`;
|
|
122
|
+
if (sig.parameters?.length) {
|
|
123
|
+
const params = sig.parameters.map((p: any) => typeof p.label === "string" ? p.label : Array.isArray(p.label) ? p.label.join("-") : "").filter(Boolean);
|
|
124
|
+
if (params.length) text += `\nParameters: ${params.join(", ")}`;
|
|
125
|
+
}
|
|
126
|
+
return text;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function collectSymbols(symbols: any[], depth = 0, lines: string[] = [], query?: string): string[] {
|
|
130
|
+
for (const sym of symbols) {
|
|
131
|
+
const name = sym?.name ?? "<unknown>";
|
|
132
|
+
if (query && !name.toLowerCase().includes(query.toLowerCase())) {
|
|
133
|
+
if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const loc = sym?.range?.start ? `${sym.range.start.line + 1}:${sym.range.start.character + 1}` : "";
|
|
137
|
+
lines.push(`${" ".repeat(depth)}${name}${loc ? ` (${loc})` : ""}`);
|
|
138
|
+
if (sym.children?.length) collectSymbols(sym.children, depth + 1, lines, query);
|
|
139
|
+
}
|
|
140
|
+
return lines;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatWorkspaceEdit(edit: any, cwd?: string): string {
|
|
144
|
+
const lines: string[] = [];
|
|
145
|
+
|
|
146
|
+
if (edit.documentChanges?.length) {
|
|
147
|
+
for (const change of edit.documentChanges) {
|
|
148
|
+
if (change.textDocument?.uri) {
|
|
149
|
+
const fp = uriToPath(change.textDocument.uri);
|
|
150
|
+
const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
|
|
151
|
+
lines.push(`${display}:`);
|
|
152
|
+
for (const e of change.edits || []) {
|
|
153
|
+
const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
|
|
154
|
+
lines.push(` [${loc}] → "${e.newText}"`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (edit.changes) {
|
|
161
|
+
for (const [uri, edits] of Object.entries(edit.changes)) {
|
|
162
|
+
const fp = uriToPath(uri);
|
|
163
|
+
const display = cwd && path.isAbsolute(fp) ? path.relative(cwd, fp) : fp;
|
|
164
|
+
lines.push(`${display}:`);
|
|
165
|
+
for (const e of edits as any[]) {
|
|
166
|
+
const loc = `${e.range.start.line + 1}:${e.range.start.character + 1}`;
|
|
167
|
+
lines.push(` [${loc}] → "${e.newText}"`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return lines.length ? lines.join("\n") : "No edits.";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatCodeActions(actions: any[]): string[] {
|
|
176
|
+
return actions.map((a, i) => {
|
|
177
|
+
const title = a.title || a.command?.title || "Untitled action";
|
|
178
|
+
const kind = a.kind ? ` (${a.kind})` : "";
|
|
179
|
+
const isPreferred = a.isPreferred ? " ★" : "";
|
|
180
|
+
return `${i + 1}. ${title}${kind}${isPreferred}`;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export default function (pi: ExtensionAPI) {
|
|
185
|
+
pi.registerTool({
|
|
186
|
+
name: "lsp",
|
|
187
|
+
label: "LSP",
|
|
188
|
+
description: `Query language server for definitions, references, types, symbols, diagnostics, rename, and code actions.
|
|
189
|
+
|
|
190
|
+
Actions: definition, references, hover, signature, rename (require file + line/column or query), symbols (file, optional query), diagnostics (file), workspace-diagnostics (files array), codeAction (file + position).
|
|
191
|
+
Use bash to find files: find src -name "*.ts" -type f`,
|
|
192
|
+
parameters: LspParams,
|
|
193
|
+
|
|
194
|
+
async execute(_toolCallId, params, onUpdate, ctx, signal) {
|
|
195
|
+
if (signal?.aborted) return cancelledToolResult();
|
|
196
|
+
onUpdate?.({ content: [{ type: "text", text: "Working..." }], details: { status: "working" } });
|
|
197
|
+
|
|
198
|
+
const manager = getOrCreateManager(ctx.cwd);
|
|
199
|
+
const { action, file, files, line, column, endLine, endColumn, query, newName, severity } = params as LspParamsType;
|
|
200
|
+
const sevFilter: SeverityFilter = severity || "all";
|
|
201
|
+
const needsFile = action !== "workspace-diagnostics";
|
|
202
|
+
const needsPos = ["definition", "references", "hover", "signature", "rename", "codeAction"].includes(action);
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
if (needsFile && !file) throw new Error(`Action "${action}" requires a file path.`);
|
|
206
|
+
|
|
207
|
+
let rLine = line, rCol = column, fromQuery = false;
|
|
208
|
+
if (needsPos && (rLine === undefined || rCol === undefined) && query && file) {
|
|
209
|
+
const resolved = await abortable(resolvePosition(manager, file, query), signal);
|
|
210
|
+
if (resolved) { rLine = resolved.line; rCol = resolved.column; fromQuery = true; }
|
|
211
|
+
}
|
|
212
|
+
if (needsPos && (rLine === undefined || rCol === undefined)) {
|
|
213
|
+
throw new Error(`Action "${action}" requires line/column or a query matching a symbol.`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const qLine = query ? `query: ${query}\n` : "";
|
|
217
|
+
const sevLine = sevFilter !== "all" ? `severity: ${sevFilter}\n` : "";
|
|
218
|
+
const posLine = fromQuery && rLine && rCol ? `resolvedPosition: ${rLine}:${rCol}\n` : "";
|
|
219
|
+
|
|
220
|
+
switch (action) {
|
|
221
|
+
case "definition": {
|
|
222
|
+
const results = await abortable(manager.getDefinition(file!, rLine!, rCol!), signal);
|
|
223
|
+
const locs = results.map(l => formatLocation(l, ctx?.cwd));
|
|
224
|
+
const payload = locs.length ? locs.join("\n") : fromQuery ? `${file}:${rLine}:${rCol}` : "No definitions found.";
|
|
225
|
+
return { content: [{ type: "text", text: `action: definition\n${qLine}${posLine}${payload}` }], details: results };
|
|
226
|
+
}
|
|
227
|
+
case "references": {
|
|
228
|
+
const results = await abortable(manager.getReferences(file!, rLine!, rCol!), signal);
|
|
229
|
+
const locs = results.map(l => formatLocation(l, ctx?.cwd));
|
|
230
|
+
return { content: [{ type: "text", text: `action: references\n${qLine}${posLine}${locs.length ? locs.join("\n") : "No references found."}` }], details: results };
|
|
231
|
+
}
|
|
232
|
+
case "hover": {
|
|
233
|
+
const result = await abortable(manager.getHover(file!, rLine!, rCol!), signal);
|
|
234
|
+
const payload = result ? formatHover(result.contents) || "No hover information." : "No hover information.";
|
|
235
|
+
return { content: [{ type: "text", text: `action: hover\n${qLine}${posLine}${payload}` }], details: result ?? null };
|
|
236
|
+
}
|
|
237
|
+
case "symbols": {
|
|
238
|
+
const symbols = await abortable(manager.getDocumentSymbols(file!), signal);
|
|
239
|
+
const lines = collectSymbols(symbols, 0, [], query);
|
|
240
|
+
const payload = lines.length ? lines.join("\n") : query ? `No symbols matching "${query}".` : "No symbols found.";
|
|
241
|
+
return { content: [{ type: "text", text: `action: symbols\n${qLine}${payload}` }], details: symbols };
|
|
242
|
+
}
|
|
243
|
+
case "diagnostics": {
|
|
244
|
+
const result = await abortable(manager.touchFileAndWait(file!, diagnosticsWaitMsForFile(file!)), signal);
|
|
245
|
+
const filtered = filterDiagnosticsBySeverity(result.diagnostics, sevFilter);
|
|
246
|
+
const payload = (result as any).unsupported
|
|
247
|
+
? `Unsupported: ${(result as any).error || "No LSP for this file."}`
|
|
248
|
+
: !result.receivedResponse
|
|
249
|
+
? "Timeout: LSP server did not respond. Try again."
|
|
250
|
+
: filtered.length ? filtered.map(formatDiagnostic).join("\n") : "No diagnostics.";
|
|
251
|
+
return { content: [{ type: "text", text: `action: diagnostics\n${sevLine}${payload}` }], details: { ...result, diagnostics: filtered } };
|
|
252
|
+
}
|
|
253
|
+
case "workspace-diagnostics": {
|
|
254
|
+
if (!files?.length) throw new Error('Action "workspace-diagnostics" requires a "files" array.');
|
|
255
|
+
const waitMs = Math.max(...files.map(diagnosticsWaitMsForFile));
|
|
256
|
+
const result = await abortable(manager.getDiagnosticsForFiles(files, waitMs), signal);
|
|
257
|
+
const out: string[] = [];
|
|
258
|
+
let errors = 0, warnings = 0, filesWithIssues = 0;
|
|
259
|
+
|
|
260
|
+
for (const item of result.items) {
|
|
261
|
+
const display = ctx?.cwd && path.isAbsolute(item.file) ? path.relative(ctx.cwd, item.file) : item.file;
|
|
262
|
+
if (item.status !== 'ok') { out.push(`${display}: ${item.error || item.status}`); continue; }
|
|
263
|
+
const filtered = filterDiagnosticsBySeverity(item.diagnostics, sevFilter);
|
|
264
|
+
if (filtered.length) {
|
|
265
|
+
filesWithIssues++;
|
|
266
|
+
out.push(`${display}:`);
|
|
267
|
+
for (const d of filtered) {
|
|
268
|
+
if (d.severity === 1) errors++; else if (d.severity === 2) warnings++;
|
|
269
|
+
out.push(` ${formatDiagnostic(d)}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const summary = `Analyzed ${result.items.length} file(s): ${errors} error(s), ${warnings} warning(s) in ${filesWithIssues} file(s)`;
|
|
275
|
+
return { content: [{ type: "text", text: `action: workspace-diagnostics\n${sevLine}${summary}\n\n${out.length ? out.join("\n") : "No diagnostics."}` }], details: result };
|
|
276
|
+
}
|
|
277
|
+
case "signature": {
|
|
278
|
+
const result = await abortable(manager.getSignatureHelp(file!, rLine!, rCol!), signal);
|
|
279
|
+
return { content: [{ type: "text", text: `action: signature\n${qLine}${posLine}${formatSignature(result)}` }], details: result ?? null };
|
|
280
|
+
}
|
|
281
|
+
case "rename": {
|
|
282
|
+
if (!newName) throw new Error('Action "rename" requires a "newName" parameter.');
|
|
283
|
+
const result = await abortable(manager.rename(file!, rLine!, rCol!, newName), signal);
|
|
284
|
+
if (!result) return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}No rename available at this position.` }], details: null };
|
|
285
|
+
const edits = formatWorkspaceEdit(result, ctx?.cwd);
|
|
286
|
+
return { content: [{ type: "text", text: `action: rename\n${qLine}${posLine}newName: ${newName}\n\n${edits}` }], details: result };
|
|
287
|
+
}
|
|
288
|
+
case "codeAction": {
|
|
289
|
+
const result = await abortable(manager.getCodeActions(file!, rLine!, rCol!, endLine, endColumn), signal);
|
|
290
|
+
const actions = formatCodeActions(result);
|
|
291
|
+
return { content: [{ type: "text", text: `action: codeAction\n${qLine}${posLine}${actions.length ? actions.join("\n") : "No code actions available."}` }], details: result };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} catch (e) {
|
|
295
|
+
if (signal?.aborted || isAbortedError(e)) return cancelledToolResult();
|
|
296
|
+
throw e;
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
renderCall(args, theme) {
|
|
301
|
+
const params = args as LspParamsType;
|
|
302
|
+
let text = theme.fg("toolTitle", theme.bold("lsp ")) + theme.fg("accent", params.action || "...");
|
|
303
|
+
if (params.file) text += " " + theme.fg("muted", params.file);
|
|
304
|
+
else if (params.files?.length) text += " " + theme.fg("muted", `${params.files.length} file(s)`);
|
|
305
|
+
if (params.query) text += " " + theme.fg("dim", `query="${params.query}"`);
|
|
306
|
+
else if (params.line !== undefined && params.column !== undefined) text += theme.fg("warning", `:${params.line}:${params.column}`);
|
|
307
|
+
if (params.severity && params.severity !== "all") text += " " + theme.fg("dim", `[${params.severity}]`);
|
|
308
|
+
return new Text(text, 0, 0);
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
renderResult(result, options, theme) {
|
|
312
|
+
if (options.isPartial) return new Text(theme.fg("warning", "Working..."), 0, 0);
|
|
313
|
+
|
|
314
|
+
const textContent = (result.content?.find((c: any) => c.type === "text") as any)?.text || "";
|
|
315
|
+
const lines = textContent.split("\n");
|
|
316
|
+
|
|
317
|
+
let headerEnd = 0;
|
|
318
|
+
for (let i = 0; i < lines.length; i++) {
|
|
319
|
+
if (/^(action|query|severity|resolvedPosition):/.test(lines[i])) headerEnd = i + 1;
|
|
320
|
+
else break;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const header = lines.slice(0, headerEnd);
|
|
324
|
+
const content = lines.slice(headerEnd);
|
|
325
|
+
const maxLines = options.expanded ? content.length : PREVIEW_LINES;
|
|
326
|
+
const display = content.slice(0, maxLines);
|
|
327
|
+
const remaining = content.length - maxLines;
|
|
328
|
+
|
|
329
|
+
let out = header.map((l: string) => theme.fg("muted", l)).join("\n");
|
|
330
|
+
if (display.length) {
|
|
331
|
+
if (out) out += "\n";
|
|
332
|
+
out += display.map((l: string) => theme.fg("toolOutput", l)).join("\n");
|
|
333
|
+
}
|
|
334
|
+
if (remaining > 0) out += theme.fg("dim", `\n... (${remaining} more lines)`);
|
|
335
|
+
|
|
336
|
+
return new Text(out, 0, 0);
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
}
|