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/diff.test.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/diff.test.ts')
| -rw-r--r-- | packages/lsp/src/diff.test.ts | 117 |
1 files changed, 117 insertions, 0 deletions
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')"); + }); +}); |
