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 | |
| 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')
| -rw-r--r-- | packages/lsp/src/client.ts | 130 | ||||
| -rw-r--r-- | packages/lsp/src/diagnostics.ts | 28 | ||||
| -rw-r--r-- | packages/lsp/src/diff.test.ts | 117 | ||||
| -rw-r--r-- | packages/lsp/src/diff.ts | 85 | ||||
| -rw-r--r-- | packages/lsp/src/extension.ts | 55 | ||||
| -rw-r--r-- | packages/lsp/src/language.ts | 16 | ||||
| -rw-r--r-- | packages/lsp/src/tool.ts | 103 | ||||
| -rw-r--r-- | packages/lsp/src/types.ts | 27 | ||||
| -rw-r--r-- | packages/tool-edit-file/package.json | 3 | ||||
| -rw-r--r-- | packages/tool-edit-file/src/edit-file.ts | 55 | ||||
| -rw-r--r-- | packages/tool-edit-file/src/extension.ts | 20 | ||||
| -rw-r--r-- | packages/tool-edit-file/tsconfig.json | 2 |
12 files changed, 588 insertions, 53 deletions
diff --git a/packages/lsp/src/client.ts b/packages/lsp/src/client.ts index 114b8eb..743fcb4 100644 --- a/packages/lsp/src/client.ts +++ b/packages/lsp/src/client.ts @@ -5,7 +5,9 @@ */ import { DiagnosticsStore, type PublishDiagnosticsParams } from "./diagnostics.js"; +import { computeChangeRange } from "./diff.js"; import { FrameDecoder } from "./framing.js"; +import { languageId as resolveLanguageId } from "./language.js"; import { JsonRpcConnection, type WriteFn } from "./rpc.js"; import { FileChangeType, WatchedFilesRegistry } from "./watched-files.js"; @@ -97,7 +99,9 @@ export class LanguageServerClient { private state: ClientState = "not-started"; private stateError: string | undefined; private deps: ClientDeps; - private openDocuments = new Map<string, number>(); + private openDocuments = new Map<string, { version: number; text: string }>(); + /** Sync mode captured from the server's initialize capabilities: 1=Full, 2=Incremental. */ + private textDocumentChange: 1 | 2 = 1; constructor(deps: ClientDeps) { this.deps = deps; @@ -262,7 +266,22 @@ export class LanguageServerClient { setTimeout(() => reject(new Error("Initialize timeout")), timeout); }); - await Promise.race([initPromise, timeoutPromise]); + const result = (await Promise.race([initPromise, timeoutPromise])) as { + readonly capabilities?: { + readonly textDocumentSync?: + | number + | { readonly openClose?: boolean; readonly change?: number } + | undefined; + }; + }; + + // Capture the server's text document sync mode for didChange. + const sync = result.capabilities?.textDocumentSync; + if (typeof sync === "number") { + this.textDocumentChange = sync as 1 | 2; + } else if (sync && typeof sync === "object" && sync.change !== undefined) { + this.textDocumentChange = sync.change as 1 | 2; + } rpc.sendNotification("initialized", {}); @@ -301,36 +320,99 @@ export class LanguageServerClient { const rpc = this.rpc; if (!rpc || this.state !== "connected") return; - const version = (this.openDocuments.get(filePath) ?? 0) + 1; - this.openDocuments.set(filePath, version); - try { const text = await this.deps.fs.readText(filePath); - rpc.sendNotification("textDocument/didOpen", { - textDocument: { - uri: `file://${filePath}`, - languageId: "unknown", - version, - text, - }, - }); + await this.openWithText(filePath, text); } catch { // file may not exist } } - async waitForDiagnostics(filePath: string, timeoutMs = 10_000): Promise<string> { - await this.open(filePath); - return new Promise<string>((resolve) => { - const start = Date.now(); + async openWithText(filePath: string, text: string, langId?: string): Promise<void> { + const rpc = this.rpc; + if (!rpc || this.state !== "connected") return; + + // If already open, use didChange instead of re-opening. + if (this.openDocuments.has(filePath)) { + await this.change(filePath, text); + return; + } + + const version = 1; + this.openDocuments.set(filePath, { version, text }); + + rpc.sendNotification("textDocument/didOpen", { + textDocument: { + uri: `file://${filePath}`, + languageId: langId ?? resolveLanguageId(filePath), + version, + text, + }, + }); + } + + async change(filePath: string, newText: string): Promise<void> { + const rpc = this.rpc; + if (!rpc || this.state !== "connected") return; + + const existing = this.openDocuments.get(filePath); + if (!existing) { + // Not open yet — didOpen instead. + await this.openWithText(filePath, newText); + return; + } + + const version = existing.version + 1; + this.openDocuments.set(filePath, { version, text: newText }); + + if (this.textDocumentChange === 2) { + // Incremental sync — compute the minimal change range. + const changeEvent = computeChangeRange(existing.text, newText); + rpc.sendNotification("textDocument/didChange", { + textDocument: { uri: `file://${filePath}`, version }, + contentChanges: [changeEvent], + }); + } else { + // Full sync — send the entire content. + rpc.sendNotification("textDocument/didChange", { + textDocument: { uri: `file://${filePath}`, version }, + contentChanges: [{ text: newText }], + }); + } + } + + async waitForDiagnostics( + filePath: string, + opts?: { readonly text?: string; readonly timeoutMs?: number; readonly minSeverity?: number }, + ): Promise<{ readonly formatted: string; readonly slow: boolean; readonly timedOut: boolean }> { + const timeoutMs = opts?.timeoutMs ?? 10_000; + const uri = `file://${filePath}`; + + // Clear the "received" flag so we detect fresh publishDiagnostics after our sync. + this.diagnostics.clearReceived(uri); + + // Sync the document: use didChange with the provided text (post-edit buffer) + // or fall back to didOpen reading from disk. + if (opts?.text !== undefined) { + await this.change(filePath, opts.text); + } else { + await this.open(filePath); + } + + const slowThreshold = 10_000; + const start = Date.now(); + + // Poll until the server pushes diagnostics (even empty = done) or timeout. + return new Promise((resolve) => { const check = () => { - const formatted = this.diagnostics.format(`file://${filePath}`); - if (formatted) { - resolve(formatted); - return; - } - if (Date.now() - start >= timeoutMs) { - resolve(this.diagnostics.format(`file://${filePath}`) || ""); + const elapsed = Date.now() - start; + const received = this.diagnostics.hasReceivedPush(uri); + if (received || elapsed >= timeoutMs) { + resolve({ + formatted: this.diagnostics.formatFiltered(uri, opts?.minSeverity), + slow: elapsed > slowThreshold, + timedOut: !received, + }); return; } setTimeout(check, 100); diff --git a/packages/lsp/src/diagnostics.ts b/packages/lsp/src/diagnostics.ts index ea18811..bc7ac0a 100644 --- a/packages/lsp/src/diagnostics.ts +++ b/packages/lsp/src/diagnostics.ts @@ -34,9 +34,11 @@ const severityNames: Record<number, string> = { export class DiagnosticsStore { private pushDiagnostics = new Map<string, readonly Diagnostic[]>(); private pullDiagnostics = new Map<string, readonly Diagnostic[]>(); + private pushReceived = new Set<string>(); setPushDiagnostics(params: PublishDiagnosticsParams): void { this.pushDiagnostics.set(params.uri, params.diagnostics); + this.pushReceived.add(params.uri); } setPullDiagnostics(uri: string, report: DocumentDiagnosticReport): void { @@ -45,14 +47,32 @@ export class DiagnosticsStore { } } + /** True if the server has pushed at least one publishDiagnostics for this URI. */ + hasReceivedPush(uri: string): boolean { + return this.pushReceived.has(uri); + } + + /** Clear the "received" flag so the next waitForDiagnostics poll detects fresh pushes. */ + clearReceived(uri: string): void { + this.pushReceived.delete(uri); + } + getMerged(uri: string): readonly Diagnostic[] { const push = this.pushDiagnostics.get(uri) ?? []; const pull = this.pullDiagnostics.get(uri) ?? []; return dedupeDiagnostics([...push, ...pull]); } - format(uri: string): string { - const diags = this.getMerged(uri); + /** + * Format diagnostics for a URI, optionally filtering by minimum severity. + * `minSeverity` includes only diagnostics with severity ≤ the given value + * (1=Error, 2=Warning, 3=Info, 4=Hint). Omit to include all. + */ + formatFiltered(uri: string, minSeverity?: number): string { + let diags = this.getMerged(uri); + if (minSeverity !== undefined) { + diags = diags.filter((d) => (d.severity ?? 0) <= minSeverity); + } if (diags.length === 0) return ""; const lines: string[] = []; for (const d of diags) { @@ -65,6 +85,10 @@ export class DiagnosticsStore { } return lines.join("\n"); } + + format(uri: string): string { + return this.formatFiltered(uri); + } } function diagnosticKey(d: Diagnostic): string { diff --git a/packages/lsp/src/diff.test.ts b/packages/lsp/src/diff.test.ts new file mode 100644 index 0000000..b5b6a7b --- /dev/null +++ b/packages/lsp/src/diff.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { computeChangeRange, offsetToPosition } from "./diff.js"; + +describe("offsetToPosition", () => { + it("returns 0:0 for offset 0", () => { + expect(offsetToPosition("hello", 0)).toEqual({ line: 0, character: 0 }); + }); + + it("counts characters on the first line", () => { + expect(offsetToPosition("hello", 3)).toEqual({ line: 0, character: 3 }); + }); + + it("resets character count after newline", () => { + expect(offsetToPosition("ab\ncd", 4)).toEqual({ line: 1, character: 1 }); + }); + + it("handles multiple lines", () => { + expect(offsetToPosition("a\nb\nc", 4)).toEqual({ line: 2, character: 0 }); + }); + + it("clamps offset beyond text length", () => { + expect(offsetToPosition("ab", 100)).toEqual({ line: 0, character: 2 }); + }); + + it("handles empty string", () => { + expect(offsetToPosition("", 0)).toEqual({ line: 0, character: 0 }); + }); +}); + +describe("computeChangeRange", () => { + it("detects a single-line insertion", () => { + const oldText = "hello world"; + const newText = "hello cruel world"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 6 }); + expect(change.range.end).toEqual({ line: 0, character: 6 }); + expect(change.text).toBe("cruel "); + }); + + it("detects a single-line deletion", () => { + const oldText = "hello cruel world"; + const newText = "hello world"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 6 }); + expect(change.range.end).toEqual({ line: 0, character: 12 }); + expect(change.text).toBe(""); + }); + + it("detects a single-line replacement", () => { + const oldText = "hello world"; + const newText = "hello earth"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 6 }); + expect(change.range.end).toEqual({ line: 0, character: 11 }); + expect(change.text).toBe("earth"); + }); + + it("handles multi-line changes with correct line positions", () => { + const oldText = "line1\nline2\nline3"; + const newText = "line1\nCHANGED\nline3"; + const change = computeChangeRange(oldText, newText); + // Common prefix: "line1\n" → start at beginning of line 1 + expect(change.range.start).toEqual({ line: 1, character: 0 }); + // Common suffix: "\nline3" → end after "line2" on line 1 + expect(change.range.end).toEqual({ line: 1, character: 5 }); + expect(change.text).toBe("CHANGED"); + }); + + it("handles insertion at end of file", () => { + const oldText = "abc"; + const newText = "abcdef"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 3 }); + expect(change.range.end).toEqual({ line: 0, character: 3 }); + expect(change.text).toBe("def"); + }); + + it("handles complete file replacement (no common prefix/suffix)", () => { + const oldText = "abc"; + const newText = "xyz"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 0 }); + expect(change.range.end).toEqual({ line: 0, character: 3 }); + expect(change.text).toBe("xyz"); + }); + + it("handles empty old text (new file)", () => { + const oldText = ""; + const newText = "hello\nworld"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 0 }); + expect(change.range.end).toEqual({ line: 0, character: 0 }); + expect(change.text).toBe("hello\nworld"); + }); + + it("handles identical text (no change)", () => { + const oldText = "same text"; + const newText = "same text"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 9 }); + expect(change.range.end).toEqual({ line: 0, character: 9 }); + expect(change.text).toBe(""); + }); + + it("handles change spanning multiple lines", () => { + const oldText = "function foo() {\n return 1;\n}\n"; + const newText = "function foo() {\n return 2;\n console.log('hi');\n}\n"; + const change = computeChangeRange(oldText, newText); + // Common prefix: "function foo() {\n return " (26 chars) + // The change starts at "1" on line 1, character 9 + expect(change.range.start).toEqual({ line: 1, character: 9 }); + // Common suffix: ";\n}\n" (4 chars) → end at offset 27 (the ";") + // offset 27 = line 1, char 10 (after " return 1") + expect(change.range.end).toEqual({ line: 1, character: 10 }); + expect(change.text).toBe("2;\n console.log('hi')"); + }); +}); diff --git a/packages/lsp/src/diff.ts b/packages/lsp/src/diff.ts new file mode 100644 index 0000000..ab40b36 --- /dev/null +++ b/packages/lsp/src/diff.ts @@ -0,0 +1,85 @@ +/** + * Pure diff utilities for computing LSP text document change ranges. + * Language-agnostic — works for any text content from any language server. + * + * Uses longest-common-prefix/suffix matching to find the minimal changed + * region between two versions of a file. O(n) in text length. + */ + +export interface Position { + readonly line: number; // 0-based + readonly character: number; // 0-based +} + +export interface TextDocumentContentChangeEvent { + readonly range: { + readonly start: Position; + readonly end: Position; + }; + readonly text: string; +} + +/** + * Compute the minimal change range that transforms `oldText` into `newText`. + * Returns a single `TextDocumentContentChangeEvent` suitable for + * `textDocument/didChange` with incremental sync (change: 2). + * + * Algorithm: find the longest common prefix and suffix of the two strings. + * The region between them is the changed range. The replacement text is + * the portion of `newText` between the prefix and suffix. + */ +export function computeChangeRange( + oldText: string, + newText: string, +): TextDocumentContentChangeEvent { + const minLen = Math.min(oldText.length, newText.length); + + // Longest common prefix + let prefixLen = 0; + while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) { + prefixLen++; + } + + // Longest common suffix (must not overlap with prefix) + const oldRemaining = oldText.length - prefixLen; + const newRemaining = newText.length - prefixLen; + const maxSuffix = Math.min(oldRemaining, newRemaining); + let suffixLen = 0; + while ( + suffixLen < maxSuffix && + oldText[oldText.length - 1 - suffixLen] === newText[newText.length - 1 - suffixLen] + ) { + suffixLen++; + } + + const startOffset = prefixLen; + const endOffset = oldText.length - suffixLen; + const replacementText = newText.slice(prefixLen, newText.length - suffixLen); + + return { + range: { + start: offsetToPosition(oldText, startOffset), + end: offsetToPosition(oldText, endOffset), + }, + text: replacementText, + }; +} + +/** + * Convert a 0-based character offset into a text string to an LSP Position + * (0-based line and character). Scans for newlines up to the offset. + */ +export function offsetToPosition(text: string, offset: number): Position { + let line = 0; + let character = 0; + const limit = Math.min(offset, text.length); + for (let i = 0; i < limit; i++) { + if (text[i] === "\n") { + line++; + character = 0; + } else { + character++; + } + } + return { line, character }; +} diff --git a/packages/lsp/src/extension.ts b/packages/lsp/src/extension.ts index 486f66d..8e3178a 100644 --- a/packages/lsp/src/extension.ts +++ b/packages/lsp/src/extension.ts @@ -5,12 +5,18 @@ * lspServiceHandle, and wires deactivate to manager.shutdownAll(). */ +import { extname, join } from "node:path"; import type { Extension, HostAPI, ServiceHandle } from "@dispatch/kernel"; import { defineService } from "@dispatch/kernel"; import type { SpawnedProcess } from "./client.js"; import { LspManager } from "./manager.js"; import { createLspTool } from "./tool.js"; -import type { LspServerStatus, LspService } from "./types.js"; +import type { + DiagnosticsResult, + GetDiagnosticsOpts, + LspServerStatus, + LspService, +} from "./types.js"; export const lspServiceHandle: ServiceHandle<LspService> = defineService<LspService>("lsp"); @@ -101,6 +107,53 @@ export const extension: Extension = { async status(cwd: string): Promise<readonly LspServerStatus[]> { return manager.status(cwd); }, + async getDiagnostics(opts: GetDiagnosticsOpts): Promise<DiagnosticsResult> { + const timeoutMs = opts.timeoutMs ?? 60_000; + const slowThreshold = 10_000; + const fileExt = extname(opts.filePath).toLowerCase(); + const absolutePath = opts.filePath.startsWith("/") + ? opts.filePath + : join(opts.cwd, opts.filePath); + + // Get all connected servers matching this file's extension. + const statuses = await manager.status(opts.cwd); + const matching = statuses.filter( + (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), + ); + + if (matching.length === 0) { + return { formatted: "", slow: false, timedOut: false }; + } + + const parts: string[] = []; + let anySlow = false; + let anyTimedOut = false; + const start = Date.now(); + + for (const s of matching) { + const client = manager.getClient(s.id, s.root); + if (!client) continue; + const waitOpts: { text?: string; timeoutMs?: number; minSeverity?: number } = { + timeoutMs, + }; + if (opts.text !== undefined) waitOpts.text = opts.text; + if (opts.minSeverity !== undefined) waitOpts.minSeverity = opts.minSeverity; + const result = await client.waitForDiagnostics(absolutePath, waitOpts); + if (result.slow) anySlow = true; + if (result.timedOut) anyTimedOut = true; + if (result.formatted) { + parts.push(`[${s.name}]\n${result.formatted}`); + } + } + + const elapsed = Date.now() - start; + + return { + formatted: parts.join("\n\n"), + slow: anySlow || elapsed > slowThreshold, + timedOut: anyTimedOut, + }; + }, }; host.provideService(lspServiceHandle, service); diff --git a/packages/lsp/src/language.ts b/packages/lsp/src/language.ts index 214294e..a10dbed 100644 --- a/packages/lsp/src/language.ts +++ b/packages/lsp/src/language.ts @@ -26,6 +26,22 @@ const extensionMap: Record<string, string> = { ".sh": "shellscript", ".bash": "shellscript", ".zsh": "shellscript", + ".rb": "ruby", + ".rbs": "ruby", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".hpp": "cpp", + ".hxx": "cpp", + ".java": "java", + ".kt": "kotlin", + ".swift": "swift", + ".php": "php", + ".cs": "csharp", + ".sql": "sql", + ".dockerfile": "dockerfile", }; export function languageId(filePath: string): string { 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); +} diff --git a/packages/lsp/src/types.ts b/packages/lsp/src/types.ts index cc33e03..1f72bdf 100644 --- a/packages/lsp/src/types.ts +++ b/packages/lsp/src/types.ts @@ -20,6 +20,25 @@ export interface LspServerStatus { readonly configSource?: string | undefined; } +export interface DiagnosticsResult { + /** Formatted diagnostic string (filtered by minSeverity). Empty if none. */ + readonly formatted: string; + /** True if diagnostics took >10s. */ + readonly slow: boolean; + /** True if the 60s timeout was hit before all servers responded. */ + readonly timedOut: boolean; +} + +export interface GetDiagnosticsOpts { + readonly filePath: string; + /** Post-edit buffer content. If omitted, the server reads from disk. */ + readonly text?: string; + readonly cwd: string; + readonly timeoutMs?: number; + /** Only include diagnostics with severity ≤ this (1=Error, 2=Warning). Omit for all. */ + readonly minSeverity?: number; +} + export interface LspService { /** * Resolve the language servers configured for `cwd`, ensure each is spawned + @@ -27,4 +46,12 @@ export interface LspService { * server's failure — reflect it as state:"error" with a short `error`. */ status(cwd: string): Promise<readonly LspServerStatus[]>; + + /** + * Query ALL connected language servers matching the file's extension for + * diagnostics. Merges results tagged by source server. Sends didOpen/didChange + * with the provided text (post-edit buffer) so the server checks the in-memory + * version, not stale disk content. + */ + getDiagnostics(opts: GetDiagnosticsOpts): Promise<DiagnosticsResult>; } diff --git a/packages/tool-edit-file/package.json b/packages/tool-edit-file/package.json index f71ad80..7194cce 100644 --- a/packages/tool-edit-file/package.json +++ b/packages/tool-edit-file/package.json @@ -6,6 +6,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "dependencies": { - "@dispatch/kernel": "workspace:*" + "@dispatch/kernel": "workspace:*", + "@dispatch/lsp": "workspace:*" } } diff --git a/packages/tool-edit-file/src/edit-file.ts b/packages/tool-edit-file/src/edit-file.ts index 36f99e0..1719ea3 100644 --- a/packages/tool-edit-file/src/edit-file.ts +++ b/packages/tool-edit-file/src/edit-file.ts @@ -103,13 +103,35 @@ export function computeReplacement( }; } +// --- Diagnostics hook --- + +/** + * Optional post-edit diagnostics hook. Returns formatted diagnostics string + * (empty if none) + timing metadata. Injected by the extension from the LSP + * service; absent when no LSP is available (graceful degradation). + */ +export type DiagnosticsHook = (opts: { + readonly filePath: string; + readonly text: string; + readonly cwd: string; +}) => Promise<{ + readonly formatted: string; + readonly slow: boolean; + readonly timedOut: boolean; +}>; + // --- Shell / edge --- /** * Factory: create an edit_file ToolContract bound to a working directory. * The working directory is injected so the tool is testable. + * `diagnostics` is optional — when provided, errors+warnings from LSP servers + * are appended to successful edit results (only when errors exist). */ -export function createEditFileTool(workingDirectory: string): ToolContract { +export function createEditFileTool( + workingDirectory: string, + diagnostics?: DiagnosticsHook, +): ToolContract { const workdir = resolve(workingDirectory); return { @@ -202,7 +224,36 @@ export function createEditFileTool(workingDirectory: string): ToolContract { } const plural = result.count === 1 ? "" : "s"; - return { content: `Replaced ${result.count} occurrence${plural} in "${relPath}".` }; + let baseContent = `Replaced ${result.count} occurrence${plural} in "${relPath}".`; + + // After a successful edit, query LSP diagnostics (if available). + // Only append if there are actual errors/warnings (no noise on clean edits). + if (diagnostics) { + try { + const cwd = ctx.cwd ?? process.cwd(); + const diag = await diagnostics({ + filePath: resolvedPath, + text: result.content, + cwd, + }); + const suffix: string[] = []; + if (diag.slow) { + suffix.push( + "⚠️ LSP is taking unusually long. If this happens more than once, raise it to the user.", + ); + } + if (diag.formatted) { + suffix.push(diag.formatted); + } + if (suffix.length > 0) { + baseContent += `\n\n${suffix.join("\n\n")}`; + } + } catch { + // LSP diagnostics failure is non-fatal — the edit already succeeded. + } + } + + return { content: baseContent }; }, }; } diff --git a/packages/tool-edit-file/src/extension.ts b/packages/tool-edit-file/src/extension.ts index a4bb19e..bbd8256 100644 --- a/packages/tool-edit-file/src/extension.ts +++ b/packages/tool-edit-file/src/extension.ts @@ -1,5 +1,6 @@ import type { Extension } from "@dispatch/kernel"; -import { createEditFileTool } from "./edit-file.js"; +import { lspServiceHandle } from "@dispatch/lsp"; +import { createEditFileTool, type DiagnosticsHook } from "./edit-file.js"; export const extension: Extension = { manifest: { @@ -13,6 +14,21 @@ export const extension: Extension = { contributes: { tools: ["edit_file"] }, }, activate(host) { - host.defineTool(createEditFileTool(process.cwd())); + // Optional LSP integration: if the lsp extension is loaded, wire its + // getDiagnostics service as the post-edit diagnostics hook. If absent, + // edits proceed without diagnostics (graceful degradation). + const lspService = host.getService(lspServiceHandle); + const diagnostics: DiagnosticsHook | undefined = lspService + ? async (opts) => + lspService.getDiagnostics({ + filePath: opts.filePath, + text: opts.text, + cwd: opts.cwd, + timeoutMs: 60_000, + minSeverity: 2, // errors + warnings only + }) + : undefined; + + host.defineTool(createEditFileTool(process.cwd(), diagnostics)); }, }; diff --git a/packages/tool-edit-file/tsconfig.json b/packages/tool-edit-file/tsconfig.json index ff99a43..38a7610 100644 --- a/packages/tool-edit-file/tsconfig.json +++ b/packages/tool-edit-file/tsconfig.json @@ -2,5 +2,5 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../kernel" }] + "references": [{ "path": "../kernel" }, { "path": "../lsp" }] } |
