initial commit
This commit is contained in:
484
index.ts
Normal file
484
index.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
// LSP Extension - Registers tools that let the LLM query language servers
|
||||
// for hover, definition, references, completions, document symbols, and
|
||||
// diagnostics. Each tool spawns a short-lived server, runs one request,
|
||||
// and tears it down (same lifecycle as the CLI).
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import * as path from "node:path";
|
||||
import { LspClient, uriToPath } from "./src/client.ts";
|
||||
import { pickServer, findRoot } from "./src/root.ts";
|
||||
|
||||
// Format Hover - Turn an LSP hover response into readable text.
|
||||
function formatHover(result: unknown): string {
|
||||
if (!result || typeof result !== "object") return "(no hover info)";
|
||||
const hover = result as { contents?: unknown };
|
||||
if (!hover.contents) return "(empty)";
|
||||
|
||||
// MarkupContent
|
||||
if (
|
||||
"value" in hover.contents &&
|
||||
typeof (hover.contents as any).value === "string"
|
||||
) {
|
||||
return (hover.contents as any).value;
|
||||
}
|
||||
// MarkedString | MarkedString[]
|
||||
if (Array.isArray(hover.contents)) {
|
||||
return hover.contents
|
||||
.map((s: any) => (typeof s === "string" ? s : (s?.value ?? "")))
|
||||
.join("\n");
|
||||
}
|
||||
if (
|
||||
"value" in hover.contents &&
|
||||
typeof (hover.contents as any).language === "string"
|
||||
) {
|
||||
const ms = hover.contents as any;
|
||||
return `\`\`\`${ms.language}\n${ms.value}\n\`\`\``;
|
||||
}
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
// Format Definition - Turn definition locations into readable text.
|
||||
function formatDefinition(result: unknown): string {
|
||||
if (!result) return "(no definition found)";
|
||||
const locations = Array.isArray(result) ? result : [result];
|
||||
if (locations.length === 0) return "(no definition found)";
|
||||
|
||||
return locations
|
||||
.map((loc: any, i: number) => {
|
||||
const file = uriToPath(loc.uri);
|
||||
const range = loc.range;
|
||||
return `${i + 1}. ${file} (${range.start.line + 1}:${range.start.character + 1})`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Format References - Turn reference locations into readable text.
|
||||
function formatReferences(result: unknown): string {
|
||||
if (!result || !Array.isArray(result)) return "(no references found)";
|
||||
if (result.length === 0) return "(no references found)";
|
||||
|
||||
return result
|
||||
.map((loc: any, i: number) => {
|
||||
const file = uriToPath(loc.uri);
|
||||
const range = loc.range;
|
||||
return `${i + 1}. ${file} (${range.start.line + 1}:${range.start.character + 1})`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Format Completions - Turn completion items into readable text.
|
||||
function formatCompletions(result: unknown): string {
|
||||
if (!result) return "(no completions)";
|
||||
|
||||
// Resolve to CompletionItem[] if it's a CompletionList
|
||||
let items: any[];
|
||||
if (Array.isArray(result)) {
|
||||
items = result;
|
||||
} else if ("items" in result && Array.isArray((result as any).items)) {
|
||||
items = (result as any).items;
|
||||
} else {
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
if (items.length === 0) return "(no completions)";
|
||||
|
||||
// Limit to top 30 to avoid flooding context
|
||||
const limited = items.slice(0, 30);
|
||||
const lines = limited.map((item: any) => {
|
||||
let label = item.label;
|
||||
if (item.kind) {
|
||||
const kindNames: Record<number, string> = {
|
||||
1: "text",
|
||||
2: "method",
|
||||
3: "function",
|
||||
4: "constructor",
|
||||
5: "field",
|
||||
6: "variable",
|
||||
7: "class",
|
||||
8: "interface",
|
||||
9: "module",
|
||||
10: "property",
|
||||
11: "unit",
|
||||
12: "value",
|
||||
13: "enum",
|
||||
14: "keyword",
|
||||
15: "snippet",
|
||||
16: "color",
|
||||
17: "file",
|
||||
18: "reference",
|
||||
19: "folder",
|
||||
20: "enumMember",
|
||||
21: "constant",
|
||||
22: "struct",
|
||||
23: "event",
|
||||
24: "operator",
|
||||
25: "typeParameter",
|
||||
};
|
||||
label += ` (${kindNames[item.kind] ?? item.kind})`;
|
||||
}
|
||||
if (item.detail) label += ` — ${item.detail}`;
|
||||
return `• ${label}`;
|
||||
});
|
||||
|
||||
if (items.length > 30) {
|
||||
lines.push(`\n... and ${items.length - 30} more`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Format Document Symbols - Turn symbols into a tree outline.
|
||||
function formatDocumentSymbols(result: unknown): string {
|
||||
if (!result || !Array.isArray(result)) return "(no symbols)";
|
||||
const symbols = result as any[];
|
||||
if (symbols.length === 0) return "(no symbols)";
|
||||
|
||||
const kindNames: Record<number, string> = {
|
||||
1: "File",
|
||||
2: "Module",
|
||||
3: "Namespace",
|
||||
4: "Package",
|
||||
5: "Class",
|
||||
6: "Method",
|
||||
7: "Property",
|
||||
8: "Field",
|
||||
9: "Constructor",
|
||||
10: "Enum",
|
||||
11: "Interface",
|
||||
12: "Function",
|
||||
13: "Variable",
|
||||
14: "Constant",
|
||||
15: "String",
|
||||
16: "Number",
|
||||
17: "Boolean",
|
||||
18: "Array",
|
||||
19: "Object",
|
||||
20: "Key",
|
||||
21: "Null",
|
||||
22: "EnumMember",
|
||||
23: "Struct",
|
||||
24: "Event",
|
||||
25: "Operator",
|
||||
26: "TypeParameter",
|
||||
};
|
||||
|
||||
function renderSymbol(sym: any, indent = 0): string {
|
||||
const pad = " ".repeat(indent);
|
||||
const kind = kindNames[sym.kind] ?? `kind:${sym.kind}`;
|
||||
const line = sym.range?.start?.line ? sym.range.start.line + 1 : "?";
|
||||
let text = `${pad}${sym.name} [${kind}] :${line}`;
|
||||
if (sym.children && Array.isArray(sym.children)) {
|
||||
for (const child of sym.children) {
|
||||
text += "\n" + renderSymbol(child, indent + 1);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
return symbols.map((s) => renderSymbol(s)).join("\n");
|
||||
}
|
||||
|
||||
// Format Diagnostics - Turn diagnostic messages into readable text.
|
||||
function formatDiagnostics(result: unknown): string {
|
||||
if (!result || !("diagnostics" in result)) return "(no diagnostics)";
|
||||
const diags = (result as any).diagnostics;
|
||||
if (!Array.isArray(diags) || diags.length === 0) return "(no diagnostics)";
|
||||
|
||||
const severityNames: Record<number, string> = {
|
||||
1: "Error",
|
||||
2: "Warning",
|
||||
3: "Info",
|
||||
4: "Hint",
|
||||
};
|
||||
|
||||
return diags
|
||||
.map((d: any, i: number) => {
|
||||
const sev = severityNames[d.severity] ?? `sev:${d.severity}`;
|
||||
const range = d.range;
|
||||
const line = range?.start?.line != null ? range.start.line + 1 : "?";
|
||||
const col =
|
||||
range?.start?.character != null ? range.start.character + 1 : "?";
|
||||
return `${i + 1}. [${sev}] ${d.message} (line ${line}, col ${col})`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Run LSP Request - Spawn a server, open the file, run one request, dispose.
|
||||
// Mirrors the CLI lifecycle: fresh server per request.
|
||||
async function runLsp(
|
||||
filePath: string,
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
const server = pickServer(filePath);
|
||||
const rootDir = findRoot(filePath, server.rootMarkers);
|
||||
const client = new LspClient(server);
|
||||
|
||||
try {
|
||||
await client.start(rootDir);
|
||||
const uri = client.openDocument(filePath);
|
||||
await client.waitForReady();
|
||||
// Populate textDocument.uri if the params have a textDocument field
|
||||
if (params.textDocument && typeof params.textDocument === "object") {
|
||||
params.textDocument = { ...params.textDocument, uri };
|
||||
}
|
||||
return client.sendRequest(method, params);
|
||||
} finally {
|
||||
// Fire-and-forget shutdown; don't wait for graceful exit.
|
||||
void client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Run LSP Diagnostics - Diagnostics arrive as a notification, so we use
|
||||
// the dedicated waitForDiagnostics helper instead of sendRequest.
|
||||
async function runDiagnostics(filePath: string): Promise<unknown> {
|
||||
const server = pickServer(filePath);
|
||||
const rootDir = findRoot(filePath, server.rootMarkers);
|
||||
const client = new LspClient(server);
|
||||
|
||||
try {
|
||||
await client.start(rootDir);
|
||||
const uri = client.openDocument(filePath);
|
||||
await client.waitForReady();
|
||||
return client.waitForDiagnostics(uri, 1500);
|
||||
} finally {
|
||||
void client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Shared Parameters Schema - All position-based tools accept file + optional
|
||||
// position. The "file" field is required; position defaults to (0, 0).
|
||||
const PositionSchema = Type.Object({
|
||||
file: Type.String({ description: "Path to the file to query" }),
|
||||
line: Type.Optional(
|
||||
Type.Integer({ description: "0-indexed line number (default: 0)" }),
|
||||
),
|
||||
character: Type.Optional(
|
||||
Type.Integer({ description: "0-indexed character offset (default: 0)" }),
|
||||
),
|
||||
});
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Hover - Get hover documentation for the symbol at a position.
|
||||
pi.registerTool({
|
||||
name: "lsp_hover",
|
||||
label: "LSP Hover",
|
||||
description: "Get hover documentation for a symbol at a position in a file",
|
||||
promptSnippet: "Query LSP server for hover docs at a cursor position",
|
||||
parameters: PositionSchema,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const filePath = path.resolve(ctx.cwd, params.file);
|
||||
const lspParams = {
|
||||
textDocument: {},
|
||||
position: { line: params.line ?? 0, character: params.character ?? 0 },
|
||||
};
|
||||
const result = await runLsp(filePath, "textDocument/hover", lspParams);
|
||||
return {
|
||||
content: [{ type: "text", text: formatHover(result) }],
|
||||
details: { raw: result },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Definition - Find the definition location of a symbol.
|
||||
pi.registerTool({
|
||||
name: "lsp_definition",
|
||||
label: "LSP Definition",
|
||||
description: "Find the definition of a symbol at a position in a file",
|
||||
promptSnippet: "Query LSP server for symbol definition locations",
|
||||
parameters: PositionSchema,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const filePath = path.resolve(ctx.cwd, params.file);
|
||||
const lspParams = {
|
||||
textDocument: {},
|
||||
position: { line: params.line ?? 0, character: params.character ?? 0 },
|
||||
};
|
||||
const result = await runLsp(
|
||||
filePath,
|
||||
"textDocument/definition",
|
||||
lspParams,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: formatDefinition(result) }],
|
||||
details: { raw: result },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// References - Find all references to a symbol.
|
||||
pi.registerTool({
|
||||
name: "lsp_references",
|
||||
label: "LSP References",
|
||||
description: "Find all references to a symbol at a position in a file",
|
||||
promptSnippet: "Query LSP server for all references to a symbol",
|
||||
parameters: PositionSchema,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const filePath = path.resolve(ctx.cwd, params.file);
|
||||
const lspParams = {
|
||||
textDocument: {},
|
||||
position: { line: params.line ?? 0, character: params.character ?? 0 },
|
||||
context: { includeDeclaration: true },
|
||||
};
|
||||
const result = await runLsp(
|
||||
filePath,
|
||||
"textDocument/references",
|
||||
lspParams,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: formatReferences(result) }],
|
||||
details: { raw: result },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Completion - Get completion suggestions at a position.
|
||||
pi.registerTool({
|
||||
name: "lsp_completion",
|
||||
label: "LSP Completion",
|
||||
description: "Get completion suggestions at a position in a file",
|
||||
promptSnippet: "Query LSP server for completion items at a cursor position",
|
||||
parameters: PositionSchema,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const filePath = path.resolve(ctx.cwd, params.file);
|
||||
const lspParams = {
|
||||
textDocument: {},
|
||||
position: { line: params.line ?? 0, character: params.character ?? 0 },
|
||||
};
|
||||
const result = await runLsp(
|
||||
filePath,
|
||||
"textDocument/completion",
|
||||
lspParams,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: formatCompletions(result) }],
|
||||
details: { raw: result },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Document Symbol - Get the outline/symbol tree of a file.
|
||||
pi.registerTool({
|
||||
name: "lsp_documentSymbol",
|
||||
label: "LSP Document Symbols",
|
||||
description: "Get the symbol outline (classes, functions, etc.) of a file",
|
||||
promptSnippet: "Query LSP server for document symbol outline",
|
||||
parameters: Type.Object({
|
||||
file: Type.String({ description: "Path to the file" }),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const filePath = path.resolve(ctx.cwd, params.file);
|
||||
const result = await runLsp(filePath, "textDocument/documentSymbol", {
|
||||
textDocument: {},
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: formatDocumentSymbols(result) }],
|
||||
details: { raw: result },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Diagnostics - Get lint/type-check diagnostics for a file.
|
||||
pi.registerTool({
|
||||
name: "lsp_diagnostics",
|
||||
label: "LSP Diagnostics",
|
||||
description:
|
||||
"Get diagnostics (errors, warnings) for a file from its LSP server",
|
||||
promptSnippet: "Query LSP server for file diagnostics (errors, warnings)",
|
||||
parameters: Type.Object({
|
||||
file: Type.String({ description: "Path to the file" }),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const filePath = path.resolve(ctx.cwd, params.file);
|
||||
const result = await runDiagnostics(filePath);
|
||||
return {
|
||||
content: [{ type: "text", text: formatDiagnostics(result) }],
|
||||
details: { raw: result },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-Check Flag - Enable automatic diagnostics after edit/write
|
||||
pi.registerFlag("lsp-auto-check", {
|
||||
description:
|
||||
"Automatically run LSP diagnostics after edit/write operations",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
});
|
||||
|
||||
// Auto-Check After Edit/Write - Run diagnostics automatically
|
||||
pi.on("tool_result", async (event, ctx) => {
|
||||
// Check Enabled
|
||||
if (!pi.getFlag("lsp-auto-check")) return;
|
||||
|
||||
// Edit & Write Only
|
||||
if (!["edit", "write"].includes(event.toolName)) return;
|
||||
|
||||
// Skip Errors
|
||||
if (event.isError) return;
|
||||
|
||||
// Resolve Filepath
|
||||
const filePath = event.input?.path;
|
||||
if (!filePath || typeof filePath !== "string") return;
|
||||
const absolutePath = path.resolve(ctx.cwd, filePath);
|
||||
|
||||
try {
|
||||
// Run LSP diagnostics
|
||||
const result = await runDiagnostics(absolutePath);
|
||||
const formatted = formatDiagnostics(result);
|
||||
|
||||
// Only send a message if there are actual diagnostics
|
||||
if (formatted !== "(no diagnostics)") {
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: "lsp-auto-check",
|
||||
content: `LSP found issues in ${filePath}:\n${formatted}`,
|
||||
display: true,
|
||||
details: { filePath, diagnostics: result },
|
||||
},
|
||||
{ deliverAs: "steer" },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - don't interrupt the flow
|
||||
// Only log if there's an actual error we care about
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
const msg = (error as { message: string }).message;
|
||||
if (!msg.includes("not found on PATH")) {
|
||||
console.error("LSP auto-check failed:", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Manual Check Command - Run diagnostics on specific files
|
||||
pi.registerCommand("lsp-check", {
|
||||
description: "Run LSP diagnostics on one or more files",
|
||||
handler: async (args, ctx) => {
|
||||
const files = args ? args.split(/\s+/) : [];
|
||||
|
||||
if (files.length === 0) {
|
||||
ctx.ui.notify("Usage: /lsp-check <file1> [file2...]", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const absolutePath = path.resolve(ctx.cwd, file);
|
||||
try {
|
||||
const result = await runDiagnostics(absolutePath);
|
||||
const formatted = formatDiagnostics(result);
|
||||
|
||||
if (formatted === "(no diagnostics)") {
|
||||
ctx.ui.notify(`${file}: ✓ No issues`, "info");
|
||||
} else {
|
||||
ctx.ui.notify(`${file}:\n${formatted}`, "warning");
|
||||
}
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error && typeof error === "object" && "message" in error
|
||||
? (error as { message: string }).message
|
||||
: "Unknown error";
|
||||
ctx.ui.notify(`${file}: Failed to check - ${msg}`, "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user