// LSP Extension - Registers tools that let the LLM query language servers // for hover, definition, references, completions, document symbols, and // diagnostics. Tool calls are forwarded to the long-lived pi-lsp daemon // (autospawned on first use) so LSP servers stay warm across calls. import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import * as path from "node:path"; import { uriToPath } from "./src/client.ts"; import { daemonDiagnostics, daemonRequest } from "./src/daemonClient.ts"; import { ServerNotFoundError, UnsupportedExtensionError, } from "./src/types.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 = { 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 = { 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 = { 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"); } // Is Expected Error - Returns true if the error is an expected condition // (unsupported file type or missing server binary) that should be // suppressed rather than surfaced to the user. function isExpectedError(error: unknown): boolean { return ( error instanceof UnsupportedExtensionError || error instanceof ServerNotFoundError ); } // Run LSP Request - Forwards to the daemon, which owns the long-lived // LspClient cache and handles didOpen/didChange syncing. The daemon // injects textDocument.uri from the file path, so we omit it here. async function runLsp( filePath: string, method: string, params: Record, ): Promise { try { return await daemonRequest(filePath, method, params); } catch (error) { if (isExpectedError(error)) { return undefined; } throw error; } } // Run LSP Diagnostics - Diagnostics arrive as a notification, so the // daemon has a dedicated op that waits for the next publish. async function runDiagnostics(filePath: string): Promise { try { return await daemonDiagnostics(filePath, 1500); } catch (error) { if (isExpectedError(error)) { return undefined; } throw error; } } // 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 = { 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 = { 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 = { 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 = { 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", {}, ); 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 (!isExpectedError(error)) { const msg = error && typeof error === "object" && "message" in error ? (error as { message: string }).message : String(error); 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 [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"); } } }, }); }