diff --git a/src/tools/lsp/client.test.ts b/src/tools/lsp/client.test.ts new file mode 100644 index 000000000..d7fcf336b --- /dev/null +++ b/src/tools/lsp/client.test.ts @@ -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 + }) 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 }) + } + }) + }) +}) diff --git a/src/tools/lsp/client.ts b/src/tools/lsp/client.ts index b4802033a..f9362558e 100644 --- a/src/tools/lsp/client.ts +++ b/src/tools/lsp/client.ts @@ -215,6 +215,8 @@ export class LSPClient { private proc: Subprocess<"pipe", "pipe", "pipe"> | null = null private connection: MessageConnection | null = null private openedFiles = new Set() + private documentVersions = new Map() + private lastSyncedText = new Map() private stderrBuffer: string[] = [] private processExited = false private diagnosticsStore = new Map() @@ -432,23 +434,50 @@ export class LSPClient { async openFile(filePath: string): Promise { 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 {