diff options
| author | Adam Malczewski <[email protected]> | 2026-06-24 16:48:46 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-24 16:48:46 +0900 |
| commit | 8f6114be790016bd954fcfccbe80a88bd0cb758e (patch) | |
| tree | 6be223628e35ce83759314f6fcce2161daa370ba /packages/lsp/src/tool.ts | |
| parent | 4935c268dd53592ec264c1b3eaa9805b3e069df5 (diff) | |
| download | dispatch-8f6114be790016bd954fcfccbe80a88bd0cb758e.tar.gz dispatch-8f6114be790016bd954fcfccbe80a88bd0cb758e.zip | |
feat(lsp+tool-edit-file): multi-server diagnostics + per-edit auto-append
LSP extension:
- Multi-server aggregation: query ALL connected servers matching the
file's extension (not just the first), merge diagnostics tagged by source
- Incremental sync: capture each server's textDocumentSync.change during
initialize; compute prefix/suffix diff ranges for change:2 servers;
full content for change:1 (generic, works for any LSP)
- New diff.ts: pure computeChangeRange + offsetToPosition (O(n), tested)
- Buffer sync: change(filePath, newText) sends didChange with post-edit
in-memory content; openWithText for first open; tracks open doc text
- languageId mapping: extended with .rb/.rbs/.c/.cpp/etc. (was 'unknown')
- waitForDiagnostics: accepts text override + timeoutMs; returns
{ formatted, slow, timedOut }; polls for publishDiagnostics push
- DiagnosticsStore: hasReceivedPush/clearReceived tracking; formatFiltered
with minSeverity (1=Error, 2=Warning) for edit_file integration
- LspService.getDiagnostics: service method for cross-extension use
tool-edit-file:
- After successful edit, calls LSP getDiagnostics with post-edit buffer
- Only appends diagnostics with severity ≤ 2 (errors+warnings, no noise)
- Appends slow warning (>10s): 'LSP is taking unusually long...'
- 60s timeout; graceful degradation when no LSP available
- Optional dep on @dispatch/lsp (getService pattern, not manifest depOn)
1468 vitest pass (was 1453, +15 new diff tests).
Diffstat (limited to 'packages/lsp/src/tool.ts')
| -rw-r--r-- | packages/lsp/src/tool.ts | 103 |
1 files changed, 83 insertions, 20 deletions
diff --git a/packages/lsp/src/tool.ts b/packages/lsp/src/tool.ts index bc4b41f..8d282ec 100644 --- a/packages/lsp/src/tool.ts +++ b/packages/lsp/src/tool.ts @@ -4,7 +4,7 @@ * Operations: diagnostics, hover, definition, references, documentSymbol. */ -import { resolve } from "node:path"; +import { extname, resolve } from "node:path"; import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; import type { LspManager } from "./manager.js"; @@ -153,30 +153,61 @@ export function createLspTool(manager: LspManager): ToolContract { return { content: "No language server configured for this workspace.", isError: true }; } - const connected = statuses.find((s) => s.state === "connected"); - if (!connected) { - const first = statuses[0]; - const detail = first - ? `"${first.name}" is not connected (state: ${first.state})` - : "is not connected"; - return { - content: `Language server ${detail}.`, - isError: true, - }; - } - - // Find the client for this server - const client = manager.getClient(connected.id, connected.root); - if (!client) { - return { content: "Language server client not available.", isError: true }; - } + const fileExt = extname(absolutePath).toLowerCase(); switch (operation) { case "diagnostics": { - const diags = await client.waitForDiagnostics(absolutePath); - return { content: diags || "No diagnostics found." }; + // Query ALL connected servers whose extensions match this file. + const matching = statuses.filter( + (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), + ); + + if (matching.length === 0) { + // No matching server — fall back to any connected server. + const connected = statuses.find((s) => s.state === "connected"); + if (!connected) { + const first = statuses[0]; + const detail = first + ? `"${first.name}" is not connected (state: ${first.state})` + : "is not connected"; + return { + content: `Language server ${detail}.`, + isError: true, + }; + } + const client = manager.getClient(connected.id, connected.root); + if (!client) { + return { content: "Language server client not available.", isError: true }; + } + const result = await client.waitForDiagnostics(absolutePath); + return { content: result.formatted || "No diagnostics found." }; + } + + // Query each matching server and merge results, tagged by source. + const parts: string[] = []; + let anyTimedOut = false; + for (const s of matching) { + const client = manager.getClient(s.id, s.root); + if (!client) continue; + const result = await client.waitForDiagnostics(absolutePath, { timeoutMs: 60_000 }); + if (result.timedOut) anyTimedOut = true; + if (result.slow) { + parts.push( + `⚠️ LSP is taking unusually long. If this happens more than once, raise it to the user.`, + ); + } + if (result.formatted) { + parts.push(`[${s.name}]\n${result.formatted}`); + } + } + if (anyTimedOut && parts.length === 0) { + parts.push("Diagnostics timed out (server may still be indexing)."); + } + return { content: parts.length > 0 ? parts.join("\n\n") : "No diagnostics found." }; } case "hover": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; const result = await client.request("textDocument/hover", { textDocument: { uri: `file://${absolutePath}` }, position: toPosition(line, character), @@ -190,6 +221,8 @@ export function createLspTool(manager: LspManager): ToolContract { return { content }; } case "definition": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; const result = await client.request("textDocument/definition", { textDocument: { uri: `file://${absolutePath}` }, position: toPosition(line, character), @@ -198,6 +231,8 @@ export function createLspTool(manager: LspManager): ToolContract { return { content: JSON.stringify(result) }; } case "references": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; const result = await client.request("textDocument/references", { textDocument: { uri: `file://${absolutePath}` }, position: toPosition(line, character), @@ -207,6 +242,8 @@ export function createLspTool(manager: LspManager): ToolContract { return { content: JSON.stringify(result) }; } case "documentSymbol": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; const result = await client.request("textDocument/documentSymbol", { textDocument: { uri: `file://${absolutePath}` }, }); @@ -223,3 +260,29 @@ export function createLspTool(manager: LspManager): ToolContract { }, }; } + +/** + * Find the first connected client whose server claims the file's extension. + * Falls back to any connected server if no extension match is found. + * Used by hover/definition/references/documentSymbol (single-server ops). + */ +async function getFirstMatchingClient( + manager: LspManager, + statuses: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: string; + }[], + fileExt: string, +): Promise< + { readonly request: (method: string, params?: unknown) => Promise<unknown> } | undefined +> { + const matching = statuses.filter( + (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), + ); + const target = matching[0] ?? statuses.find((s) => s.state === "connected"); + if (!target) return undefined; + return manager.getClient(target.id, target.root); +} |
