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/client.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/client.ts')
| -rw-r--r-- | packages/lsp/src/client.ts | 130 |
1 files changed, 106 insertions, 24 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); |
