fix(lsp): prevent stale diagnostics by syncing didChange

This commit is contained in:
Zacks Zhang
2026-01-30 16:33:04 +08:00
parent 6bb2854162
commit 3bb4289b18
2 changed files with 104 additions and 12 deletions

View File

@@ -0,0 +1,63 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { describe, it, expect, spyOn, mock } from "bun:test"
mock.module("vscode-jsonrpc/node", () => ({
createMessageConnection: () => {
throw new Error("not used in unit test")
},
StreamMessageReader: function StreamMessageReader() {},
StreamMessageWriter: function StreamMessageWriter() {},
}))
import { LSPClient } from "./client"
import type { ResolvedServer } from "./types"
describe("LSPClient", () => {
describe("openFile", () => {
it("sends didChange when a previously opened file changes on disk", async () => {
// #given
const dir = mkdtempSync(join(tmpdir(), "lsp-client-test-"))
const filePath = join(dir, "test.ts")
writeFileSync(filePath, "const a = 1\n")
const originalSetTimeout = globalThis.setTimeout
globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => {
fn()
return 0 as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
const server: ResolvedServer = {
id: "typescript",
command: ["typescript-language-server", "--stdio"],
extensions: [".ts"],
priority: 0,
}
const client = new LSPClient(dir, server)
// Stub protocol output: we only want to assert notifications.
const sendNotificationSpy = spyOn(
client as unknown as { sendNotification: (m: string, p?: unknown) => void },
"sendNotification"
)
try {
// #when
await client.openFile(filePath)
writeFileSync(filePath, "const a = 2\n")
await client.openFile(filePath)
// #then
const methods = sendNotificationSpy.mock.calls.map((c) => c[0])
expect(methods).toContain("textDocument/didOpen")
expect(methods).toContain("textDocument/didChange")
} finally {
globalThis.setTimeout = originalSetTimeout
rmSync(dir, { recursive: true, force: true })
}
})
})
})

View File

@@ -215,6 +215,8 @@ export class LSPClient {
private proc: Subprocess<"pipe", "pipe", "pipe"> | null = null
private connection: MessageConnection | null = null
private openedFiles = new Set<string>()
private documentVersions = new Map<string, number>()
private lastSyncedText = new Map<string, string>()
private stderrBuffer: string[] = []
private processExited = false
private diagnosticsStore = new Map<string, Diagnostic[]>()
@@ -432,23 +434,50 @@ export class LSPClient {
async openFile(filePath: string): Promise<void> {
const absPath = resolve(filePath)
if (this.openedFiles.has(absPath)) return
const uri = pathToFileURL(absPath).href
const text = readFileSync(absPath, "utf-8")
const ext = extname(absPath)
const languageId = getLanguageId(ext)
this.sendNotification("textDocument/didOpen", {
textDocument: {
uri: pathToFileURL(absPath).href,
languageId,
version: 1,
text,
},
if (!this.openedFiles.has(absPath)) {
const ext = extname(absPath)
const languageId = getLanguageId(ext)
const version = 1
this.sendNotification("textDocument/didOpen", {
textDocument: {
uri,
languageId,
version,
text,
},
})
this.openedFiles.add(absPath)
this.documentVersions.set(uri, version)
this.lastSyncedText.set(uri, text)
await new Promise((r) => setTimeout(r, 1000))
return
}
const prevText = this.lastSyncedText.get(uri)
if (prevText === text) {
return
}
const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1
this.documentVersions.set(uri, nextVersion)
this.lastSyncedText.set(uri, text)
this.sendNotification("textDocument/didChange", {
textDocument: { uri, version: nextVersion },
contentChanges: [{ text }],
})
this.openedFiles.add(absPath)
await new Promise((r) => setTimeout(r, 1000))
// Some servers update diagnostics only after save
this.sendNotification("textDocument/didSave", {
textDocument: { uri },
text,
})
}
async definition(filePath: string, line: number, character: number): Promise<unknown> {