import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent"; import { StringEnum } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import path from "node:path"; import type { LspManager } from "./manager.js"; // Core tool surface: diagnostics, status, fix. All tools are factories over a // manager getter so the extension can build them once and swap the manager on // session lifecycle. const STATUS_KEY = "lsp"; function severityName(severity: number | undefined): string { if (severity === 1) return "error"; if (severity === 2) return "warning"; if (severity === 3) return "info"; if (severity === 4) return "hint"; return "diagnostic"; } export function createDiagnosticsTool(getManager: () => LspManager): ToolDefinition { return defineTool({ name: "lsp_diagnostics", label: "LSP: Diagnostics", description: "Run diagnostics using configured LSP server routes (official servers by default).", promptSnippet: "Get diagnostics from configured LSP servers", promptGuidelines: [ "Use lsp_diagnostics when the user asks to check for type errors or verify files compile, or after a series of edits.", "Pass wait: \"full\" to lsp_diagnostics on the first (cold) check of a project to wait for real diagnostics instead of a provisional empty result; document mode is fast but may miss cold-start diagnostics.", "Relative paths passed to lsp_diagnostics are resolved against the workspace root; leave paths empty to check the whole workspace.", "If lsp_diagnostics reports 'No LSP server available', run lsp_status to see which servers failed to start.", ], parameters: Type.Object({ paths: Type.Optional( Type.Array(Type.String(), { description: "Files or directories to check. Defaults to the workspace root." }), ), server: Type.Optional(Type.String({ description: "Restrict to a named LSP server." })), wait: Type.Optional( StringEnum(["document", "full"] as const, { description: "document (default) settles after a short grace window; full waits for real (non-empty) diagnostics up to the request timeout. Use full on cold projects.", }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { ctx.ui.setStatus(STATUS_KEY, "lsp diagnostics"); try { const manager = getManager(); const files = params.paths?.length ? params.paths : [ctx.cwd]; const lines: string[] = []; const noServerFiles: string[] = []; let total = 0; for (const input of files) { // Resolve once so client lookups keyed by absolute path match. const file = path.resolve(ctx.cwd, input); const clients = await manager.getClients(file); if (!clients.length) { noServerFiles.push(file); continue; } for (const client of clients) { if (params.server && client.serverID !== params.server) continue; const version = await client.touchFile(file); await client.waitForDiagnostics({ path: file, version, mode: params.wait ?? "document", requireNonEmpty: params.wait === "full", }); const diags = client.diagnostics.get(file) ?? []; total += diags.length; for (const d of diags) { const line = d.range.start.line + 1; const col = d.range.start.character + 1; lines.push( `${file}:${line}:${col}: ${severityName(d.severity)} ${d.source ?? client.serverID}${d.code !== undefined ? ` ${d.code}` : ""}: ${d.message}`, ); } } } const unavailable = manager .status() .filter((s) => s.status === "error") .map((s) => s.id); const notes: string[] = []; if (noServerFiles.length) { notes.push(`No LSP server available for ${noServerFiles.join(", ")}.`); } if (unavailable.length) { notes.push(`Unavailable server(s): ${unavailable.join(", ")}.`); } let text = lines.length ? lines.join("\n") : `No LSP diagnostics (${total} across ${files.length} file(s)).`; if (notes.length) text = `${text}\n\n${notes.join("\n")}`; return { content: [{ type: "text", text }], details: { files, total, unavailable }, }; } finally { ctx.ui.setStatus(STATUS_KEY, undefined); } }, }); } export function createStatusTool(getManager: () => LspManager): ToolDefinition { return defineTool({ name: "lsp_status", label: "LSP: Status", description: "Show live LSP server sessions for the workspace.", promptSnippet: "Show live LSP server sessions", promptGuidelines: [ "Use lsp_status when the user asks which LSP servers are running, or to diagnose why lsp_diagnostics returned nothing.", ], parameters: Type.Object({}), async execute() { const sessions = getManager().status(); const text = sessions.length ? sessions.map((s) => `${s.id} @ ${s.root}: ${s.status}`).join("\n") : "No LSP sessions started yet."; return { content: [{ type: "text", text }], details: { sessions } }; }, }); } export function createFixTool(getManager: () => LspManager): ToolDefinition { return defineTool({ name: "lsp_fix", label: "LSP: Fix", description: "Apply a source code action (default source.fixAll) via the file's LSP server.", promptSnippet: "Apply LSP source fixes to a file", promptGuidelines: [ "Use lsp_fix to apply a server-provided source action (default source.fixAll) to a file; without write: true it only previews the edits and never writes them.", "After lsp_fix previews edits, apply them with your file tools (edit/write) — lsp_fix never applies previews itself.", ], parameters: Type.Object({ path: Type.String({ description: "File to fix." }), kind: Type.Optional( Type.String({ description: "Code action kind. Defaults to source.fixAll." }), ), write: Type.Optional( Type.Boolean({ description: "Write the fix to disk. Defaults to false (preview)." }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const manager = getManager(); const file = params.path.startsWith("/") ? params.path : `${ctx.cwd}/${params.path}`; const clients = await manager.getClients(file); if (!clients.length) { return { content: [{ type: "text", text: `No LSP server configured for ${params.path}.` }], details: {}, }; } const client = clients[0]; const kind = params.kind?.trim() || "source.fixAll"; const version = await client.touchFile(file); const diags = client.diagnostics.get(file) ?? []; const actions = await client.request("textDocument/codeAction", { textDocument: { uri: `file://${file}` }, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, context: { diagnostics: diags, only: [kind] }, }); const action = actions?.find((a) => a.kind === kind || a.kind?.startsWith(`${kind}.`)); if (!action?.edit?.changes) { return { content: [{ type: "text", text: `No ${kind} action available for ${params.path}.` }], details: {}, }; } const edits = Object.entries(action.edit.changes as Record>)[0]?.[1] ?? []; return { content: [ { type: "text", text: `${client.serverID} returned ${edits.length} edit(s) for ${params.path} (kind: ${kind}).`, }, ], details: { edits, write: params.write ?? false, version }, }; }, }); }