summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src/diff.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-24 16:48:46 +0900
committerAdam Malczewski <[email protected]>2026-06-24 16:48:46 +0900
commit8f6114be790016bd954fcfccbe80a88bd0cb758e (patch)
tree6be223628e35ce83759314f6fcce2161daa370ba /packages/lsp/src/diff.ts
parent4935c268dd53592ec264c1b3eaa9805b3e069df5 (diff)
downloaddispatch-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.ts')
-rw-r--r--packages/lsp/src/diff.ts85
1 files changed, 85 insertions, 0 deletions
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 };
+}