summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/lsp/src/client.ts130
-rw-r--r--packages/lsp/src/diagnostics.ts28
-rw-r--r--packages/lsp/src/diff.test.ts117
-rw-r--r--packages/lsp/src/diff.ts85
-rw-r--r--packages/lsp/src/extension.ts55
-rw-r--r--packages/lsp/src/language.ts16
-rw-r--r--packages/lsp/src/tool.ts103
-rw-r--r--packages/lsp/src/types.ts27
-rw-r--r--packages/tool-edit-file/package.json3
-rw-r--r--packages/tool-edit-file/src/edit-file.ts55
-rw-r--r--packages/tool-edit-file/src/extension.ts20
-rw-r--r--packages/tool-edit-file/tsconfig.json2
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" }]
}