fix(lsp): prevent stale diagnostics by syncing didChange
This commit is contained in:
parent
6bb2854162
commit
3bb4289b18
63
src/tools/lsp/client.test.ts
Normal file
63
src/tools/lsp/client.test.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -215,6 +215,8 @@ export class LSPClient {
|
|||||||
private proc: Subprocess<"pipe", "pipe", "pipe"> | null = null
|
private proc: Subprocess<"pipe", "pipe", "pipe"> | null = null
|
||||||
private connection: MessageConnection | null = null
|
private connection: MessageConnection | null = null
|
||||||
private openedFiles = new Set<string>()
|
private openedFiles = new Set<string>()
|
||||||
|
private documentVersions = new Map<string, number>()
|
||||||
|
private lastSyncedText = new Map<string, string>()
|
||||||
private stderrBuffer: string[] = []
|
private stderrBuffer: string[] = []
|
||||||
private processExited = false
|
private processExited = false
|
||||||
private diagnosticsStore = new Map<string, Diagnostic[]>()
|
private diagnosticsStore = new Map<string, Diagnostic[]>()
|
||||||
@ -432,23 +434,50 @@ export class LSPClient {
|
|||||||
|
|
||||||
async openFile(filePath: string): Promise<void> {
|
async openFile(filePath: string): Promise<void> {
|
||||||
const absPath = resolve(filePath)
|
const absPath = resolve(filePath)
|
||||||
if (this.openedFiles.has(absPath)) return
|
|
||||||
|
|
||||||
|
const uri = pathToFileURL(absPath).href
|
||||||
const text = readFileSync(absPath, "utf-8")
|
const text = readFileSync(absPath, "utf-8")
|
||||||
const ext = extname(absPath)
|
|
||||||
const languageId = getLanguageId(ext)
|
|
||||||
|
|
||||||
this.sendNotification("textDocument/didOpen", {
|
if (!this.openedFiles.has(absPath)) {
|
||||||
textDocument: {
|
const ext = extname(absPath)
|
||||||
uri: pathToFileURL(absPath).href,
|
const languageId = getLanguageId(ext)
|
||||||
languageId,
|
const version = 1
|
||||||
version: 1,
|
|
||||||
text,
|
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> {
|
async definition(filePath: string, line: number, character: number): Promise<unknown> {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user