diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:09:39 +0900 |
| commit | 61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch) | |
| tree | 2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/lsp/src | |
| parent | 63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff) | |
| parent | 727c98c9dae516a2070eb950410314380a20c974 (diff) | |
| download | dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip | |
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/lsp/src')
27 files changed, 3753 insertions, 3753 deletions
diff --git a/packages/lsp/src/aggregate.test.ts b/packages/lsp/src/aggregate.test.ts index 4579a0a..9f93893 100644 --- a/packages/lsp/src/aggregate.test.ts +++ b/packages/lsp/src/aggregate.test.ts @@ -8,134 +8,134 @@ import type { LanguageServerClient } from "./client.js"; * tool.test.ts) — no real process, no internal mocks of our own modules. */ function fakeClient( - waitForDiagnostics: LanguageServerClient["waitForDiagnostics"], + waitForDiagnostics: LanguageServerClient["waitForDiagnostics"], ): LanguageServerClient { - return { waitForDiagnostics } as unknown as LanguageServerClient; + return { waitForDiagnostics } as unknown as LanguageServerClient; } const SERVER_A: AggregateServer = { id: "a", name: "Ruby-LSP", root: "/p" }; const SERVER_B: AggregateServer = { id: "b", name: "Steep", root: "/p" }; describe("aggregateDiagnostics", () => { - it("returns merged diagnostics from all responding servers, tagged by source", async () => { - const clients = new Map<string, LanguageServerClient>([ - [ - "a", - fakeClient(async () => ({ formatted: "ERROR L1:1: boom", slow: false, timedOut: false })), - ], - [ - "b", - fakeClient(async () => ({ formatted: "WARNING L2:3: meh", slow: false, timedOut: false })), - ], - ]); + it("returns merged diagnostics from all responding servers, tagged by source", async () => { + const clients = new Map<string, LanguageServerClient>([ + [ + "a", + fakeClient(async () => ({ formatted: "ERROR L1:1: boom", slow: false, timedOut: false })), + ], + [ + "b", + fakeClient(async () => ({ formatted: "WARNING L2:3: meh", slow: false, timedOut: false })), + ], + ]); - const result = await aggregateDiagnostics( - (id) => clients.get(id), - [SERVER_A, SERVER_B], - "/p/x.rb", - 10_000, - {}, - ); + const result = await aggregateDiagnostics( + (id) => clients.get(id), + [SERVER_A, SERVER_B], + "/p/x.rb", + 10_000, + {}, + ); - expect(result.timedOut).toBe(false); - expect(result.formatted).toContain("[Ruby-LSP]"); - expect(result.formatted).toContain("boom"); - expect(result.formatted).toContain("[Steep]"); - expect(result.formatted).toContain("meh"); - }); + expect(result.timedOut).toBe(false); + expect(result.formatted).toContain("[Ruby-LSP]"); + expect(result.formatted).toContain("boom"); + expect(result.formatted).toContain("[Steep]"); + expect(result.formatted).toContain("meh"); + }); - it("skips a server that times out with a raise-to-user notice, and still returns the fast server's result", async () => { - // Steep never resolves within the cap → timedOut; ruby-lsp answers fast. - const clients = new Map<string, LanguageServerClient>([ - ["a", fakeClient(async () => ({ formatted: "", slow: false, timedOut: false }))], - ["b", fakeClient(async () => ({ formatted: "", slow: false, timedOut: true }))], - ]); + it("skips a server that times out with a raise-to-user notice, and still returns the fast server's result", async () => { + // Steep never resolves within the cap → timedOut; ruby-lsp answers fast. + const clients = new Map<string, LanguageServerClient>([ + ["a", fakeClient(async () => ({ formatted: "", slow: false, timedOut: false }))], + ["b", fakeClient(async () => ({ formatted: "", slow: false, timedOut: true }))], + ]); - const result = await aggregateDiagnostics( - (id) => clients.get(id), - [SERVER_A, SERVER_B], - "/p/x.rb", - 10_000, - {}, - ); + const result = await aggregateDiagnostics( + (id) => clients.get(id), + [SERVER_A, SERVER_B], + "/p/x.rb", + 10_000, + {}, + ); - expect(result.timedOut).toBe(true); - // The skip notice names the offending server and the cap. - expect(result.formatted).toContain("[Steep]"); - expect(result.formatted).toContain("took too long"); - expect(result.formatted).toContain(">10s"); - expect(result.formatted).toContain("raise this to the user"); - // ruby-lsp answered cleanly (empty diagnostics) → no line for it. - expect(result.formatted).not.toContain("[Ruby-LSP]"); - }); + expect(result.timedOut).toBe(true); + // The skip notice names the offending server and the cap. + expect(result.formatted).toContain("[Steep]"); + expect(result.formatted).toContain("took too long"); + expect(result.formatted).toContain(">10s"); + expect(result.formatted).toContain("raise this to the user"); + // ruby-lsp answered cleanly (empty diagnostics) → no line for it. + expect(result.formatted).not.toContain("[Ruby-LSP]"); + }); - it("runs servers concurrently: a slow server does not delay a fast one's contribution order", async () => { - const callOrder: string[] = []; - const clients = new Map<string, LanguageServerClient>([ - [ - "a", - fakeClient(async () => { - callOrder.push("a-start"); - await new Promise((r) => setTimeout(r, 5)); - callOrder.push("a-end"); - return { formatted: "from-a", slow: false, timedOut: false }; - }), - ], - [ - "b", - fakeClient(async () => { - callOrder.push("b-start"); - await new Promise((r) => setTimeout(r, 30)); - callOrder.push("b-end"); - return { formatted: "from-b", slow: false, timedOut: false }; - }), - ], - ]); + it("runs servers concurrently: a slow server does not delay a fast one's contribution order", async () => { + const callOrder: string[] = []; + const clients = new Map<string, LanguageServerClient>([ + [ + "a", + fakeClient(async () => { + callOrder.push("a-start"); + await new Promise((r) => setTimeout(r, 5)); + callOrder.push("a-end"); + return { formatted: "from-a", slow: false, timedOut: false }; + }), + ], + [ + "b", + fakeClient(async () => { + callOrder.push("b-start"); + await new Promise((r) => setTimeout(r, 30)); + callOrder.push("b-end"); + return { formatted: "from-b", slow: false, timedOut: false }; + }), + ], + ]); - const result = await aggregateDiagnostics( - (id) => clients.get(id), - [SERVER_A, SERVER_B], - "/p/x.rb", - 10_000, - {}, - ); + const result = await aggregateDiagnostics( + (id) => clients.get(id), + [SERVER_A, SERVER_B], + "/p/x.rb", + 10_000, + {}, + ); - // Both started before either ended → concurrent, not sequential. - expect(callOrder.slice(0, 2).sort()).toEqual(["a-start", "b-start"]); - expect(result.formatted).toContain("from-a"); - expect(result.formatted).toContain("from-b"); - }); + // Both started before either ended → concurrent, not sequential. + expect(callOrder.slice(0, 2).sort()).toEqual(["a-start", "b-start"]); + expect(result.formatted).toContain("from-a"); + expect(result.formatted).toContain("from-b"); + }); - it("a missing client (dead/excluded) contributes nothing and never rejects", async () => { - const result = await aggregateDiagnostics(() => undefined, [SERVER_A], "/p/x.rb", 10_000, {}); - expect(result.formatted).toBe(""); - expect(result.timedOut).toBe(false); - }); + it("a missing client (dead/excluded) contributes nothing and never rejects", async () => { + const result = await aggregateDiagnostics(() => undefined, [SERVER_A], "/p/x.rb", 10_000, {}); + expect(result.formatted).toBe(""); + expect(result.timedOut).toBe(false); + }); - it("forwards text + minSeverity to each client's waitForDiagnostics", async () => { - const seen: Array<{ text?: string; minSeverity?: number; timeoutMs: number }> = []; - const clients = new Map<string, LanguageServerClient>([ - [ - "a", - fakeClient(async (_path, opts) => { - seen.push({ - text: opts?.text, - minSeverity: opts?.minSeverity, - timeoutMs: opts?.timeoutMs ?? -1, - }); - return { formatted: "", slow: false, timedOut: false }; - }), - ], - ]); + it("forwards text + minSeverity to each client's waitForDiagnostics", async () => { + const seen: Array<{ text?: string; minSeverity?: number; timeoutMs: number }> = []; + const clients = new Map<string, LanguageServerClient>([ + [ + "a", + fakeClient(async (_path, opts) => { + seen.push({ + text: opts?.text, + minSeverity: opts?.minSeverity, + timeoutMs: opts?.timeoutMs ?? -1, + }); + return { formatted: "", slow: false, timedOut: false }; + }), + ], + ]); - await aggregateDiagnostics((id) => clients.get(id), [SERVER_A], "/p/x.rb", 7000, { - text: "post-edit buffer", - minSeverity: 2, - }); + await aggregateDiagnostics((id) => clients.get(id), [SERVER_A], "/p/x.rb", 7000, { + text: "post-edit buffer", + minSeverity: 2, + }); - expect(seen).toHaveLength(1); - expect(seen[0]?.text).toBe("post-edit buffer"); - expect(seen[0]?.minSeverity).toBe(2); - expect(seen[0]?.timeoutMs).toBe(7000); - }); + expect(seen).toHaveLength(1); + expect(seen[0]?.text).toBe("post-edit buffer"); + expect(seen[0]?.minSeverity).toBe(2); + expect(seen[0]?.timeoutMs).toBe(7000); + }); }); diff --git a/packages/lsp/src/aggregate.ts b/packages/lsp/src/aggregate.ts index b044e21..af89c00 100644 --- a/packages/lsp/src/aggregate.ts +++ b/packages/lsp/src/aggregate.ts @@ -14,23 +14,23 @@ import type { LanguageServerClient } from "./client.js"; export interface AggregateServer { - readonly id: string; - readonly name: string; - readonly root: string; + readonly id: string; + readonly name: string; + readonly root: string; } export interface AggregateOpts { - /** Post-edit buffer; when omitted the server reads from disk. */ - readonly text?: string | undefined; - /** Only include diagnostics with severity ≤ this (1=Error, 2=Warning). */ - readonly minSeverity?: number | undefined; + /** Post-edit buffer; when omitted the server reads from disk. */ + readonly text?: string | undefined; + /** Only include diagnostics with severity ≤ this (1=Error, 2=Warning). */ + readonly minSeverity?: number | undefined; } export interface AggregateResult { - /** Merged diagnostics tagged by source + a per-skipped-server notice. */ - readonly formatted: string; - /** True if at least one server was skipped for exceeding the cap. */ - readonly timedOut: boolean; + /** Merged diagnostics tagged by source + a per-skipped-server notice. */ + readonly formatted: string; + /** True if at least one server was skipped for exceeding the cap. */ + readonly timedOut: boolean; } /** @@ -41,40 +41,40 @@ export interface AggregateResult { * contribution for that server. */ export async function aggregateDiagnostics( - getClient: (id: string, root: string) => LanguageServerClient | undefined, - servers: readonly AggregateServer[], - absolutePath: string, - timeoutMs: number, - opts: AggregateOpts, + getClient: (id: string, root: string) => LanguageServerClient | undefined, + servers: readonly AggregateServer[], + absolutePath: string, + timeoutMs: number, + opts: AggregateOpts, ): Promise<AggregateResult> { - const entries = await Promise.all( - servers.map(async (server) => { - const client = getClient(server.id, server.root); - if (!client) return null; - const waitOpts: { text?: string; timeoutMs: number; minSeverity?: number } = { timeoutMs }; - if (opts.text !== undefined) waitOpts.text = opts.text; - if (opts.minSeverity !== undefined) waitOpts.minSeverity = opts.minSeverity; - const result = await client.waitForDiagnostics(absolutePath, waitOpts); - return { server, result }; - }), - ); + const entries = await Promise.all( + servers.map(async (server) => { + const client = getClient(server.id, server.root); + if (!client) return null; + const waitOpts: { text?: string; timeoutMs: number; minSeverity?: number } = { timeoutMs }; + if (opts.text !== undefined) waitOpts.text = opts.text; + if (opts.minSeverity !== undefined) waitOpts.minSeverity = opts.minSeverity; + const result = await client.waitForDiagnostics(absolutePath, waitOpts); + return { server, result }; + }), + ); - const parts: string[] = []; - let timedOut = false; - const capSeconds = Math.round(timeoutMs / 1000); + const parts: string[] = []; + let timedOut = false; + const capSeconds = Math.round(timeoutMs / 1000); - for (const entry of entries) { - if (!entry) continue; - const { server, result } = entry; - if (result.timedOut) { - timedOut = true; - parts.push( - `⚠️ [${server.name}] LSP took too long (>${capSeconds}s), diagnostics skipped — please raise this to the user.`, - ); - } else if (result.formatted) { - parts.push(`[${server.name}]\n${result.formatted}`); - } - } + for (const entry of entries) { + if (!entry) continue; + const { server, result } = entry; + if (result.timedOut) { + timedOut = true; + parts.push( + `⚠️ [${server.name}] LSP took too long (>${capSeconds}s), diagnostics skipped — please raise this to the user.`, + ); + } else if (result.formatted) { + parts.push(`[${server.name}]\n${result.formatted}`); + } + } - return { formatted: parts.join("\n\n"), timedOut }; + return { formatted: parts.join("\n\n"), timedOut }; } diff --git a/packages/lsp/src/client.test.ts b/packages/lsp/src/client.test.ts index 338ef0b..9495888 100644 --- a/packages/lsp/src/client.test.ts +++ b/packages/lsp/src/client.test.ts @@ -1,437 +1,437 @@ import { describe, expect, it } from "vitest"; import { - type FileWatcher, - type FsAccess, - LanguageServerClient, - type ProcessExitHandler, - type SpawnProcess, + type FileWatcher, + type FsAccess, + LanguageServerClient, + type ProcessExitHandler, + type SpawnProcess, } from "./client.js"; import { encode } from "./framing.js"; function makeClient(overrides?: { - readonly spawn?: SpawnProcess; - readonly fileWatcher?: FileWatcher; - readonly fs?: FsAccess; - readonly initialization?: Record<string, unknown>; + readonly spawn?: SpawnProcess; + readonly fileWatcher?: FileWatcher; + readonly fs?: FsAccess; + readonly initialization?: Record<string, unknown>; }): { - client: LanguageServerClient; - stdinChunks: Uint8Array[]; - serverResponses: (msg: string) => void; + client: LanguageServerClient; + stdinChunks: Uint8Array[]; + serverResponses: (msg: string) => void; } { - const stdinChunks: Uint8Array[] = []; - let serverMessageHandler: ((msg: string) => void) | null = null; - - const mockSpawn: SpawnProcess = () => ({ - stdin: { write: (bytes) => stdinChunks.push(bytes) }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - // We'll feed messages through serverResponses - serverMessageHandler = (msg: string) => { - cb(encode(msg)); - }; - }, - }, - pid: 123, - kill: () => {}, - }); - - const mockFileWatcher: FileWatcher = (_root, _onEvent) => ({ - close: () => {}, - }); - - const mockFs: FsAccess = { - readText: async (path) => `// content of ${path}`, - exists: async () => true, - }; - - const client = new LanguageServerClient({ - spawn: overrides?.spawn ?? mockSpawn, - fileWatcher: overrides?.fileWatcher ?? mockFileWatcher, - fs: overrides?.fs ?? mockFs, - command: ["test-lsp"], - root: "/project", - serverId: "test", - ...(overrides?.initialization ? { initialization: overrides.initialization } : {}), - }); - - return { - client, - stdinChunks, - serverResponses: (msg: string) => serverMessageHandler?.(msg), - }; + const stdinChunks: Uint8Array[] = []; + let serverMessageHandler: ((msg: string) => void) | null = null; + + const mockSpawn: SpawnProcess = () => ({ + stdin: { write: (bytes) => stdinChunks.push(bytes) }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + // We'll feed messages through serverResponses + serverMessageHandler = (msg: string) => { + cb(encode(msg)); + }; + }, + }, + pid: 123, + kill: () => {}, + }); + + const mockFileWatcher: FileWatcher = (_root, _onEvent) => ({ + close: () => {}, + }); + + const mockFs: FsAccess = { + readText: async (path) => `// content of ${path}`, + exists: async () => true, + }; + + const client = new LanguageServerClient({ + spawn: overrides?.spawn ?? mockSpawn, + fileWatcher: overrides?.fileWatcher ?? mockFileWatcher, + fs: overrides?.fs ?? mockFs, + command: ["test-lsp"], + root: "/project", + serverId: "test", + ...(overrides?.initialization ? { initialization: overrides.initialization } : {}), + }); + + return { + client, + stdinChunks, + serverResponses: (msg: string) => serverMessageHandler?.(msg), + }; } describe("client", () => { - it("initialize declares didChangeWatchedFiles.dynamicRegistration true", async () => { - const { client, stdinChunks, serverResponses } = makeClient(); - - const startPromise = client.start(); - - // Wait for the initialize message to be sent - await new Promise((r) => setTimeout(r, 50)); - - // Parse the sent messages to find initialize - const sentMessages = stdinChunks.map((chunk) => { - const decoded = new TextDecoder().decode(chunk); - const headerEnd = decoded.indexOf("\r\n\r\n"); - return JSON.parse(decoded.slice(headerEnd + 4)); - }); - - const initMsg = sentMessages.find((m: { method?: string }) => m.method === "initialize"); - expect(initMsg).toBeDefined(); - expect(initMsg.params.capabilities.workspace.didChangeWatchedFiles.dynamicRegistration).toBe( - true, - ); - - // Send initialize response - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: initMsg.id, - result: { capabilities: {} }, - }), - ); - - await startPromise; - expect(client.getState()).toBe("connected"); - }); - - it("honors registerCapability for BOTH textDocument/diagnostic and workspace/didChangeWatchedFiles", async () => { - const { client, serverResponses } = makeClient(); - const startPromise = client.start(); - - await new Promise((r) => setTimeout(r, 50)); - - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: 1, - result: { capabilities: {} }, - }), - ); - - await startPromise; - - // Register workspace/didChangeWatchedFiles - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: 100, - method: "client/registerCapability", - params: { - registrations: [ - { - id: "reg-watched", - method: "workspace/didChangeWatchedFiles", - registerOptions: { - watchers: [{ globPattern: "**/*.luau" }], - }, - }, - { - id: "reg-diag", - method: "textDocument/diagnostic", - registerOptions: {}, - }, - ], - }, - }), - ); - - await new Promise((r) => setTimeout(r, 50)); - - const registry = client.getWatchedFilesRegistry(); - expect(registry.matches("src/main.luau")).toBe(true); - expect(registry.matches("src/main.ts")).toBe(false); - }); - - it("an injected fs change for a registered glob sends workspace/didChangeWatchedFiles type=Changed (opencode-bug regression)", async () => { - const callbackHolder: { - cb: - | ((e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void) - | null; - } = { cb: null }; - - const trackingFileWatcher: FileWatcher = (_root, onEvent) => { - callbackHolder.cb = onEvent; - return { close: () => {} }; - }; - - const { client, stdinChunks, serverResponses } = makeClient({ - fileWatcher: trackingFileWatcher, - }); - - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: 1, - result: { capabilities: {} }, - }), - ); - - await startPromise; - - // Register a watcher for sourcemap.json - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: 100, - method: "client/registerCapability", - params: { - registrations: [ - { - id: "reg-1", - method: "workspace/didChangeWatchedFiles", - registerOptions: { - watchers: [{ globPattern: "sourcemap.json" }], - }, - }, - ], - }, - }), - ); - - await new Promise((r) => setTimeout(r, 100)); - - // Simulate a file change - const onFsEvent = callbackHolder.cb; - if (!onFsEvent) throw new Error("file watcher callback was never registered"); - onFsEvent({ type: "change", path: "/project/sourcemap.json" }); - - await new Promise((r) => setTimeout(r, 100)); - - // Check that the notification was sent - const sentMessages = stdinChunks.map((chunk) => { - const decoded = new TextDecoder().decode(chunk); - const headerEnd = decoded.indexOf("\r\n\r\n"); - return JSON.parse(decoded.slice(headerEnd + 4)); - }); - - const didChangeMsg = sentMessages.find( - (m: { method?: string }) => m.method === "workspace/didChangeWatchedFiles", - ); - expect(didChangeMsg).toBeDefined(); - expect(didChangeMsg.params.changes[0].uri).toBe("file:///project/sourcemap.json"); - expect(didChangeMsg.params.changes[0].type).toBe(2); // Changed - }); - - it("publishDiagnostics are stored + returned", async () => { - const { client, serverResponses } = makeClient(); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - id: 1, - result: { capabilities: {} }, - }), - ); - - await startPromise; - - // Send diagnostics - serverResponses( - JSON.stringify({ - jsonrpc: "2.0", - method: "textDocument/publishDiagnostics", - params: { - uri: "file:///project/test.ts", - diagnostics: [ - { - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, - severity: 1, - message: "Test error", - }, - ], - }, - }), - ); - - await new Promise((r) => setTimeout(r, 50)); - - const store = client.getDiagnosticsStore(); - const formatted = store.format("file:///project/test.ts"); - expect(formatted).toContain("ERROR"); - expect(formatted).toContain("Test error"); - }); - - it("shutdown kills the process", async () => { - const state = { killed: false }; - const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; - const killableSpawn: SpawnProcess = () => ({ - stdin: { write: () => {} }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - stdoutHolder.cb = cb; - }, - }, - pid: 123, - kill: () => { - state.killed = true; - }, - }); - - const { client } = makeClient({ spawn: killableSpawn }); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - - // Deliver the initialize response through the spawned process's own - // stdout (the real read path), so start() can complete the handshake. - stdoutHolder.cb?.( - encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), - ); - - await startPromise; - - client.shutdown(); - expect(state.killed).toBe(true); - }); - - it("onExit marks the client broken (error) so callers stop querying a corpse", async () => { - const state = { killed: false }; - let exitCb: ProcessExitHandler | null = null; - const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; - - const spawnWithExit: SpawnProcess = () => ({ - stdin: { write: () => {} }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - stdoutHolder.cb = cb; - }, - }, - pid: 999, - kill: () => { - state.killed = true; - }, - onExit: (handler) => { - exitCb = handler; - }, - }); - - const { client } = makeClient({ spawn: spawnWithExit }); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - stdoutHolder.cb?.( - encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), - ); - await startPromise; - expect(client.getState()).toBe("connected"); - - // Simulate the process dying (user kill / crash). - exitCb?.({ code: 1 }); - - expect(client.getState()).toBe("error"); - expect(client.getStateError()).toMatch(/process exited/); - // The (still-alive-in-test) process was killed to avoid a zombie. - expect(state.killed).toBe(true); - }); - - it("a dead client is skipped by waitForDiagnostics callers (state !== connected)", async () => { - // Build a client, connect, kill via onExit, then assert a diagnostics - // query would not block: getState() is "error" so the matching filter - // (state === "connected") excludes it. We assert the state guard. - const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; - let exitCb: ProcessExitHandler | null = null; - const spawnWithExit: SpawnProcess = () => ({ - stdin: { write: () => {} }, - stdout: { - on: (_e, cb) => { - stdoutHolder.cb = cb; - }, - }, - pid: 1, - kill: () => {}, - onExit: (handler) => { - exitCb = handler; - }, - }); - - const { client } = makeClient({ spawn: spawnWithExit }); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - stdoutHolder.cb?.( - encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), - ); - await startPromise; - - exitCb?.({ code: null }); - expect(client.getState()).toBe("error"); - // The aggregate / getDiagnostics matching filter requires "connected". - expect(client.getState() === "connected").toBe(false); - }); - - it("corruption detector marks the client broken after repeated identical diagnostics despite text changes", async () => { - // A healthy server would change diagnostics as the file changes; a - // corrupted one re-emits the SAME non-empty set. Drive 5 edits with - // different text but identical diagnostics → client flips to error. - const { client, serverResponses } = makeClient(); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - serverResponses(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })); - await startPromise; - - const phantom = JSON.stringify({ - jsonrpc: "2.0", - method: "textDocument/publishDiagnostics", - params: { - uri: "file:///project/game.rb", - diagnostics: [ - { - range: { start: { line: 0, character: 27 }, end: { line: 0, character: 28 } }, - severity: 1, - message: "SyntaxError: unexpected token", - }, - ], - }, - }); - - const path = "/project/game.rb"; - // The first call establishes the baseline snapshot (no increment). - // Each subsequent call with identical diagnostics + changed text - // increments; the 6th call (5th increment) trips the threshold. - for (let i = 1; i <= 5; i++) { - const p = client.waitForDiagnostics(path, { text: `buf-v${i}`, timeoutMs: 2000 }); - // Push the identical phantom diagnostics so the poll resolves. - await new Promise((r) => setTimeout(r, 30)); - serverResponses(phantom); - await p; - expect(client.getState()).toBe("connected"); - } - // 6th identical-across-changed-text repeat trips the threshold. - const p6 = client.waitForDiagnostics(path, { text: "buf-v6", timeoutMs: 2000 }); - await new Promise((r) => setTimeout(r, 30)); - serverResponses(phantom); - await p6; - - expect(client.getState()).toBe("error"); - expect(client.getStateError()).toMatch(/repeated stale diagnostics/i); - }); - - it("corruption detector does NOT trip on a clean file (empty diagnostics stay identical)", async () => { - const { client, serverResponses } = makeClient(); - const startPromise = client.start(); - await new Promise((r) => setTimeout(r, 50)); - serverResponses(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })); - await startPromise; - - const clean = JSON.stringify({ - jsonrpc: "2.0", - method: "textDocument/publishDiagnostics", - params: { uri: "file:///project/game.rb", diagnostics: [] }, - }); - const path = "/project/game.rb"; - - for (let i = 1; i <= 6; i++) { - const p = client.waitForDiagnostics(path, { text: `clean-v${i}`, timeoutMs: 2000 }); - await new Promise((r) => setTimeout(r, 30)); - serverResponses(clean); - await p; - } - // Empty diagnostics never count as "stale" — a clean file staying clean - // is normal, not corruption. - expect(client.getState()).toBe("connected"); - }); + it("initialize declares didChangeWatchedFiles.dynamicRegistration true", async () => { + const { client, stdinChunks, serverResponses } = makeClient(); + + const startPromise = client.start(); + + // Wait for the initialize message to be sent + await new Promise((r) => setTimeout(r, 50)); + + // Parse the sent messages to find initialize + const sentMessages = stdinChunks.map((chunk) => { + const decoded = new TextDecoder().decode(chunk); + const headerEnd = decoded.indexOf("\r\n\r\n"); + return JSON.parse(decoded.slice(headerEnd + 4)); + }); + + const initMsg = sentMessages.find((m: { method?: string }) => m.method === "initialize"); + expect(initMsg).toBeDefined(); + expect(initMsg.params.capabilities.workspace.didChangeWatchedFiles.dynamicRegistration).toBe( + true, + ); + + // Send initialize response + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: initMsg.id, + result: { capabilities: {} }, + }), + ); + + await startPromise; + expect(client.getState()).toBe("connected"); + }); + + it("honors registerCapability for BOTH textDocument/diagnostic and workspace/didChangeWatchedFiles", async () => { + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Register workspace/didChangeWatchedFiles + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "client/registerCapability", + params: { + registrations: [ + { + id: "reg-watched", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }, + { + id: "reg-diag", + method: "textDocument/diagnostic", + registerOptions: {}, + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 50)); + + const registry = client.getWatchedFilesRegistry(); + expect(registry.matches("src/main.luau")).toBe(true); + expect(registry.matches("src/main.ts")).toBe(false); + }); + + it("an injected fs change for a registered glob sends workspace/didChangeWatchedFiles type=Changed (opencode-bug regression)", async () => { + const callbackHolder: { + cb: + | ((e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void) + | null; + } = { cb: null }; + + const trackingFileWatcher: FileWatcher = (_root, onEvent) => { + callbackHolder.cb = onEvent; + return { close: () => {} }; + }; + + const { client, stdinChunks, serverResponses } = makeClient({ + fileWatcher: trackingFileWatcher, + }); + + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Register a watcher for sourcemap.json + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "client/registerCapability", + params: { + registrations: [ + { + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "sourcemap.json" }], + }, + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 100)); + + // Simulate a file change + const onFsEvent = callbackHolder.cb; + if (!onFsEvent) throw new Error("file watcher callback was never registered"); + onFsEvent({ type: "change", path: "/project/sourcemap.json" }); + + await new Promise((r) => setTimeout(r, 100)); + + // Check that the notification was sent + const sentMessages = stdinChunks.map((chunk) => { + const decoded = new TextDecoder().decode(chunk); + const headerEnd = decoded.indexOf("\r\n\r\n"); + return JSON.parse(decoded.slice(headerEnd + 4)); + }); + + const didChangeMsg = sentMessages.find( + (m: { method?: string }) => m.method === "workspace/didChangeWatchedFiles", + ); + expect(didChangeMsg).toBeDefined(); + expect(didChangeMsg.params.changes[0].uri).toBe("file:///project/sourcemap.json"); + expect(didChangeMsg.params.changes[0].type).toBe(2); // Changed + }); + + it("publishDiagnostics are stored + returned", async () => { + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Send diagnostics + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { + uri: "file:///project/test.ts", + diagnostics: [ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, + severity: 1, + message: "Test error", + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 50)); + + const store = client.getDiagnosticsStore(); + const formatted = store.format("file:///project/test.ts"); + expect(formatted).toContain("ERROR"); + expect(formatted).toContain("Test error"); + }); + + it("shutdown kills the process", async () => { + const state = { killed: false }; + const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; + const killableSpawn: SpawnProcess = () => ({ + stdin: { write: () => {} }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + stdoutHolder.cb = cb; + }, + }, + pid: 123, + kill: () => { + state.killed = true; + }, + }); + + const { client } = makeClient({ spawn: killableSpawn }); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + // Deliver the initialize response through the spawned process's own + // stdout (the real read path), so start() can complete the handshake. + stdoutHolder.cb?.( + encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), + ); + + await startPromise; + + client.shutdown(); + expect(state.killed).toBe(true); + }); + + it("onExit marks the client broken (error) so callers stop querying a corpse", async () => { + const state = { killed: false }; + let exitCb: ProcessExitHandler | null = null; + const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; + + const spawnWithExit: SpawnProcess = () => ({ + stdin: { write: () => {} }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + stdoutHolder.cb = cb; + }, + }, + pid: 999, + kill: () => { + state.killed = true; + }, + onExit: (handler) => { + exitCb = handler; + }, + }); + + const { client } = makeClient({ spawn: spawnWithExit }); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + stdoutHolder.cb?.( + encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), + ); + await startPromise; + expect(client.getState()).toBe("connected"); + + // Simulate the process dying (user kill / crash). + exitCb?.({ code: 1 }); + + expect(client.getState()).toBe("error"); + expect(client.getStateError()).toMatch(/process exited/); + // The (still-alive-in-test) process was killed to avoid a zombie. + expect(state.killed).toBe(true); + }); + + it("a dead client is skipped by waitForDiagnostics callers (state !== connected)", async () => { + // Build a client, connect, kill via onExit, then assert a diagnostics + // query would not block: getState() is "error" so the matching filter + // (state === "connected") excludes it. We assert the state guard. + const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; + let exitCb: ProcessExitHandler | null = null; + const spawnWithExit: SpawnProcess = () => ({ + stdin: { write: () => {} }, + stdout: { + on: (_e, cb) => { + stdoutHolder.cb = cb; + }, + }, + pid: 1, + kill: () => {}, + onExit: (handler) => { + exitCb = handler; + }, + }); + + const { client } = makeClient({ spawn: spawnWithExit }); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + stdoutHolder.cb?.( + encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), + ); + await startPromise; + + exitCb?.({ code: null }); + expect(client.getState()).toBe("error"); + // The aggregate / getDiagnostics matching filter requires "connected". + expect(client.getState() === "connected").toBe(false); + }); + + it("corruption detector marks the client broken after repeated identical diagnostics despite text changes", async () => { + // A healthy server would change diagnostics as the file changes; a + // corrupted one re-emits the SAME non-empty set. Drive 5 edits with + // different text but identical diagnostics → client flips to error. + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + serverResponses(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })); + await startPromise; + + const phantom = JSON.stringify({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { + uri: "file:///project/game.rb", + diagnostics: [ + { + range: { start: { line: 0, character: 27 }, end: { line: 0, character: 28 } }, + severity: 1, + message: "SyntaxError: unexpected token", + }, + ], + }, + }); + + const path = "/project/game.rb"; + // The first call establishes the baseline snapshot (no increment). + // Each subsequent call with identical diagnostics + changed text + // increments; the 6th call (5th increment) trips the threshold. + for (let i = 1; i <= 5; i++) { + const p = client.waitForDiagnostics(path, { text: `buf-v${i}`, timeoutMs: 2000 }); + // Push the identical phantom diagnostics so the poll resolves. + await new Promise((r) => setTimeout(r, 30)); + serverResponses(phantom); + await p; + expect(client.getState()).toBe("connected"); + } + // 6th identical-across-changed-text repeat trips the threshold. + const p6 = client.waitForDiagnostics(path, { text: "buf-v6", timeoutMs: 2000 }); + await new Promise((r) => setTimeout(r, 30)); + serverResponses(phantom); + await p6; + + expect(client.getState()).toBe("error"); + expect(client.getStateError()).toMatch(/repeated stale diagnostics/i); + }); + + it("corruption detector does NOT trip on a clean file (empty diagnostics stay identical)", async () => { + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + serverResponses(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })); + await startPromise; + + const clean = JSON.stringify({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { uri: "file:///project/game.rb", diagnostics: [] }, + }); + const path = "/project/game.rb"; + + for (let i = 1; i <= 6; i++) { + const p = client.waitForDiagnostics(path, { text: `clean-v${i}`, timeoutMs: 2000 }); + await new Promise((r) => setTimeout(r, 30)); + serverResponses(clean); + await p; + } + // Empty diagnostics never count as "stale" — a clean file staying clean + // is normal, not corruption. + expect(client.getState()).toBe("connected"); + }); }); diff --git a/packages/lsp/src/client.ts b/packages/lsp/src/client.ts index ac7d025..f0e40ff 100644 --- a/packages/lsp/src/client.ts +++ b/packages/lsp/src/client.ts @@ -13,565 +13,565 @@ import { FileChangeType, WatchedFilesRegistry } from "./watched-files.js"; /** Info delivered to an `onExit` handler when the child process terminates. */ export interface ProcessExitInfo { - readonly code: number | null; - readonly signal?: string; + readonly code: number | null; + readonly signal?: string; } /** A handler registered to be called when the child process exits. */ export type ProcessExitHandler = (info: ProcessExitInfo) => void; export interface SpawnedProcess { - readonly stdin: { readonly write: (bytes: Uint8Array) => void }; - readonly stdout: - | AsyncIterable<Uint8Array> - | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }; - readonly stderr?: - | AsyncIterable<Uint8Array> - | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void } - | undefined; - readonly pid: number | undefined; - readonly kill: () => void; - /** - * Register a handler fired when the child process exits (code|signal). - * Optional: when absent, death is detected via stdout-end instead. Wires - * Bun's `proc.exited` in production; tests invoke it directly to simulate - * a crash. Lets the client stop querying a dead server (no per-edit hang). - */ - readonly onExit?: ((handler: ProcessExitHandler) => void) | undefined; + readonly stdin: { readonly write: (bytes: Uint8Array) => void }; + readonly stdout: + | AsyncIterable<Uint8Array> + | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }; + readonly stderr?: + | AsyncIterable<Uint8Array> + | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void } + | undefined; + readonly pid: number | undefined; + readonly kill: () => void; + /** + * Register a handler fired when the child process exits (code|signal). + * Optional: when absent, death is detected via stdout-end instead. Wires + * Bun's `proc.exited` in production; tests invoke it directly to simulate + * a crash. Lets the client stop querying a dead server (no per-edit hang). + */ + readonly onExit?: ((handler: ProcessExitHandler) => void) | undefined; } export type SpawnProcess = ( - command: string[], - opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> | undefined }, + command: string[], + opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> | undefined }, ) => SpawnedProcess; export interface FileWatcherHandle { - readonly close: () => void; + readonly close: () => void; } export type FileWatcher = ( - root: string, - onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, + root: string, + onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, ) => FileWatcherHandle; export interface FsAccess { - readonly readText: (path: string) => Promise<string>; - readonly exists: (path: string) => Promise<boolean>; + readonly readText: (path: string) => Promise<string>; + readonly exists: (path: string) => Promise<boolean>; } export interface ClientCapabilities { - readonly window: { readonly workDoneProgress: boolean }; - readonly workspace: { - readonly configuration: boolean; - readonly didChangeWatchedFiles: { readonly dynamicRegistration: boolean }; - readonly diagnostics: { readonly refreshSupport: boolean }; - }; - readonly textDocument: { - readonly synchronization: { readonly didOpen: boolean; readonly didChange: boolean }; - readonly diagnostic: { - readonly dynamicRegistration: boolean; - readonly relatedDocumentSupport: boolean; - }; - readonly publishDiagnostics: { readonly versionSupport: boolean }; - }; + readonly window: { readonly workDoneProgress: boolean }; + readonly workspace: { + readonly configuration: boolean; + readonly didChangeWatchedFiles: { readonly dynamicRegistration: boolean }; + readonly diagnostics: { readonly refreshSupport: boolean }; + }; + readonly textDocument: { + readonly synchronization: { readonly didOpen: boolean; readonly didChange: boolean }; + readonly diagnostic: { + readonly dynamicRegistration: boolean; + readonly relatedDocumentSupport: boolean; + }; + readonly publishDiagnostics: { readonly versionSupport: boolean }; + }; } export const CLIENT_CAPABILITIES: ClientCapabilities = { - window: { workDoneProgress: true }, - workspace: { - configuration: true, - didChangeWatchedFiles: { dynamicRegistration: true }, - diagnostics: { refreshSupport: false }, - }, - textDocument: { - synchronization: { didOpen: true, didChange: true }, - diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true }, - publishDiagnostics: { versionSupport: false }, - }, + window: { workDoneProgress: true }, + workspace: { + configuration: true, + didChangeWatchedFiles: { dynamicRegistration: true }, + diagnostics: { refreshSupport: false }, + }, + textDocument: { + synchronization: { didOpen: true, didChange: true }, + diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true }, + publishDiagnostics: { versionSupport: false }, + }, }; export interface ClientDeps { - readonly spawn: SpawnProcess; - readonly fileWatcher: FileWatcher; - readonly fs: FsAccess; - readonly command: readonly string[]; - readonly env?: Readonly<Record<string, string>> | undefined; - readonly root: string; - readonly initialization?: Readonly<Record<string, unknown>> | undefined; - readonly serverId: string; + readonly spawn: SpawnProcess; + readonly fileWatcher: FileWatcher; + readonly fs: FsAccess; + readonly command: readonly string[]; + readonly env?: Readonly<Record<string, string>> | undefined; + readonly root: string; + readonly initialization?: Readonly<Record<string, unknown>> | undefined; + readonly serverId: string; } export type ClientState = "starting" | "connected" | "error" | "not-started"; export class LanguageServerClient { - readonly serverId: string; - readonly root: string; - private process: SpawnedProcess | null = null; - private rpc: JsonRpcConnection | null = null; - private decoder = new FrameDecoder(); - private diagnostics = new DiagnosticsStore(); - private watchedFiles = new WatchedFilesRegistry(); - private fileWatcherHandle: FileWatcherHandle | null = null; - private state: ClientState = "not-started"; - private stateError: string | undefined; - private deps: ClientDeps; - 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; - /** - * Corruption detection: the last diagnostic-key set + synced text per URI. - * A healthy server's diagnostics change when the file changes; a corrupted - * one (e.g. Steep's ~3h phantom-SyntaxError drift) re-emits the identical - * non-empty set across edits. `staleRepeat` counts consecutive such repeats - * across URIs; at the threshold the client is marked broken (→ respawn). - */ - private lastDiagSnapshot = new Map<string, { keys: Set<string>; text: string }>(); - private staleRepeat = 0; - private static readonly STALE_REPEAT_THRESHOLD = 5; - /** Default timeout for outbound requests (hover/definition/references). */ - private static readonly REQUEST_TIMEOUT_MS = 10_000; - - constructor(deps: ClientDeps) { - this.deps = deps; - this.serverId = deps.serverId; - this.root = deps.root; - } - - getState(): ClientState { - return this.state; - } - - getStateError(): string | undefined { - return this.stateError; - } - - async start(): Promise<void> { - this.state = "starting"; - try { - const spawnOpts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> } = { - cwd: this.root, - }; - if (this.deps.env) { - (spawnOpts as { env?: Readonly<Record<string, string>> }).env = this.deps.env; - } - const proc = this.deps.spawn(this.deps.command as string[], spawnOpts); - this.process = proc; - // Detect process death so we stop querying a corpse (fixes the - // per-edit hang after a server is killed/crashes). onExit is the - // primary signal; stdout-end is the defence-in-depth fallback. - if (proc.onExit) { - proc.onExit((info) => this.handleExit(info)); - } - - const writeFn: WriteFn = (bytes) => proc.stdin.write(bytes); - const rpc = new JsonRpcConnection(writeFn); - this.rpc = rpc; - - this.setupServerHandlers(rpc); - - const stdoutSource = proc.stdout; - if (Symbol.asyncIterator in stdoutSource) { - this.readFromAsyncIterable(stdoutSource as AsyncIterable<Uint8Array>); - } else { - this.readFromEventSource( - stdoutSource as { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }, - ); - } - - await this.initialize(rpc); - this.state = "connected"; - } catch (err: unknown) { - this.state = "error"; - this.stateError = err instanceof Error ? err.message : String(err); - } - } - - private readFromAsyncIterable(source: AsyncIterable<Uint8Array>): void { - (async () => { - try { - for await (const chunk of source) { - this.handleBytes(chunk); - } - // stdout closed — the process is gone (defence-in-depth alongside onExit, - // which some edges never call). Idempotent via handleExit's guard. - this.handleExit({ code: null }); - } catch { - this.handleExit({ code: null }); - } - })(); - } - - private readFromEventSource(source: { - readonly on: (event: string, cb: (data: Uint8Array) => void) => void; - }): void { - source.on("data", (data: Uint8Array) => { - this.handleBytes(data); - }); - } - - /** - * The server process exited (onExit or stdout-end). Transition to a broken - * state so callers skip it and the manager re-spawns after backoff — instead - * of polling a corpse for the full timeout on every edit. Idempotent. - */ - private handleExit(info: ProcessExitInfo): void { - if (this.state === "error" || this.state === "not-started") return; - const detail = info.signal !== undefined ? `signal ${info.signal}` : `code ${info.code ?? "?"}`; - this.markBroken(`language server process exited (${detail})`); - } - - /** - * Mark this client permanently broken: kill the process if still alive - * (corruption case), dispose the rpc (rejects pending requests), and drop - * edge handles. The manager's status() observes state:"error" and re-spawns - * after the bounded backoff. Called on process death AND on corruption. - */ - private markBroken(reason: string): void { - if (this.state === "error") return; - this.state = "error"; - this.stateError = reason; - this.fileWatcherHandle?.close(); - this.fileWatcherHandle = null; - this.process?.kill(); - this.process = null; - this.rpc?.dispose(); - this.rpc = null; - } - - /** - * Detect a server stuck re-emitting identical non-empty diagnostics - * despite the file content changing between calls — the signature of a - * corrupted parse/type-check state (e.g. Steep's ~3h phantom-SyntaxError - * drift, where a fresh CLI reports green on the same project). After - * STALE_REPEAT_THRESHOLD consecutive such repeats, mark the client broken - * so it is skipped + re-spawned. A clean file (empty diagnostics) or a - * genuinely changing diagnostic set resets the counter. Note the - * tradeoff: a real, unfixed error on an untouched line also "stays the - * same across edits", so this can false-positive on a healthy server — - * the threshold is set conservatively and the CLI type-check gate remains - * authoritative either way. - */ - private detectStaleDiagnostics(uri: string, text: string): void { - const merged = this.diagnostics.getMerged(uri); - const keys = new Set(merged.map((d) => diagnosticKey(d))); - const prev = this.lastDiagSnapshot.get(uri); - if (prev && keys.size > 0 && setsEqual(keys, prev.keys) && text !== prev.text) { - this.staleRepeat++; - } else { - this.staleRepeat = 0; - } - this.lastDiagSnapshot.set(uri, { keys, text }); - if (this.staleRepeat >= LanguageServerClient.STALE_REPEAT_THRESHOLD) { - this.markBroken( - "language server emitting repeated stale diagnostics despite file changes — likely corrupted; restarting", - ); - } - } - - private handleBytes(chunk: Uint8Array): void { - const messages = this.decoder.decode(chunk); - for (const msg of messages) { - // handleMessage is async — catch rejections so a malformed - // message never becomes an unhandled rejection that crashes - // the server. (handleMessage also has its own try/catch around - // JSON.parse, but this is the defence-in-depth boundary.) - void this.rpc?.handleMessage(msg).catch(() => {}); - } - } - - private setupServerHandlers(rpc: JsonRpcConnection): void { - rpc.onNotification("textDocument/publishDiagnostics", (params) => { - this.diagnostics.setPushDiagnostics(params as PublishDiagnosticsParams); - }); - - rpc.onRequest("workspace/configuration", (params) => { - const { items } = params as { readonly items: readonly { readonly section?: string }[] }; - const init = this.deps.initialization ?? {}; - return items.map((item) => { - if (item.section) { - const keys = item.section.split("."); - let value: unknown = init; - for (const key of keys) { - if (value && typeof value === "object" && key in value) { - value = (value as Record<string, unknown>)[key]; - } else { - return undefined; - } - } - return value; - } - return init; - }); - }); - - rpc.onRequest("workspace/workspaceFolders", () => { - return [{ uri: `file://${this.root}`, name: this.root }]; - }); - - rpc.onRequest("window/workDoneProgress/create", () => null); - rpc.onRequest("workspace/diagnostic/refresh", () => null); - - rpc.onRequest("client/registerCapability", (params) => { - const { registrations } = params as { - readonly registrations: readonly { - readonly id: string; - readonly method: string; - readonly registerOptions?: unknown; - }[]; - }; - for (const reg of registrations) { - if (reg.method === "textDocument/diagnostic") { - // Store diagnostic registration (future use) - } else if (reg.method === "workspace/didChangeWatchedFiles") { - const opts = reg.registerOptions as - | import("./watched-files.js").DidChangeWatchedFilesRegistrationOptions - | undefined; - if (opts) { - this.watchedFiles.applyRegister({ - id: reg.id, - method: reg.method, - registerOptions: opts, - }); - } - } - } - return null; - }); - - rpc.onRequest("client/unregisterCapability", (params) => { - const { unregistrations } = params as { - readonly unregistrations: readonly { - readonly id: string; - readonly method: string; - }[]; - }; - for (const unreg of unregistrations) { - this.watchedFiles.applyUnregister(unreg); - } - return null; - }); - } - - private async initialize(rpc: JsonRpcConnection): Promise<void> { - const timeout = 45_000; - - const initPromise = rpc.sendRequest("initialize", { - processId: this.process?.pid ?? null, - rootUri: `file://${this.root}`, - workspaceFolders: [{ uri: `file://${this.root}`, name: this.root }], - capabilities: CLIENT_CAPABILITIES, - }); - - const timeoutPromise = new Promise<never>((_, reject) => { - setTimeout(() => reject(new Error("Initialize timeout")), timeout); - }); - - 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", {}); - - if (this.deps.initialization) { - rpc.sendNotification("workspace/didChangeConfiguration", { - settings: this.deps.initialization, - }); - } - - this.startFileWatcher(); - } - - private startFileWatcher(): void { - const rootPrefix = this.root.endsWith("/") ? this.root : `${this.root}/`; - this.fileWatcherHandle = this.deps.fileWatcher(this.root, (event) => { - const changeType = - event.type === "create" - ? FileChangeType.Created - : event.type === "delete" - ? FileChangeType.Deleted - : FileChangeType.Changed; - - const relativePath = event.path.startsWith(rootPrefix) - ? event.path.slice(rootPrefix.length) - : event.path.replace(/^\/+/, ""); - - if (this.watchedFiles.matches(relativePath)) { - this.rpc?.sendNotification("workspace/didChangeWatchedFiles", { - changes: [{ uri: `file://${event.path}`, type: changeType }], - }); - } - }); - } - - async open(filePath: string): Promise<void> { - const rpc = this.rpc; - if (!rpc || this.state !== "connected") return; - - try { - const text = await this.deps.fs.readText(filePath); - await this.openWithText(filePath, text); - } catch { - // file may not exist - } - } - - 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 start = Date.now(); - - // Poll until the server pushes diagnostics (even empty = done) or the - // per-server cap elapses (then we skip it — see aggregateDiagnostics). - const received = await new Promise<boolean>((resolve) => { - const check = () => { - const elapsed = Date.now() - start; - const got = this.diagnostics.hasReceivedPush(uri); - if (got || elapsed >= timeoutMs) { - resolve(got); - return; - } - setTimeout(check, 100); - }; - check(); - }); - - // Only a server that actually pushed can be corruption-checked. - if (received) { - this.detectStaleDiagnostics(uri, opts?.text ?? ""); - } - - // `slow` is structurally false now: the per-server cap is 10s, so - // elapsed can never exceed the old "unusually long" threshold. That - // warning is superseded by the timeout→skip notice produced in - // aggregateDiagnostics. The field is kept for contract compatibility. - return { - formatted: this.diagnostics.formatFiltered(uri, opts?.minSeverity), - slow: false, - timedOut: !received, - }; - } - - getWatchedFilesRegistry(): WatchedFilesRegistry { - return this.watchedFiles; - } - - getDiagnosticsStore(): DiagnosticsStore { - return this.diagnostics; - } - - /** - * Send a request (hover/definition/references/documentSymbol). Capped at - * REQUEST_TIMEOUT_MS so a dead/slow server can't hang the turn — the - * initialize handshake bypasses this (it calls rpc.sendRequest directly - * with its own 45s race). - */ - async request( - method: string, - params?: unknown, - timeoutMs: number = LanguageServerClient.REQUEST_TIMEOUT_MS, - ): Promise<unknown> { - if (!this.rpc || this.state !== "connected") { - throw new Error("Client not connected"); - } - return this.rpc.sendRequest(method, params, timeoutMs); - } - - shutdown(): void { - this.fileWatcherHandle?.close(); - this.fileWatcherHandle = null; - this.process?.kill(); - this.process = null; - this.rpc?.dispose(); - this.rpc = null; - this.state = "not-started"; - } + readonly serverId: string; + readonly root: string; + private process: SpawnedProcess | null = null; + private rpc: JsonRpcConnection | null = null; + private decoder = new FrameDecoder(); + private diagnostics = new DiagnosticsStore(); + private watchedFiles = new WatchedFilesRegistry(); + private fileWatcherHandle: FileWatcherHandle | null = null; + private state: ClientState = "not-started"; + private stateError: string | undefined; + private deps: ClientDeps; + 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; + /** + * Corruption detection: the last diagnostic-key set + synced text per URI. + * A healthy server's diagnostics change when the file changes; a corrupted + * one (e.g. Steep's ~3h phantom-SyntaxError drift) re-emits the identical + * non-empty set across edits. `staleRepeat` counts consecutive such repeats + * across URIs; at the threshold the client is marked broken (→ respawn). + */ + private lastDiagSnapshot = new Map<string, { keys: Set<string>; text: string }>(); + private staleRepeat = 0; + private static readonly STALE_REPEAT_THRESHOLD = 5; + /** Default timeout for outbound requests (hover/definition/references). */ + private static readonly REQUEST_TIMEOUT_MS = 10_000; + + constructor(deps: ClientDeps) { + this.deps = deps; + this.serverId = deps.serverId; + this.root = deps.root; + } + + getState(): ClientState { + return this.state; + } + + getStateError(): string | undefined { + return this.stateError; + } + + async start(): Promise<void> { + this.state = "starting"; + try { + const spawnOpts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> } = { + cwd: this.root, + }; + if (this.deps.env) { + (spawnOpts as { env?: Readonly<Record<string, string>> }).env = this.deps.env; + } + const proc = this.deps.spawn(this.deps.command as string[], spawnOpts); + this.process = proc; + // Detect process death so we stop querying a corpse (fixes the + // per-edit hang after a server is killed/crashes). onExit is the + // primary signal; stdout-end is the defence-in-depth fallback. + if (proc.onExit) { + proc.onExit((info) => this.handleExit(info)); + } + + const writeFn: WriteFn = (bytes) => proc.stdin.write(bytes); + const rpc = new JsonRpcConnection(writeFn); + this.rpc = rpc; + + this.setupServerHandlers(rpc); + + const stdoutSource = proc.stdout; + if (Symbol.asyncIterator in stdoutSource) { + this.readFromAsyncIterable(stdoutSource as AsyncIterable<Uint8Array>); + } else { + this.readFromEventSource( + stdoutSource as { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }, + ); + } + + await this.initialize(rpc); + this.state = "connected"; + } catch (err: unknown) { + this.state = "error"; + this.stateError = err instanceof Error ? err.message : String(err); + } + } + + private readFromAsyncIterable(source: AsyncIterable<Uint8Array>): void { + (async () => { + try { + for await (const chunk of source) { + this.handleBytes(chunk); + } + // stdout closed — the process is gone (defence-in-depth alongside onExit, + // which some edges never call). Idempotent via handleExit's guard. + this.handleExit({ code: null }); + } catch { + this.handleExit({ code: null }); + } + })(); + } + + private readFromEventSource(source: { + readonly on: (event: string, cb: (data: Uint8Array) => void) => void; + }): void { + source.on("data", (data: Uint8Array) => { + this.handleBytes(data); + }); + } + + /** + * The server process exited (onExit or stdout-end). Transition to a broken + * state so callers skip it and the manager re-spawns after backoff — instead + * of polling a corpse for the full timeout on every edit. Idempotent. + */ + private handleExit(info: ProcessExitInfo): void { + if (this.state === "error" || this.state === "not-started") return; + const detail = info.signal !== undefined ? `signal ${info.signal}` : `code ${info.code ?? "?"}`; + this.markBroken(`language server process exited (${detail})`); + } + + /** + * Mark this client permanently broken: kill the process if still alive + * (corruption case), dispose the rpc (rejects pending requests), and drop + * edge handles. The manager's status() observes state:"error" and re-spawns + * after the bounded backoff. Called on process death AND on corruption. + */ + private markBroken(reason: string): void { + if (this.state === "error") return; + this.state = "error"; + this.stateError = reason; + this.fileWatcherHandle?.close(); + this.fileWatcherHandle = null; + this.process?.kill(); + this.process = null; + this.rpc?.dispose(); + this.rpc = null; + } + + /** + * Detect a server stuck re-emitting identical non-empty diagnostics + * despite the file content changing between calls — the signature of a + * corrupted parse/type-check state (e.g. Steep's ~3h phantom-SyntaxError + * drift, where a fresh CLI reports green on the same project). After + * STALE_REPEAT_THRESHOLD consecutive such repeats, mark the client broken + * so it is skipped + re-spawned. A clean file (empty diagnostics) or a + * genuinely changing diagnostic set resets the counter. Note the + * tradeoff: a real, unfixed error on an untouched line also "stays the + * same across edits", so this can false-positive on a healthy server — + * the threshold is set conservatively and the CLI type-check gate remains + * authoritative either way. + */ + private detectStaleDiagnostics(uri: string, text: string): void { + const merged = this.diagnostics.getMerged(uri); + const keys = new Set(merged.map((d) => diagnosticKey(d))); + const prev = this.lastDiagSnapshot.get(uri); + if (prev && keys.size > 0 && setsEqual(keys, prev.keys) && text !== prev.text) { + this.staleRepeat++; + } else { + this.staleRepeat = 0; + } + this.lastDiagSnapshot.set(uri, { keys, text }); + if (this.staleRepeat >= LanguageServerClient.STALE_REPEAT_THRESHOLD) { + this.markBroken( + "language server emitting repeated stale diagnostics despite file changes — likely corrupted; restarting", + ); + } + } + + private handleBytes(chunk: Uint8Array): void { + const messages = this.decoder.decode(chunk); + for (const msg of messages) { + // handleMessage is async — catch rejections so a malformed + // message never becomes an unhandled rejection that crashes + // the server. (handleMessage also has its own try/catch around + // JSON.parse, but this is the defence-in-depth boundary.) + void this.rpc?.handleMessage(msg).catch(() => {}); + } + } + + private setupServerHandlers(rpc: JsonRpcConnection): void { + rpc.onNotification("textDocument/publishDiagnostics", (params) => { + this.diagnostics.setPushDiagnostics(params as PublishDiagnosticsParams); + }); + + rpc.onRequest("workspace/configuration", (params) => { + const { items } = params as { readonly items: readonly { readonly section?: string }[] }; + const init = this.deps.initialization ?? {}; + return items.map((item) => { + if (item.section) { + const keys = item.section.split("."); + let value: unknown = init; + for (const key of keys) { + if (value && typeof value === "object" && key in value) { + value = (value as Record<string, unknown>)[key]; + } else { + return undefined; + } + } + return value; + } + return init; + }); + }); + + rpc.onRequest("workspace/workspaceFolders", () => { + return [{ uri: `file://${this.root}`, name: this.root }]; + }); + + rpc.onRequest("window/workDoneProgress/create", () => null); + rpc.onRequest("workspace/diagnostic/refresh", () => null); + + rpc.onRequest("client/registerCapability", (params) => { + const { registrations } = params as { + readonly registrations: readonly { + readonly id: string; + readonly method: string; + readonly registerOptions?: unknown; + }[]; + }; + for (const reg of registrations) { + if (reg.method === "textDocument/diagnostic") { + // Store diagnostic registration (future use) + } else if (reg.method === "workspace/didChangeWatchedFiles") { + const opts = reg.registerOptions as + | import("./watched-files.js").DidChangeWatchedFilesRegistrationOptions + | undefined; + if (opts) { + this.watchedFiles.applyRegister({ + id: reg.id, + method: reg.method, + registerOptions: opts, + }); + } + } + } + return null; + }); + + rpc.onRequest("client/unregisterCapability", (params) => { + const { unregistrations } = params as { + readonly unregistrations: readonly { + readonly id: string; + readonly method: string; + }[]; + }; + for (const unreg of unregistrations) { + this.watchedFiles.applyUnregister(unreg); + } + return null; + }); + } + + private async initialize(rpc: JsonRpcConnection): Promise<void> { + const timeout = 45_000; + + const initPromise = rpc.sendRequest("initialize", { + processId: this.process?.pid ?? null, + rootUri: `file://${this.root}`, + workspaceFolders: [{ uri: `file://${this.root}`, name: this.root }], + capabilities: CLIENT_CAPABILITIES, + }); + + const timeoutPromise = new Promise<never>((_, reject) => { + setTimeout(() => reject(new Error("Initialize timeout")), timeout); + }); + + 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", {}); + + if (this.deps.initialization) { + rpc.sendNotification("workspace/didChangeConfiguration", { + settings: this.deps.initialization, + }); + } + + this.startFileWatcher(); + } + + private startFileWatcher(): void { + const rootPrefix = this.root.endsWith("/") ? this.root : `${this.root}/`; + this.fileWatcherHandle = this.deps.fileWatcher(this.root, (event) => { + const changeType = + event.type === "create" + ? FileChangeType.Created + : event.type === "delete" + ? FileChangeType.Deleted + : FileChangeType.Changed; + + const relativePath = event.path.startsWith(rootPrefix) + ? event.path.slice(rootPrefix.length) + : event.path.replace(/^\/+/, ""); + + if (this.watchedFiles.matches(relativePath)) { + this.rpc?.sendNotification("workspace/didChangeWatchedFiles", { + changes: [{ uri: `file://${event.path}`, type: changeType }], + }); + } + }); + } + + async open(filePath: string): Promise<void> { + const rpc = this.rpc; + if (!rpc || this.state !== "connected") return; + + try { + const text = await this.deps.fs.readText(filePath); + await this.openWithText(filePath, text); + } catch { + // file may not exist + } + } + + 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 start = Date.now(); + + // Poll until the server pushes diagnostics (even empty = done) or the + // per-server cap elapses (then we skip it — see aggregateDiagnostics). + const received = await new Promise<boolean>((resolve) => { + const check = () => { + const elapsed = Date.now() - start; + const got = this.diagnostics.hasReceivedPush(uri); + if (got || elapsed >= timeoutMs) { + resolve(got); + return; + } + setTimeout(check, 100); + }; + check(); + }); + + // Only a server that actually pushed can be corruption-checked. + if (received) { + this.detectStaleDiagnostics(uri, opts?.text ?? ""); + } + + // `slow` is structurally false now: the per-server cap is 10s, so + // elapsed can never exceed the old "unusually long" threshold. That + // warning is superseded by the timeout→skip notice produced in + // aggregateDiagnostics. The field is kept for contract compatibility. + return { + formatted: this.diagnostics.formatFiltered(uri, opts?.minSeverity), + slow: false, + timedOut: !received, + }; + } + + getWatchedFilesRegistry(): WatchedFilesRegistry { + return this.watchedFiles; + } + + getDiagnosticsStore(): DiagnosticsStore { + return this.diagnostics; + } + + /** + * Send a request (hover/definition/references/documentSymbol). Capped at + * REQUEST_TIMEOUT_MS so a dead/slow server can't hang the turn — the + * initialize handshake bypasses this (it calls rpc.sendRequest directly + * with its own 45s race). + */ + async request( + method: string, + params?: unknown, + timeoutMs: number = LanguageServerClient.REQUEST_TIMEOUT_MS, + ): Promise<unknown> { + if (!this.rpc || this.state !== "connected") { + throw new Error("Client not connected"); + } + return this.rpc.sendRequest(method, params, timeoutMs); + } + + shutdown(): void { + this.fileWatcherHandle?.close(); + this.fileWatcherHandle = null; + this.process?.kill(); + this.process = null; + this.rpc?.dispose(); + this.rpc = null; + this.state = "not-started"; + } } function setsEqual<T>(a: Set<T>, b: Set<T>): boolean { - if (a.size !== b.size) return false; - for (const v of a) { - if (!b.has(v)) return false; - } - return true; + if (a.size !== b.size) return false; + for (const v of a) { + if (!b.has(v)) return false; + } + return true; } diff --git a/packages/lsp/src/config.test.ts b/packages/lsp/src/config.test.ts index 6d51774..0fa27e3 100644 --- a/packages/lsp/src/config.test.ts +++ b/packages/lsp/src/config.test.ts @@ -2,201 +2,201 @@ import { describe, expect, it } from "vitest"; import { resolveServers } from "./config.js"; describe("config", () => { - it("built-in typescript resolves when tsconfig.json exists", async () => { - const { servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: null, - opencodeJson: null, - exists: async (path) => path === "/project/tsconfig.json", - }); - - const ts = servers.find((s) => s.id === "typescript"); - expect(ts).toBeDefined(); - expect(ts?.command).toEqual(["typescript-language-server", "--stdio"]); - expect(ts?.extensions).toContain(".ts"); - expect(ts?.rootMarkers).toContain("tsconfig.json"); - }); - - it(".dispatch/lsp.json servers resolve", async () => { - const config = JSON.stringify({ - servers: { - mylsp: { - command: ["my-lsp", "--stdio"], - extensions: [".ml"], - rootMarkers: ["Makefile"], - }, - }, - }); - - const { servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: config, - opencodeJson: null, - exists: async () => false, - }); - - expect(servers).toHaveLength(1); - expect(servers[0]?.id).toBe("mylsp"); - expect(servers[0]?.command).toEqual(["my-lsp", "--stdio"]); - }); - - it("opencode.json lsp is used only as fallback", async () => { - const opencodeConfig = JSON.stringify({ - lsp: { - fallback: { - command: ["fallback-lsp"], - extensions: [".fb"], - }, - }, - }); - - const { servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: null, - opencodeJson: opencodeConfig, - exists: async () => false, - }); - - expect(servers).toHaveLength(1); - expect(servers[0]?.id).toBe("fallback"); - }); - - it(".dispatch/lsp.json wins over opencode.json", async () => { - const dispatchConfig = JSON.stringify({ - servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, - }); - const opencodeConfig = JSON.stringify({ - lsp: { fallback: { command: ["fallback-lsp"], extensions: [".f"] } }, - }); - - const { servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: dispatchConfig, - opencodeJson: opencodeConfig, - exists: async () => false, - }); - - expect(servers).toHaveLength(1); - expect(servers[0]?.id).toBe("primary"); - }); - - it("luau-lsp sourcemap.autogenerate yields a rojo --watch sidecar", async () => { - const config = JSON.stringify({ - servers: { - luau: { - command: ["luau-lsp", "lsp"], - extensions: [".luau"], - initialization: { - "luau-lsp": { - sourcemap: { - autogenerate: true, - rojoProjectFile: "default.project.json", - }, - }, - }, - }, - }, - }); - - const { servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: config, - opencodeJson: null, - exists: async () => false, - }); - - expect(servers).toHaveLength(1); - expect(servers[0]?.sidecar).toBeDefined(); - expect(servers[0]?.sidecar?.command).toEqual([ - "rojo", - "sourcemap", - "default.project.json", - "--watch", - "-o", - "sourcemap.json", - ]); - }); - - it("config: resolveServers records configSource", async () => { - // .dispatch/lsp.json entry → ".dispatch/lsp.json" - const dispatch = JSON.stringify({ - servers: { - mylsp: { command: ["my-lsp"], extensions: [".ml"] }, - }, - }); - const fromDispatch = await resolveServers({ - cwd: "/project", - dispatchLspJson: dispatch, - opencodeJson: null, - exists: async () => false, - }); - expect(fromDispatch.servers[0]?.configSource).toBe(".dispatch/lsp.json"); - - // fallback to opencode.json → "opencode.json" - const opencode = JSON.stringify({ - lsp: { - oc: { command: ["oc-lsp"], extensions: [".oc"] }, - }, - }); - const fromOpencode = await resolveServers({ - cwd: "/project", - dispatchLspJson: null, - opencodeJson: opencode, - exists: async () => false, - }); - expect(fromOpencode.servers[0]?.configSource).toBe("opencode.json"); - - // built-in TS → "built-in" - const fromBuiltin = await resolveServers({ - cwd: "/project", - dispatchLspJson: null, - opencodeJson: null, - exists: async () => false, - }); - expect(fromBuiltin.servers[0]?.configSource).toBe("built-in"); - }); - - it("config: shadowed flag is true when .dispatch/lsp.json shadows opencode.json lsp", async () => { - const dispatch = JSON.stringify({ - servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, - }); - const opencode = JSON.stringify({ - lsp: { fallback: { command: ["fallback-lsp"], extensions: [".f"] } }, - }); - - const { shadowed, servers } = await resolveServers({ - cwd: "/project", - dispatchLspJson: dispatch, - opencodeJson: opencode, - exists: async () => false, - }); - - // dispatch wins, opencode entry is shadowed - expect(shadowed).toBe(true); - expect(servers.map((s) => s.id)).toEqual(["primary"]); - }); - - it("config: shadowed flag is false when only one config source declares lsp", async () => { - const dispatch = JSON.stringify({ - servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, - }); - - // dispatch present, opencode has NO lsp key → not shadowed - const onlyDispatch = await resolveServers({ - cwd: "/project", - dispatchLspJson: dispatch, - opencodeJson: JSON.stringify({ lsp: {} }), - exists: async () => false, - }); - expect(onlyDispatch.shadowed).toBe(false); - - // dispatch absent, opencode has lsp → not shadowed (opencode is used) - const onlyOpencode = await resolveServers({ - cwd: "/project", - dispatchLspJson: null, - opencodeJson: JSON.stringify({ lsp: { fb: { command: ["fb"] } } }), - exists: async () => false, - }); - expect(onlyOpencode.shadowed).toBe(false); - }); + it("built-in typescript resolves when tsconfig.json exists", async () => { + const { servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: null, + exists: async (path) => path === "/project/tsconfig.json", + }); + + const ts = servers.find((s) => s.id === "typescript"); + expect(ts).toBeDefined(); + expect(ts?.command).toEqual(["typescript-language-server", "--stdio"]); + expect(ts?.extensions).toContain(".ts"); + expect(ts?.rootMarkers).toContain("tsconfig.json"); + }); + + it(".dispatch/lsp.json servers resolve", async () => { + const config = JSON.stringify({ + servers: { + mylsp: { + command: ["my-lsp", "--stdio"], + extensions: [".ml"], + rootMarkers: ["Makefile"], + }, + }, + }); + + const { servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: config, + opencodeJson: null, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("mylsp"); + expect(servers[0]?.command).toEqual(["my-lsp", "--stdio"]); + }); + + it("opencode.json lsp is used only as fallback", async () => { + const opencodeConfig = JSON.stringify({ + lsp: { + fallback: { + command: ["fallback-lsp"], + extensions: [".fb"], + }, + }, + }); + + const { servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: opencodeConfig, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("fallback"); + }); + + it(".dispatch/lsp.json wins over opencode.json", async () => { + const dispatchConfig = JSON.stringify({ + servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, + }); + const opencodeConfig = JSON.stringify({ + lsp: { fallback: { command: ["fallback-lsp"], extensions: [".f"] } }, + }); + + const { servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: dispatchConfig, + opencodeJson: opencodeConfig, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("primary"); + }); + + it("luau-lsp sourcemap.autogenerate yields a rojo --watch sidecar", async () => { + const config = JSON.stringify({ + servers: { + luau: { + command: ["luau-lsp", "lsp"], + extensions: [".luau"], + initialization: { + "luau-lsp": { + sourcemap: { + autogenerate: true, + rojoProjectFile: "default.project.json", + }, + }, + }, + }, + }, + }); + + const { servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: config, + opencodeJson: null, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.sidecar).toBeDefined(); + expect(servers[0]?.sidecar?.command).toEqual([ + "rojo", + "sourcemap", + "default.project.json", + "--watch", + "-o", + "sourcemap.json", + ]); + }); + + it("config: resolveServers records configSource", async () => { + // .dispatch/lsp.json entry → ".dispatch/lsp.json" + const dispatch = JSON.stringify({ + servers: { + mylsp: { command: ["my-lsp"], extensions: [".ml"] }, + }, + }); + const fromDispatch = await resolveServers({ + cwd: "/project", + dispatchLspJson: dispatch, + opencodeJson: null, + exists: async () => false, + }); + expect(fromDispatch.servers[0]?.configSource).toBe(".dispatch/lsp.json"); + + // fallback to opencode.json → "opencode.json" + const opencode = JSON.stringify({ + lsp: { + oc: { command: ["oc-lsp"], extensions: [".oc"] }, + }, + }); + const fromOpencode = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: opencode, + exists: async () => false, + }); + expect(fromOpencode.servers[0]?.configSource).toBe("opencode.json"); + + // built-in TS → "built-in" + const fromBuiltin = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: null, + exists: async () => false, + }); + expect(fromBuiltin.servers[0]?.configSource).toBe("built-in"); + }); + + it("config: shadowed flag is true when .dispatch/lsp.json shadows opencode.json lsp", async () => { + const dispatch = JSON.stringify({ + servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, + }); + const opencode = JSON.stringify({ + lsp: { fallback: { command: ["fallback-lsp"], extensions: [".f"] } }, + }); + + const { shadowed, servers } = await resolveServers({ + cwd: "/project", + dispatchLspJson: dispatch, + opencodeJson: opencode, + exists: async () => false, + }); + + // dispatch wins, opencode entry is shadowed + expect(shadowed).toBe(true); + expect(servers.map((s) => s.id)).toEqual(["primary"]); + }); + + it("config: shadowed flag is false when only one config source declares lsp", async () => { + const dispatch = JSON.stringify({ + servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, + }); + + // dispatch present, opencode has NO lsp key → not shadowed + const onlyDispatch = await resolveServers({ + cwd: "/project", + dispatchLspJson: dispatch, + opencodeJson: JSON.stringify({ lsp: {} }), + exists: async () => false, + }); + expect(onlyDispatch.shadowed).toBe(false); + + // dispatch absent, opencode has lsp → not shadowed (opencode is used) + const onlyOpencode = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: JSON.stringify({ lsp: { fb: { command: ["fb"] } } }), + exists: async () => false, + }); + expect(onlyOpencode.shadowed).toBe(false); + }); }); diff --git a/packages/lsp/src/config.ts b/packages/lsp/src/config.ts index afec6bf..09cc9ce 100644 --- a/packages/lsp/src/config.ts +++ b/packages/lsp/src/config.ts @@ -11,22 +11,22 @@ */ export interface ResolvedServer { - readonly id: string; - readonly name: string; - readonly command: readonly string[]; - readonly env?: Readonly<Record<string, string>> | undefined; - readonly extensions: readonly string[]; - readonly rootMarkers: readonly string[]; - readonly initialization?: Readonly<Record<string, unknown>> | undefined; - readonly sidecar?: { readonly command: readonly string[] } | undefined; - /** - * Which config source this server was resolved from: `".dispatch/lsp.json"`, - * `"opencode.json"`, or `"built-in"`. Omitted when not yet resolved. Mirrors - * the wire `LspServerInfo.configSource` so config-shadow debugging surfaces - * to the status caller (a broken `.dispatch/lsp.json` silently shadowing - * `opencode.json`). - */ - readonly configSource?: string | undefined; + readonly id: string; + readonly name: string; + readonly command: readonly string[]; + readonly env?: Readonly<Record<string, string>> | undefined; + readonly extensions: readonly string[]; + readonly rootMarkers: readonly string[]; + readonly initialization?: Readonly<Record<string, unknown>> | undefined; + readonly sidecar?: { readonly command: readonly string[] } | undefined; + /** + * Which config source this server was resolved from: `".dispatch/lsp.json"`, + * `"opencode.json"`, or `"built-in"`. Omitted when not yet resolved. Mirrors + * the wire `LspServerInfo.configSource` so config-shadow debugging surfaces + * to the status caller (a broken `.dispatch/lsp.json` silently shadowing + * `opencode.json`). + */ + readonly configSource?: string | undefined; } /** Which config source a resolved server came from. */ @@ -35,153 +35,153 @@ export type ConfigSource = ".dispatch/lsp.json" | "opencode.json" | "built-in"; /** Result of resolving servers: the servers + whether `opencode.json`'s lsp key * was silently shadowed by a present `.dispatch/lsp.json`. */ export interface ResolveResult { - readonly servers: readonly ResolvedServer[]; - readonly shadowed: boolean; + readonly servers: readonly ResolvedServer[]; + readonly shadowed: boolean; } export interface ServerConfig { - readonly id?: string | undefined; - readonly name?: string | undefined; - readonly command: readonly string[]; - readonly env?: Readonly<Record<string, string>> | undefined; - readonly extensions?: readonly string[] | undefined; - readonly rootMarkers?: readonly string[] | undefined; - readonly initialization?: Readonly<Record<string, unknown>> | undefined; - readonly watch?: readonly string[] | undefined; + readonly id?: string | undefined; + readonly name?: string | undefined; + readonly command: readonly string[]; + readonly env?: Readonly<Record<string, string>> | undefined; + readonly extensions?: readonly string[] | undefined; + readonly rootMarkers?: readonly string[] | undefined; + readonly initialization?: Readonly<Record<string, unknown>> | undefined; + readonly watch?: readonly string[] | undefined; } export interface LspJsonConfig { - readonly servers?: Readonly<Record<string, ServerConfig>> | undefined; + readonly servers?: Readonly<Record<string, ServerConfig>> | undefined; } export interface OpencodeJsonConfig { - readonly lsp?: Readonly<Record<string, ServerConfig>> | undefined; + readonly lsp?: Readonly<Record<string, ServerConfig>> | undefined; } export interface ResolveServersDeps { - readonly cwd: string; - readonly dispatchLspJson: string | null; - readonly opencodeJson: string | null; - readonly exists: (path: string) => Promise<boolean>; + readonly cwd: string; + readonly dispatchLspJson: string | null; + readonly opencodeJson: string | null; + readonly exists: (path: string) => Promise<boolean>; } const BUILT_IN_REGISTRY: Record<string, ResolvedServer> = { - typescript: { - id: "typescript", - name: "TypeScript Language Server", - command: ["typescript-language-server", "--stdio"], - extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], - rootMarkers: ["tsconfig.json", "package.json"], - configSource: "built-in", - }, + typescript: { + id: "typescript", + name: "TypeScript Language Server", + command: ["typescript-language-server", "--stdio"], + extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], + rootMarkers: ["tsconfig.json", "package.json"], + configSource: "built-in", + }, }; export async function resolveServers(deps: ResolveServersDeps): Promise<ResolveResult> { - const result = new Map<string, ResolvedServer>(); - - // Parse opencode.json once — used both as the fallback source and to detect - // whether a present `.dispatch/lsp.json` silently shadows its `lsp` key. - let opencodeConfig: OpencodeJsonConfig | null = null; - if (deps.opencodeJson) { - try { - opencodeConfig = JSON.parse(deps.opencodeJson) as OpencodeJsonConfig; - } catch { - // ignore parse errors - } - } - const opencodeHasLsp = !!opencodeConfig?.lsp && Object.keys(opencodeConfig.lsp).length > 0; - - // 1. cwd/.dispatch/lsp.json (highest precedence) - let dispatchHadServers = false; - if (deps.dispatchLspJson) { - try { - const config = JSON.parse(deps.dispatchLspJson) as LspJsonConfig; - if (config.servers) { - for (const [key, server] of Object.entries(config.servers)) { - const resolved = resolveServer(key, server, ".dispatch/lsp.json"); - result.set(resolved.id, resolved); - } - dispatchHadServers = result.size > 0; - } - } catch { - // ignore parse errors - } - } - - // 2. fallback cwd/opencode.json lsp (only when dispatch yielded nothing) - if (result.size === 0 && opencodeConfig?.lsp) { - for (const [key, server] of Object.entries(opencodeConfig.lsp)) { - const resolved = resolveServer(key, server, "opencode.json"); - result.set(resolved.id, resolved); - } - } - - // 3. the built-in registry (when nothing else resolved) - if (result.size === 0) { - for (const [, server] of Object.entries(BUILT_IN_REGISTRY)) { - result.set(server.id, server); - } - } - - // `.dispatch/lsp.json` silently shadows `opencode.json`'s lsp key when both - // declare servers — the opencode entry is skipped with no warning otherwise. - const shadowed = dispatchHadServers && opencodeHasLsp; - return { servers: [...result.values()], shadowed }; + const result = new Map<string, ResolvedServer>(); + + // Parse opencode.json once — used both as the fallback source and to detect + // whether a present `.dispatch/lsp.json` silently shadows its `lsp` key. + let opencodeConfig: OpencodeJsonConfig | null = null; + if (deps.opencodeJson) { + try { + opencodeConfig = JSON.parse(deps.opencodeJson) as OpencodeJsonConfig; + } catch { + // ignore parse errors + } + } + const opencodeHasLsp = !!opencodeConfig?.lsp && Object.keys(opencodeConfig.lsp).length > 0; + + // 1. cwd/.dispatch/lsp.json (highest precedence) + let dispatchHadServers = false; + if (deps.dispatchLspJson) { + try { + const config = JSON.parse(deps.dispatchLspJson) as LspJsonConfig; + if (config.servers) { + for (const [key, server] of Object.entries(config.servers)) { + const resolved = resolveServer(key, server, ".dispatch/lsp.json"); + result.set(resolved.id, resolved); + } + dispatchHadServers = result.size > 0; + } + } catch { + // ignore parse errors + } + } + + // 2. fallback cwd/opencode.json lsp (only when dispatch yielded nothing) + if (result.size === 0 && opencodeConfig?.lsp) { + for (const [key, server] of Object.entries(opencodeConfig.lsp)) { + const resolved = resolveServer(key, server, "opencode.json"); + result.set(resolved.id, resolved); + } + } + + // 3. the built-in registry (when nothing else resolved) + if (result.size === 0) { + for (const [, server] of Object.entries(BUILT_IN_REGISTRY)) { + result.set(server.id, server); + } + } + + // `.dispatch/lsp.json` silently shadows `opencode.json`'s lsp key when both + // declare servers — the opencode entry is skipped with no warning otherwise. + const shadowed = dispatchHadServers && opencodeHasLsp; + return { servers: [...result.values()], shadowed }; } function resolveServer( - key: string, - config: ServerConfig, - configSource: ConfigSource, + key: string, + config: ServerConfig, + configSource: ConfigSource, ): ResolvedServer { - const id = config.id ?? key; - const name = config.name ?? id; - const extensions = config.extensions ?? []; - const rootMarkers = config.rootMarkers ?? []; - - let sidecar: { readonly command: readonly string[] } | undefined; - if (config.watch) { - sidecar = { command: config.watch }; - } else if (config.initialization) { - sidecar = detectSidecar(config.initialization); - } - - const result: ResolvedServer = { - id, - name, - command: config.command, - extensions, - rootMarkers, - configSource, - }; - if (config.env) { - (result as { env?: Readonly<Record<string, string>> }).env = config.env; - } - if (config.initialization) { - (result as { initialization?: Readonly<Record<string, unknown>> }).initialization = - config.initialization; - } - if (sidecar) { - (result as { sidecar?: { readonly command: readonly string[] } }).sidecar = sidecar; - } - return result; + const id = config.id ?? key; + const name = config.name ?? id; + const extensions = config.extensions ?? []; + const rootMarkers = config.rootMarkers ?? []; + + let sidecar: { readonly command: readonly string[] } | undefined; + if (config.watch) { + sidecar = { command: config.watch }; + } else if (config.initialization) { + sidecar = detectSidecar(config.initialization); + } + + const result: ResolvedServer = { + id, + name, + command: config.command, + extensions, + rootMarkers, + configSource, + }; + if (config.env) { + (result as { env?: Readonly<Record<string, string>> }).env = config.env; + } + if (config.initialization) { + (result as { initialization?: Readonly<Record<string, unknown>> }).initialization = + config.initialization; + } + if (sidecar) { + (result as { sidecar?: { readonly command: readonly string[] } }).sidecar = sidecar; + } + return result; } function detectSidecar( - init: Readonly<Record<string, unknown>>, + init: Readonly<Record<string, unknown>>, ): { readonly command: readonly string[] } | undefined { - const luauLsp = init["luau-lsp"]; - if (!luauLsp || typeof luauLsp !== "object") return undefined; - const luau = luauLsp as Record<string, unknown>; - const sourcemap = luau.sourcemap; - if (!sourcemap || typeof sourcemap !== "object") return undefined; - const sm = sourcemap as Record<string, unknown>; - if (sm.autogenerate !== true) return undefined; - const rojoProjectFile = sm.rojoProjectFile; - if (typeof rojoProjectFile !== "string") return undefined; - return { - command: ["rojo", "sourcemap", rojoProjectFile, "--watch", "-o", "sourcemap.json"], - }; + const luauLsp = init["luau-lsp"]; + if (!luauLsp || typeof luauLsp !== "object") return undefined; + const luau = luauLsp as Record<string, unknown>; + const sourcemap = luau.sourcemap; + if (!sourcemap || typeof sourcemap !== "object") return undefined; + const sm = sourcemap as Record<string, unknown>; + if (sm.autogenerate !== true) return undefined; + const rojoProjectFile = sm.rojoProjectFile; + if (typeof rojoProjectFile !== "string") return undefined; + return { + command: ["rojo", "sourcemap", rojoProjectFile, "--watch", "-o", "sourcemap.json"], + }; } /** @@ -195,20 +195,20 @@ function detectSidecar( * server stays broken until a bounded backoff elapses. */ export function configFingerprint(server: ResolvedServer): string { - return stableStringify(server); + return stableStringify(server); } /** Deterministic JSON: object keys sorted at every nesting level. */ function stableStringify(value: unknown): string { - return JSON.stringify(canonicalize(value)); + return JSON.stringify(canonicalize(value)); } function canonicalize(value: unknown): unknown { - if (value === null || typeof value !== "object") return value; - if (Array.isArray(value)) return value.map(canonicalize); - const sorted: Record<string, unknown> = {}; - for (const key of Object.keys(value as Record<string, unknown>).sort()) { - sorted[key] = canonicalize((value as Record<string, unknown>)[key]); - } - return sorted; + if (value === null || typeof value !== "object") return value; + if (Array.isArray(value)) return value.map(canonicalize); + const sorted: Record<string, unknown> = {}; + for (const key of Object.keys(value as Record<string, unknown>).sort()) { + sorted[key] = canonicalize((value as Record<string, unknown>)[key]); + } + return sorted; } diff --git a/packages/lsp/src/diagnostics.test.ts b/packages/lsp/src/diagnostics.test.ts index 9f2b6b4..e72e007 100644 --- a/packages/lsp/src/diagnostics.test.ts +++ b/packages/lsp/src/diagnostics.test.ts @@ -2,50 +2,50 @@ import { describe, expect, it } from "vitest"; import { DiagnosticsStore } from "./diagnostics.js"; describe("diagnostics", () => { - it("formats diagnostics with severity, location, and message", () => { - const store = new DiagnosticsStore(); - store.setPushDiagnostics({ - uri: "file:///test.ts", - diagnostics: [ - { - range: { start: { line: 0, character: 5 }, end: { line: 0, character: 10 } }, - severity: 1, - source: "typescript", - message: "Cannot find name 'hello'.", - }, - ], - }); + it("formats diagnostics with severity, location, and message", () => { + const store = new DiagnosticsStore(); + store.setPushDiagnostics({ + uri: "file:///test.ts", + diagnostics: [ + { + range: { start: { line: 0, character: 5 }, end: { line: 0, character: 10 } }, + severity: 1, + source: "typescript", + message: "Cannot find name 'hello'.", + }, + ], + }); - const formatted = store.format("file:///test.ts"); - expect(formatted).toContain("ERROR"); - expect(formatted).toContain("L1:6"); - expect(formatted).toContain("Cannot find name 'hello'."); - expect(formatted).toContain("[typescript]"); - }); + const formatted = store.format("file:///test.ts"); + expect(formatted).toContain("ERROR"); + expect(formatted).toContain("L1:6"); + expect(formatted).toContain("Cannot find name 'hello'."); + expect(formatted).toContain("[typescript]"); + }); - it("merges push and pull diagnostics with deduplication", () => { - const store = new DiagnosticsStore(); - const diag = { - range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, - severity: 1, - message: "Error", - }; + it("merges push and pull diagnostics with deduplication", () => { + const store = new DiagnosticsStore(); + const diag = { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, + severity: 1, + message: "Error", + }; - store.setPushDiagnostics({ - uri: "file:///test.ts", - diagnostics: [diag], - }); - store.setPullDiagnostics("file:///test.ts", { - kind: "full", - items: [diag], - }); + store.setPushDiagnostics({ + uri: "file:///test.ts", + diagnostics: [diag], + }); + store.setPullDiagnostics("file:///test.ts", { + kind: "full", + items: [diag], + }); - const merged = store.getMerged("file:///test.ts"); - expect(merged).toHaveLength(1); - }); + const merged = store.getMerged("file:///test.ts"); + expect(merged).toHaveLength(1); + }); - it("returns empty string when no diagnostics exist", () => { - const store = new DiagnosticsStore(); - expect(store.format("file:///nonexistent.ts")).toBe(""); - }); + it("returns empty string when no diagnostics exist", () => { + const store = new DiagnosticsStore(); + expect(store.format("file:///nonexistent.ts")).toBe(""); + }); }); diff --git a/packages/lsp/src/diagnostics.ts b/packages/lsp/src/diagnostics.ts index 50beca9..ccd695f 100644 --- a/packages/lsp/src/diagnostics.ts +++ b/packages/lsp/src/diagnostics.ts @@ -4,107 +4,107 @@ */ export interface Diagnostic { - readonly range: { - readonly start: { readonly line: number; readonly character: number }; - readonly end: { readonly line: number; readonly character: number }; - }; - readonly severity?: number; - readonly source?: string; - readonly message: string; - readonly code?: string | number; + readonly range: { + readonly start: { readonly line: number; readonly character: number }; + readonly end: { readonly line: number; readonly character: number }; + }; + readonly severity?: number; + readonly source?: string; + readonly message: string; + readonly code?: string | number; } export interface PublishDiagnosticsParams { - readonly uri: string; - readonly diagnostics: readonly Diagnostic[]; + readonly uri: string; + readonly diagnostics: readonly Diagnostic[]; } export interface DocumentDiagnosticReport { - readonly kind: "full" | "unchanged"; - readonly items?: readonly Diagnostic[]; + readonly kind: "full" | "unchanged"; + readonly items?: readonly Diagnostic[]; } const severityNames: Record<number, string> = { - 1: "ERROR", - 2: "WARNING", - 3: "INFO", - 4: "HINT", + 1: "ERROR", + 2: "WARNING", + 3: "INFO", + 4: "HINT", }; export class DiagnosticsStore { - private pushDiagnostics = new Map<string, readonly Diagnostic[]>(); - private pullDiagnostics = new Map<string, readonly Diagnostic[]>(); - private pushReceived = new Set<string>(); + private pushDiagnostics = new Map<string, readonly Diagnostic[]>(); + private pullDiagnostics = new Map<string, readonly Diagnostic[]>(); + private pushReceived = new Set<string>(); - setPushDiagnostics(params: PublishDiagnosticsParams): void { - this.pushDiagnostics.set(params.uri, params.diagnostics); - this.pushReceived.add(params.uri); - } + setPushDiagnostics(params: PublishDiagnosticsParams): void { + this.pushDiagnostics.set(params.uri, params.diagnostics); + this.pushReceived.add(params.uri); + } - setPullDiagnostics(uri: string, report: DocumentDiagnosticReport): void { - if (report.kind === "full" && report.items) { - this.pullDiagnostics.set(uri, report.items); - } - } + setPullDiagnostics(uri: string, report: DocumentDiagnosticReport): void { + if (report.kind === "full" && report.items) { + this.pullDiagnostics.set(uri, report.items); + } + } - /** True if the server has pushed at least one publishDiagnostics for this URI. */ - hasReceivedPush(uri: string): boolean { - return this.pushReceived.has(uri); - } + /** True if the server has pushed at least one publishDiagnostics for this URI. */ + hasReceivedPush(uri: string): boolean { + return this.pushReceived.has(uri); + } - /** Clear the "received" flag so the next waitForDiagnostics poll detects fresh pushes. */ - clearReceived(uri: string): void { - this.pushReceived.delete(uri); - } + /** Clear the "received" flag so the next waitForDiagnostics poll detects fresh pushes. */ + clearReceived(uri: string): void { + this.pushReceived.delete(uri); + } - getMerged(uri: string): readonly Diagnostic[] { - const push = this.pushDiagnostics.get(uri) ?? []; - const pull = this.pullDiagnostics.get(uri) ?? []; - return dedupeDiagnostics([...push, ...pull]); - } + getMerged(uri: string): readonly Diagnostic[] { + const push = this.pushDiagnostics.get(uri) ?? []; + const pull = this.pullDiagnostics.get(uri) ?? []; + return dedupeDiagnostics([...push, ...pull]); + } - /** - * Format diagnostics for a URI, optionally filtering by minimum severity. - * `minSeverity` includes only diagnostics with severity ≤ the given value - * (1=Error, 2=Warning, 3=Info, 4=Hint). Omit to include all. - */ - formatFiltered(uri: string, minSeverity?: number): string { - let diags = this.getMerged(uri); - if (minSeverity !== undefined) { - diags = diags.filter((d) => (d.severity ?? 0) <= minSeverity); - } - if (diags.length === 0) return ""; - const lines: string[] = []; - for (const d of diags) { - const sev = d.severity ? (severityNames[d.severity] ?? "UNKNOWN") : "UNKNOWN"; - const line = d.range.start.line + 1; - const col = d.range.start.character + 1; - const src = d.source ? ` [${d.source}]` : ""; - const code = d.code ? ` (${d.code})` : ""; - lines.push(`${sev}${code}${src} L${line}:${col}: ${d.message}`); - } - return lines.join("\n"); - } + /** + * Format diagnostics for a URI, optionally filtering by minimum severity. + * `minSeverity` includes only diagnostics with severity ≤ the given value + * (1=Error, 2=Warning, 3=Info, 4=Hint). Omit to include all. + */ + formatFiltered(uri: string, minSeverity?: number): string { + let diags = this.getMerged(uri); + if (minSeverity !== undefined) { + diags = diags.filter((d) => (d.severity ?? 0) <= minSeverity); + } + if (diags.length === 0) return ""; + const lines: string[] = []; + for (const d of diags) { + const sev = d.severity ? (severityNames[d.severity] ?? "UNKNOWN") : "UNKNOWN"; + const line = d.range.start.line + 1; + const col = d.range.start.character + 1; + const src = d.source ? ` [${d.source}]` : ""; + const code = d.code ? ` (${d.code})` : ""; + lines.push(`${sev}${code}${src} L${line}:${col}: ${d.message}`); + } + return lines.join("\n"); + } - format(uri: string): string { - return this.formatFiltered(uri); - } + format(uri: string): string { + return this.formatFiltered(uri); + } } export function diagnosticKey(d: Diagnostic): string { - const r = d.range; - return `${r.start.line}:${r.start.character}-${r.end.line}:${r.end.character}:${d.severity ?? 0}:${d.message}`; + const r = d.range; + return `${r.start.line}:${r.start.character}-${r.end.line}:${r.end.character}:${d.severity ?? 0}:${d.message}`; } function dedupeDiagnostics(diags: readonly Diagnostic[]): readonly Diagnostic[] { - const seen = new Set<string>(); - const result: Diagnostic[] = []; - for (const d of diags) { - const key = diagnosticKey(d); - if (!seen.has(key)) { - seen.add(key); - result.push(d); - } - } - return result; + const seen = new Set<string>(); + const result: Diagnostic[] = []; + for (const d of diags) { + const key = diagnosticKey(d); + if (!seen.has(key)) { + seen.add(key); + result.push(d); + } + } + return result; } diff --git a/packages/lsp/src/diff.test.ts b/packages/lsp/src/diff.test.ts index b5b6a7b..add3c64 100644 --- a/packages/lsp/src/diff.test.ts +++ b/packages/lsp/src/diff.test.ts @@ -2,116 +2,116 @@ import { describe, expect, it } from "vitest"; import { computeChangeRange, offsetToPosition } from "./diff.js"; describe("offsetToPosition", () => { - it("returns 0:0 for offset 0", () => { - expect(offsetToPosition("hello", 0)).toEqual({ line: 0, character: 0 }); - }); + it("returns 0:0 for offset 0", () => { + expect(offsetToPosition("hello", 0)).toEqual({ line: 0, character: 0 }); + }); - it("counts characters on the first line", () => { - expect(offsetToPosition("hello", 3)).toEqual({ line: 0, character: 3 }); - }); + it("counts characters on the first line", () => { + expect(offsetToPosition("hello", 3)).toEqual({ line: 0, character: 3 }); + }); - it("resets character count after newline", () => { - expect(offsetToPosition("ab\ncd", 4)).toEqual({ line: 1, character: 1 }); - }); + it("resets character count after newline", () => { + expect(offsetToPosition("ab\ncd", 4)).toEqual({ line: 1, character: 1 }); + }); - it("handles multiple lines", () => { - expect(offsetToPosition("a\nb\nc", 4)).toEqual({ line: 2, character: 0 }); - }); + it("handles multiple lines", () => { + expect(offsetToPosition("a\nb\nc", 4)).toEqual({ line: 2, character: 0 }); + }); - it("clamps offset beyond text length", () => { - expect(offsetToPosition("ab", 100)).toEqual({ line: 0, character: 2 }); - }); + it("clamps offset beyond text length", () => { + expect(offsetToPosition("ab", 100)).toEqual({ line: 0, character: 2 }); + }); - it("handles empty string", () => { - expect(offsetToPosition("", 0)).toEqual({ line: 0, character: 0 }); - }); + it("handles empty string", () => { + expect(offsetToPosition("", 0)).toEqual({ line: 0, character: 0 }); + }); }); describe("computeChangeRange", () => { - it("detects a single-line insertion", () => { - const oldText = "hello world"; - const newText = "hello cruel world"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 6 }); - expect(change.range.end).toEqual({ line: 0, character: 6 }); - expect(change.text).toBe("cruel "); - }); + it("detects a single-line insertion", () => { + const oldText = "hello world"; + const newText = "hello cruel world"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 6 }); + expect(change.range.end).toEqual({ line: 0, character: 6 }); + expect(change.text).toBe("cruel "); + }); - it("detects a single-line deletion", () => { - const oldText = "hello cruel world"; - const newText = "hello world"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 6 }); - expect(change.range.end).toEqual({ line: 0, character: 12 }); - expect(change.text).toBe(""); - }); + it("detects a single-line deletion", () => { + const oldText = "hello cruel world"; + const newText = "hello world"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 6 }); + expect(change.range.end).toEqual({ line: 0, character: 12 }); + expect(change.text).toBe(""); + }); - it("detects a single-line replacement", () => { - const oldText = "hello world"; - const newText = "hello earth"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 6 }); - expect(change.range.end).toEqual({ line: 0, character: 11 }); - expect(change.text).toBe("earth"); - }); + it("detects a single-line replacement", () => { + const oldText = "hello world"; + const newText = "hello earth"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 6 }); + expect(change.range.end).toEqual({ line: 0, character: 11 }); + expect(change.text).toBe("earth"); + }); - it("handles multi-line changes with correct line positions", () => { - const oldText = "line1\nline2\nline3"; - const newText = "line1\nCHANGED\nline3"; - const change = computeChangeRange(oldText, newText); - // Common prefix: "line1\n" → start at beginning of line 1 - expect(change.range.start).toEqual({ line: 1, character: 0 }); - // Common suffix: "\nline3" → end after "line2" on line 1 - expect(change.range.end).toEqual({ line: 1, character: 5 }); - expect(change.text).toBe("CHANGED"); - }); + it("handles multi-line changes with correct line positions", () => { + const oldText = "line1\nline2\nline3"; + const newText = "line1\nCHANGED\nline3"; + const change = computeChangeRange(oldText, newText); + // Common prefix: "line1\n" → start at beginning of line 1 + expect(change.range.start).toEqual({ line: 1, character: 0 }); + // Common suffix: "\nline3" → end after "line2" on line 1 + expect(change.range.end).toEqual({ line: 1, character: 5 }); + expect(change.text).toBe("CHANGED"); + }); - it("handles insertion at end of file", () => { - const oldText = "abc"; - const newText = "abcdef"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 3 }); - expect(change.range.end).toEqual({ line: 0, character: 3 }); - expect(change.text).toBe("def"); - }); + it("handles insertion at end of file", () => { + const oldText = "abc"; + const newText = "abcdef"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 3 }); + expect(change.range.end).toEqual({ line: 0, character: 3 }); + expect(change.text).toBe("def"); + }); - it("handles complete file replacement (no common prefix/suffix)", () => { - const oldText = "abc"; - const newText = "xyz"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 0 }); - expect(change.range.end).toEqual({ line: 0, character: 3 }); - expect(change.text).toBe("xyz"); - }); + it("handles complete file replacement (no common prefix/suffix)", () => { + const oldText = "abc"; + const newText = "xyz"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 0 }); + expect(change.range.end).toEqual({ line: 0, character: 3 }); + expect(change.text).toBe("xyz"); + }); - it("handles empty old text (new file)", () => { - const oldText = ""; - const newText = "hello\nworld"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 0 }); - expect(change.range.end).toEqual({ line: 0, character: 0 }); - expect(change.text).toBe("hello\nworld"); - }); + it("handles empty old text (new file)", () => { + const oldText = ""; + const newText = "hello\nworld"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 0 }); + expect(change.range.end).toEqual({ line: 0, character: 0 }); + expect(change.text).toBe("hello\nworld"); + }); - it("handles identical text (no change)", () => { - const oldText = "same text"; - const newText = "same text"; - const change = computeChangeRange(oldText, newText); - expect(change.range.start).toEqual({ line: 0, character: 9 }); - expect(change.range.end).toEqual({ line: 0, character: 9 }); - expect(change.text).toBe(""); - }); + it("handles identical text (no change)", () => { + const oldText = "same text"; + const newText = "same text"; + const change = computeChangeRange(oldText, newText); + expect(change.range.start).toEqual({ line: 0, character: 9 }); + expect(change.range.end).toEqual({ line: 0, character: 9 }); + expect(change.text).toBe(""); + }); - it("handles change spanning multiple lines", () => { - const oldText = "function foo() {\n return 1;\n}\n"; - const newText = "function foo() {\n return 2;\n console.log('hi');\n}\n"; - const change = computeChangeRange(oldText, newText); - // Common prefix: "function foo() {\n return " (26 chars) - // The change starts at "1" on line 1, character 9 - expect(change.range.start).toEqual({ line: 1, character: 9 }); - // Common suffix: ";\n}\n" (4 chars) → end at offset 27 (the ";") - // offset 27 = line 1, char 10 (after " return 1") - expect(change.range.end).toEqual({ line: 1, character: 10 }); - expect(change.text).toBe("2;\n console.log('hi')"); - }); + it("handles change spanning multiple lines", () => { + const oldText = "function foo() {\n return 1;\n}\n"; + const newText = "function foo() {\n return 2;\n console.log('hi');\n}\n"; + const change = computeChangeRange(oldText, newText); + // Common prefix: "function foo() {\n return " (26 chars) + // The change starts at "1" on line 1, character 9 + expect(change.range.start).toEqual({ line: 1, character: 9 }); + // Common suffix: ";\n}\n" (4 chars) → end at offset 27 (the ";") + // offset 27 = line 1, char 10 (after " return 1") + expect(change.range.end).toEqual({ line: 1, character: 10 }); + expect(change.text).toBe("2;\n console.log('hi')"); + }); }); diff --git a/packages/lsp/src/diff.ts b/packages/lsp/src/diff.ts index ab40b36..1bf6048 100644 --- a/packages/lsp/src/diff.ts +++ b/packages/lsp/src/diff.ts @@ -7,16 +7,16 @@ */ export interface Position { - readonly line: number; // 0-based - readonly character: number; // 0-based + readonly line: number; // 0-based + readonly character: number; // 0-based } export interface TextDocumentContentChangeEvent { - readonly range: { - readonly start: Position; - readonly end: Position; - }; - readonly text: string; + readonly range: { + readonly start: Position; + readonly end: Position; + }; + readonly text: string; } /** @@ -29,40 +29,40 @@ export interface TextDocumentContentChangeEvent { * the portion of `newText` between the prefix and suffix. */ export function computeChangeRange( - oldText: string, - newText: string, + oldText: string, + newText: string, ): TextDocumentContentChangeEvent { - const minLen = Math.min(oldText.length, newText.length); + const minLen = Math.min(oldText.length, newText.length); - // Longest common prefix - let prefixLen = 0; - while (prefixLen < minLen && oldText[prefixLen] === newText[prefixLen]) { - prefixLen++; - } + // 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++; - } + // 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); + 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, - }; + return { + range: { + start: offsetToPosition(oldText, startOffset), + end: offsetToPosition(oldText, endOffset), + }, + text: replacementText, + }; } /** @@ -70,16 +70,16 @@ export function computeChangeRange( * (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 }; + 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 }; } diff --git a/packages/lsp/src/extension.ts b/packages/lsp/src/extension.ts index c0fee44..b4eb71b 100644 --- a/packages/lsp/src/extension.ts +++ b/packages/lsp/src/extension.ts @@ -13,156 +13,156 @@ import type { SpawnedProcess } from "./client.js"; import { LspManager } from "./manager.js"; import { createLspTool } from "./tool.js"; import type { - DiagnosticsResult, - GetDiagnosticsOpts, - LspServerStatus, - LspService, + DiagnosticsResult, + GetDiagnosticsOpts, + LspServerStatus, + LspService, } from "./types.js"; export const lspServiceHandle: ServiceHandle<LspService> = defineService<LspService>("lsp"); function realSpawn( - command: string[], - opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> | undefined }, + command: string[], + opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> | undefined }, ): SpawnedProcess { - const env: Record<string, string | undefined> = { ...process.env }; - if (opts.env) { - for (const [key, value] of Object.entries(opts.env)) { - env[key] = value; - } - } - const proc = Bun.spawn(command, { - cwd: opts.cwd, - env: env as Record<string, string>, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }); - return { - stdin: proc.stdin, - stdout: proc.stdout, - stderr: proc.stderr, - pid: proc.pid, - kill: () => proc.kill(), - // Surface process exit so the client can stop querying a dead server - // and self-heal (respawn). Bun's Subprocess.exited resolves with the - // exit code (or rejects if killed by signal — treat as code:null). - onExit: (handler) => { - (proc as { exited: Promise<number | null> }).exited - .then((code) => handler({ code })) - .catch(() => handler({ code: null })); - }, - }; + const env: Record<string, string | undefined> = { ...process.env }; + if (opts.env) { + for (const [key, value] of Object.entries(opts.env)) { + env[key] = value; + } + } + const proc = Bun.spawn(command, { + cwd: opts.cwd, + env: env as Record<string, string>, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + return { + stdin: proc.stdin, + stdout: proc.stdout, + stderr: proc.stderr, + pid: proc.pid, + kill: () => proc.kill(), + // Surface process exit so the client can stop querying a dead server + // and self-heal (respawn). Bun's Subprocess.exited resolves with the + // exit code (or rejects if killed by signal — treat as code:null). + onExit: (handler) => { + (proc as { exited: Promise<number | null> }).exited + .then((code) => handler({ code })) + .catch(() => handler({ code: null })); + }, + }; } function realFileWatcher( - root: string, - onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, + root: string, + onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, ): { readonly close: () => void } { - const { watch } = require("node:fs"); - const watcher = watch(root, { recursive: true }, (eventType: string, filename: string | null) => { - if (!filename) return; - const fullPath = root.endsWith("/") ? `${root}${filename}` : `${root}/${filename}`; - const type = eventType === "rename" ? "create" : "change"; - onEvent({ type, path: fullPath }); - }); - return { close: () => watcher.close() }; + const { watch } = require("node:fs"); + const watcher = watch(root, { recursive: true }, (eventType: string, filename: string | null) => { + if (!filename) return; + const fullPath = root.endsWith("/") ? `${root}${filename}` : `${root}/${filename}`; + const type = eventType === "rename" ? "create" : "change"; + onEvent({ type, path: fullPath }); + }); + return { close: () => watcher.close() }; } function realFs() { - return { - readText: async (path: string) => { - const file = Bun.file(path); - return file.text(); - }, - exists: async (path: string) => { - const file = Bun.file(path); - return file.exists(); - }, - }; + return { + readText: async (path: string) => { + const file = Bun.file(path); + return file.text(); + }, + exists: async (path: string) => { + const file = Bun.file(path); + return file.exists(); + }, + }; } export const extension: Extension = { - manifest: { - id: "lsp", - name: "Language Server Protocol", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { spawn: true, fs: true }, - contributes: { tools: ["lsp"], services: ["lsp"] }, - }, - activate(host: HostAPI) { - const logger = host.logger; - - const manager = new LspManager({ - spawn: realSpawn, - fileWatcher: realFileWatcher, - fs: realFs(), - logger: { - info: (msg, attrs) => - logger.info(msg, attrs as Record<string, string | number | boolean | null> | undefined), - warn: (msg, attrs) => - logger.warn(msg, attrs as Record<string, string | number | boolean | null> | undefined), - error: (msg, attrs) => logger.error(msg, attrs as Record<string, unknown> | undefined), - }, - }); - - const lspTool = createLspTool(manager); - host.defineTool(lspTool); - - const service: LspService = { - async status(cwd: string): Promise<readonly LspServerStatus[]> { - return manager.status(cwd); - }, - async getDiagnostics(opts: GetDiagnosticsOpts): Promise<DiagnosticsResult> { - // 10s hard ceiling per server, regardless of what the caller - // passes (the edit hook still passes 60_000 — clamped here, so - // no other-unit edit is needed). A server that doesn't respond - // in 10s is skipped with a notice instead of waited out. - const PER_SERVER_CAP_MS = 10_000; - const timeoutMs = Math.min(opts.timeoutMs ?? PER_SERVER_CAP_MS, PER_SERVER_CAP_MS); - const fileExt = extname(opts.filePath).toLowerCase(); - const absolutePath = opts.filePath.startsWith("/") - ? opts.filePath - : join(opts.cwd, opts.filePath); - - // Get all connected servers matching this file's extension. - // A dead/corrupted server has state:"error" and is excluded — - // no per-edit hang on a corpse. - const statuses = await manager.status(opts.cwd); - const matching = statuses.filter( - (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), - ); - - if (matching.length === 0) { - return { formatted: "", slow: false, timedOut: false }; - } - - const agg = await aggregateDiagnostics( - (id, root) => manager.getClient(id, root), - matching, - absolutePath, - timeoutMs, - { text: opts.text, minSeverity: opts.minSeverity }, - ); - - return { formatted: agg.formatted, slow: false, timedOut: agg.timedOut }; - }, - }; - host.provideService(lspServiceHandle, service); - - host.logger.info("LSP extension activated"); - - // Store manager for deactivate - (lspManagerStore as { manager: LspManager | null }).manager = manager; - }, - deactivate() { - const store = lspManagerStore as { manager: LspManager | null }; - store.manager?.shutdownAll(); - store.manager = null; - }, + manifest: { + id: "lsp", + name: "Language Server Protocol", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { spawn: true, fs: true }, + contributes: { tools: ["lsp"], services: ["lsp"] }, + }, + activate(host: HostAPI) { + const logger = host.logger; + + const manager = new LspManager({ + spawn: realSpawn, + fileWatcher: realFileWatcher, + fs: realFs(), + logger: { + info: (msg, attrs) => + logger.info(msg, attrs as Record<string, string | number | boolean | null> | undefined), + warn: (msg, attrs) => + logger.warn(msg, attrs as Record<string, string | number | boolean | null> | undefined), + error: (msg, attrs) => logger.error(msg, attrs as Record<string, unknown> | undefined), + }, + }); + + const lspTool = createLspTool(manager); + host.defineTool(lspTool); + + const service: LspService = { + async status(cwd: string): Promise<readonly LspServerStatus[]> { + return manager.status(cwd); + }, + async getDiagnostics(opts: GetDiagnosticsOpts): Promise<DiagnosticsResult> { + // 10s hard ceiling per server, regardless of what the caller + // passes (the edit hook still passes 60_000 — clamped here, so + // no other-unit edit is needed). A server that doesn't respond + // in 10s is skipped with a notice instead of waited out. + const PER_SERVER_CAP_MS = 10_000; + const timeoutMs = Math.min(opts.timeoutMs ?? PER_SERVER_CAP_MS, PER_SERVER_CAP_MS); + const fileExt = extname(opts.filePath).toLowerCase(); + const absolutePath = opts.filePath.startsWith("/") + ? opts.filePath + : join(opts.cwd, opts.filePath); + + // Get all connected servers matching this file's extension. + // A dead/corrupted server has state:"error" and is excluded — + // no per-edit hang on a corpse. + const statuses = await manager.status(opts.cwd); + const matching = statuses.filter( + (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), + ); + + if (matching.length === 0) { + return { formatted: "", slow: false, timedOut: false }; + } + + const agg = await aggregateDiagnostics( + (id, root) => manager.getClient(id, root), + matching, + absolutePath, + timeoutMs, + { text: opts.text, minSeverity: opts.minSeverity }, + ); + + return { formatted: agg.formatted, slow: false, timedOut: agg.timedOut }; + }, + }; + host.provideService(lspServiceHandle, service); + + host.logger.info("LSP extension activated"); + + // Store manager for deactivate + (lspManagerStore as { manager: LspManager | null }).manager = manager; + }, + deactivate() { + const store = lspManagerStore as { manager: LspManager | null }; + store.manager?.shutdownAll(); + store.manager = null; + }, }; // Module-scoped store for deactivate diff --git a/packages/lsp/src/framing.test.ts b/packages/lsp/src/framing.test.ts index 721665c..cea7b78 100644 --- a/packages/lsp/src/framing.test.ts +++ b/packages/lsp/src/framing.test.ts @@ -2,141 +2,141 @@ import { describe, expect, it } from "vitest"; import { encode, FrameDecoder } from "./framing.js"; describe("framing", () => { - it("encode/decode round-trips", () => { - const msg = JSON.stringify({ jsonrpc: "2.0", method: "test", params: { a: 1 } }); - const encoded = encode(msg); - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toHaveLength(1); - expect(messages[0]).toBe(msg); - }); - - it("decoder reassembles a frame split across two chunks", () => { - const msg = JSON.stringify({ jsonrpc: "2.0", method: "test" }); - const encoded = encode(msg); - const mid = Math.floor(encoded.length / 2); - const chunk1 = encoded.slice(0, mid); - const chunk2 = encoded.slice(mid); - - const decoder = new FrameDecoder(); - const result1 = decoder.decode(chunk1); - expect(result1).toHaveLength(0); - - const result2 = decoder.decode(chunk2); - expect(result2).toHaveLength(1); - expect(result2[0]).toBe(msg); - }); - - it("decoder yields two messages from one chunk", () => { - const msg1 = JSON.stringify({ jsonrpc: "2.0", method: "a" }); - const msg2 = JSON.stringify({ jsonrpc: "2.0", method: "b" }); - const encoded1 = encode(msg1); - const encoded2 = encode(msg2); - - const combined = new Uint8Array(encoded1.length + encoded2.length); - combined.set(encoded1); - combined.set(encoded2, encoded1.length); - - const decoder = new FrameDecoder(); - const messages = decoder.decode(combined); - expect(messages).toHaveLength(2); - expect(messages[0]).toBe(msg1); - expect(messages[1]).toBe(msg2); - }); + it("encode/decode round-trips", () => { + const msg = JSON.stringify({ jsonrpc: "2.0", method: "test", params: { a: 1 } }); + const encoded = encode(msg); + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + expect(messages).toHaveLength(1); + expect(messages[0]).toBe(msg); + }); + + it("decoder reassembles a frame split across two chunks", () => { + const msg = JSON.stringify({ jsonrpc: "2.0", method: "test" }); + const encoded = encode(msg); + const mid = Math.floor(encoded.length / 2); + const chunk1 = encoded.slice(0, mid); + const chunk2 = encoded.slice(mid); + + const decoder = new FrameDecoder(); + const result1 = decoder.decode(chunk1); + expect(result1).toHaveLength(0); + + const result2 = decoder.decode(chunk2); + expect(result2).toHaveLength(1); + expect(result2[0]).toBe(msg); + }); + + it("decoder yields two messages from one chunk", () => { + const msg1 = JSON.stringify({ jsonrpc: "2.0", method: "a" }); + const msg2 = JSON.stringify({ jsonrpc: "2.0", method: "b" }); + const encoded1 = encode(msg1); + const encoded2 = encode(msg2); + + const combined = new Uint8Array(encoded1.length + encoded2.length); + combined.set(encoded1); + combined.set(encoded2, encoded1.length); + + const decoder = new FrameDecoder(); + const messages = decoder.decode(combined); + expect(messages).toHaveLength(2); + expect(messages[0]).toBe(msg1); + expect(messages[1]).toBe(msg2); + }); }); describe("multi-byte UTF-8", () => { - it("round-trips a message with multi-byte characters", () => { - const msg = JSON.stringify({ - jsonrpc: "2.0", - method: "textDocument/publishDiagnostics", - params: { message: "Type '漢字' is not assignable to type 'number'. 🚫" }, - }); - const encoded = encode(msg); - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toHaveLength(1); - expect(messages[0]).toBe(msg); - }); - - it("reassembles a multi-byte message split at a character boundary", () => { - // A message whose JSON body contains 3-byte UTF-8 characters (漢字). - // We split the encoded frame so the boundary falls INSIDE a multi-byte - // sequence — the old string-based decoder would corrupt this. - const msg = JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: { text: "漢字テスト" }, - }); - const encoded = encode(msg); - - // Find a split point inside the body (skip the ASCII header). - const headerEnd = encoded.indexOf(0x0d, 0); // first \r - const bodyStart = headerEnd + 4; // skip \r\n\r\n - // Split in the middle of the body — likely inside a multi-byte char. - const splitPoint = bodyStart + Math.floor((encoded.length - bodyStart) / 2); - const chunk1 = encoded.slice(0, splitPoint); - const chunk2 = encoded.slice(splitPoint); - - const decoder = new FrameDecoder(); - expect(decoder.decode(chunk1)).toHaveLength(0); // incomplete - const result = decoder.decode(chunk2); - expect(result).toHaveLength(1); - expect(result[0]).toBe(msg); - }); - - it("handles Content-Length in bytes (not characters)", () => { - // Content-Length counts bytes. For multi-byte content, byte length - // > character length. The decoder must slice by bytes, not chars. - const unicode = "🎉分段測試"; - const msg = JSON.stringify({ jsonrpc: "2.0", method: "test", params: { text: unicode } }); - const encoded = encode(msg); - - // Verify the Content-Length header matches the byte length of the body. - const headerStr = new TextDecoder().decode(encoded.slice(0, encoded.indexOf(0x0d))); - const contentLengthMatch = /Content-Length:\s*(\d+)/i.exec(headerStr); - expect(contentLengthMatch).not.toBeNull(); - const declaredLength = Number.parseInt(contentLengthMatch?.[1], 10); - const bodyBytes = new TextEncoder().encode(msg); - expect(declaredLength).toBe(bodyBytes.length); - - const decoder = new FrameDecoder(); - const messages = decoder.decode(encoded); - expect(messages).toHaveLength(1); - expect(messages[0]).toBe(msg); - }); - - it("reassembles two multi-byte messages from one chunk", () => { - const msg1 = JSON.stringify({ jsonrpc: "2.0", method: "a", params: { t: "日本語" } }); - const msg2 = JSON.stringify({ jsonrpc: "2.0", method: "b", params: { t: "한국어" } }); - const encoded1 = encode(msg1); - const encoded2 = encode(msg2); - - const combined = new Uint8Array(encoded1.length + encoded2.length); - combined.set(encoded1); - combined.set(encoded2, encoded1.length); - - const decoder = new FrameDecoder(); - const messages = decoder.decode(combined); - expect(messages).toHaveLength(2); - expect(messages[0]).toBe(msg1); - expect(messages[1]).toBe(msg2); - }); - - it("reassembles a multi-byte message split across three chunks", () => { - const msg = JSON.stringify({ - jsonrpc: "2.0", - method: "test", - params: { text: "𝕳𝖊𝖑𝖑𝖔, 世界! Привет! 🌍" }, - }); - const encoded = encode(msg); - - const third = Math.floor(encoded.length / 3); - const decoder = new FrameDecoder(); - expect(decoder.decode(encoded.slice(0, third))).toHaveLength(0); - expect(decoder.decode(encoded.slice(third, third * 2))).toHaveLength(0); - const result = decoder.decode(encoded.slice(third * 2)); - expect(result).toHaveLength(1); - expect(result[0]).toBe(msg); - }); + it("round-trips a message with multi-byte characters", () => { + const msg = JSON.stringify({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { message: "Type '漢字' is not assignable to type 'number'. 🚫" }, + }); + const encoded = encode(msg); + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + expect(messages).toHaveLength(1); + expect(messages[0]).toBe(msg); + }); + + it("reassembles a multi-byte message split at a character boundary", () => { + // A message whose JSON body contains 3-byte UTF-8 characters (漢字). + // We split the encoded frame so the boundary falls INSIDE a multi-byte + // sequence — the old string-based decoder would corrupt this. + const msg = JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: { text: "漢字テスト" }, + }); + const encoded = encode(msg); + + // Find a split point inside the body (skip the ASCII header). + const headerEnd = encoded.indexOf(0x0d, 0); // first \r + const bodyStart = headerEnd + 4; // skip \r\n\r\n + // Split in the middle of the body — likely inside a multi-byte char. + const splitPoint = bodyStart + Math.floor((encoded.length - bodyStart) / 2); + const chunk1 = encoded.slice(0, splitPoint); + const chunk2 = encoded.slice(splitPoint); + + const decoder = new FrameDecoder(); + expect(decoder.decode(chunk1)).toHaveLength(0); // incomplete + const result = decoder.decode(chunk2); + expect(result).toHaveLength(1); + expect(result[0]).toBe(msg); + }); + + it("handles Content-Length in bytes (not characters)", () => { + // Content-Length counts bytes. For multi-byte content, byte length + // > character length. The decoder must slice by bytes, not chars. + const unicode = "🎉分段測試"; + const msg = JSON.stringify({ jsonrpc: "2.0", method: "test", params: { text: unicode } }); + const encoded = encode(msg); + + // Verify the Content-Length header matches the byte length of the body. + const headerStr = new TextDecoder().decode(encoded.slice(0, encoded.indexOf(0x0d))); + const contentLengthMatch = /Content-Length:\s*(\d+)/i.exec(headerStr); + expect(contentLengthMatch).not.toBeNull(); + const declaredLength = Number.parseInt(contentLengthMatch?.[1], 10); + const bodyBytes = new TextEncoder().encode(msg); + expect(declaredLength).toBe(bodyBytes.length); + + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + expect(messages).toHaveLength(1); + expect(messages[0]).toBe(msg); + }); + + it("reassembles two multi-byte messages from one chunk", () => { + const msg1 = JSON.stringify({ jsonrpc: "2.0", method: "a", params: { t: "日本語" } }); + const msg2 = JSON.stringify({ jsonrpc: "2.0", method: "b", params: { t: "한국어" } }); + const encoded1 = encode(msg1); + const encoded2 = encode(msg2); + + const combined = new Uint8Array(encoded1.length + encoded2.length); + combined.set(encoded1); + combined.set(encoded2, encoded1.length); + + const decoder = new FrameDecoder(); + const messages = decoder.decode(combined); + expect(messages).toHaveLength(2); + expect(messages[0]).toBe(msg1); + expect(messages[1]).toBe(msg2); + }); + + it("reassembles a multi-byte message split across three chunks", () => { + const msg = JSON.stringify({ + jsonrpc: "2.0", + method: "test", + params: { text: "𝕳𝖊𝖑𝖑𝖔, 世界! Привет! 🌍" }, + }); + const encoded = encode(msg); + + const third = Math.floor(encoded.length / 3); + const decoder = new FrameDecoder(); + expect(decoder.decode(encoded.slice(0, third))).toHaveLength(0); + expect(decoder.decode(encoded.slice(third, third * 2))).toHaveLength(0); + const result = decoder.decode(encoded.slice(third * 2)); + expect(result).toHaveLength(1); + expect(result[0]).toBe(msg); + }); }); diff --git a/packages/lsp/src/framing.ts b/packages/lsp/src/framing.ts index 88e60c1..b86fcbf 100644 --- a/packages/lsp/src/framing.ts +++ b/packages/lsp/src/framing.ts @@ -16,13 +16,13 @@ const LF = 0x0a; // \n const CONTENT_LENGTH_RE = /Content-Length:\s*(\d+)/i; export function encode(msg: string): Uint8Array { - const body = new TextEncoder().encode(msg); - const header = `Content-Length: ${body.length}\r\n\r\n`; - const frame = new TextEncoder().encode(header); - const result = new Uint8Array(frame.length + body.length); - result.set(frame); - result.set(body, frame.length); - return result; + const body = new TextEncoder().encode(msg); + const header = `Content-Length: ${body.length}\r\n\r\n`; + const frame = new TextEncoder().encode(header); + const result = new Uint8Array(frame.length + body.length); + result.set(frame); + result.set(body, frame.length); + return result; } /** @@ -30,65 +30,65 @@ export function encode(msg: string): Uint8Array { * starting at offset `from`. Returns the index of the first byte, or -1. */ function findHeaderSep(buf: Uint8Array, from: number): number { - const limit = buf.length - 3; - for (let i = from; i < limit; i++) { - if (buf[i] === CR && buf[i + 1] === LF && buf[i + 2] === CR && buf[i + 3] === LF) { - return i; - } - } - return -1; + const limit = buf.length - 3; + for (let i = from; i < limit; i++) { + if (buf[i] === CR && buf[i + 1] === LF && buf[i + 2] === CR && buf[i + 3] === LF) { + return i; + } + } + return -1; } export class FrameDecoder { - private buffer: Uint8Array = new Uint8Array(0); - private expectedLength: number | null = null; - private headerEndByte = -1; + private buffer: Uint8Array = new Uint8Array(0); + private expectedLength: number | null = null; + private headerEndByte = -1; - /** - * Feed raw bytes into the decoder. Returns all complete JSON messages - * that can be extracted from the accumulated buffer. - */ - decode(chunk: Uint8Array): string[] { - // Append the new chunk to the internal byte buffer. - const newBuf = new Uint8Array(this.buffer.length + chunk.length); - newBuf.set(this.buffer); - newBuf.set(chunk, this.buffer.length); - this.buffer = newBuf; + /** + * Feed raw bytes into the decoder. Returns all complete JSON messages + * that can be extracted from the accumulated buffer. + */ + decode(chunk: Uint8Array): string[] { + // Append the new chunk to the internal byte buffer. + const newBuf = new Uint8Array(this.buffer.length + chunk.length); + newBuf.set(this.buffer); + newBuf.set(chunk, this.buffer.length); + this.buffer = newBuf; - const messages: string[] = []; + const messages: string[] = []; - while (true) { - if (this.expectedLength === null) { - const sepIdx = findHeaderSep(this.buffer, 0); - if (sepIdx === -1) break; + while (true) { + if (this.expectedLength === null) { + const sepIdx = findHeaderSep(this.buffer, 0); + if (sepIdx === -1) break; - // Decode only the header bytes (always ASCII) to read Content-Length. - const headerStr = new TextDecoder().decode(this.buffer.slice(0, sepIdx)); - const match = CONTENT_LENGTH_RE.exec(headerStr); - if (!match?.[1]) { - // Not a Content-Length header — skip past this separator and retry. - this.buffer = this.buffer.slice(sepIdx + 4); - continue; - } - this.expectedLength = Number.parseInt(match[1], 10); - this.headerEndByte = sepIdx + 4; // skip \r\n\r\n - } + // Decode only the header bytes (always ASCII) to read Content-Length. + const headerStr = new TextDecoder().decode(this.buffer.slice(0, sepIdx)); + const match = CONTENT_LENGTH_RE.exec(headerStr); + if (!match?.[1]) { + // Not a Content-Length header — skip past this separator and retry. + this.buffer = this.buffer.slice(sepIdx + 4); + continue; + } + this.expectedLength = Number.parseInt(match[1], 10); + this.headerEndByte = sepIdx + 4; // skip \r\n\r\n + } - const bodyStart = this.headerEndByte; - const available = this.buffer.length - bodyStart; + const bodyStart = this.headerEndByte; + const available = this.buffer.length - bodyStart; - if (available >= this.expectedLength) { - // Extract exactly `expectedLength` bytes (Content-Length is in bytes). - const bodyBytes = this.buffer.slice(bodyStart, bodyStart + this.expectedLength); - messages.push(new TextDecoder().decode(bodyBytes)); - this.buffer = this.buffer.slice(bodyStart + this.expectedLength); - this.expectedLength = null; - this.headerEndByte = -1; - } else { - break; - } - } + if (available >= this.expectedLength) { + // Extract exactly `expectedLength` bytes (Content-Length is in bytes). + const bodyBytes = this.buffer.slice(bodyStart, bodyStart + this.expectedLength); + messages.push(new TextDecoder().decode(bodyBytes)); + this.buffer = this.buffer.slice(bodyStart + this.expectedLength); + this.expectedLength = null; + this.headerEndByte = -1; + } else { + break; + } + } - return messages; - } + return messages; + } } diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts index 49be7ae..750aa87 100644 --- a/packages/lsp/src/index.ts +++ b/packages/lsp/src/index.ts @@ -1,25 +1,25 @@ export type { - ClientDeps, - FileWatcher, - FileWatcherHandle, - FsAccess, - SpawnedProcess, - SpawnProcess, + ClientDeps, + FileWatcher, + FileWatcherHandle, + FsAccess, + SpawnedProcess, + SpawnProcess, } from "./client.js"; export { LanguageServerClient } from "./client.js"; export type { - ConfigSource, - LspJsonConfig, - OpencodeJsonConfig, - ResolvedServer, - ResolveResult, - ServerConfig, + ConfigSource, + LspJsonConfig, + OpencodeJsonConfig, + ResolvedServer, + ResolveResult, + ServerConfig, } from "./config.js"; export { configFingerprint, resolveServers } from "./config.js"; export type { - Diagnostic, - DocumentDiagnosticReport, - PublishDiagnosticsParams, + Diagnostic, + DocumentDiagnosticReport, + PublishDiagnosticsParams, } from "./diagnostics.js"; export { DiagnosticsStore } from "./diagnostics.js"; export { extension, lspServiceHandle } from "./extension.js"; diff --git a/packages/lsp/src/language.test.ts b/packages/lsp/src/language.test.ts index 3ec4b47..b464774 100644 --- a/packages/lsp/src/language.test.ts +++ b/packages/lsp/src/language.test.ts @@ -2,27 +2,27 @@ import { describe, expect, it } from "vitest"; import { languageId } from "./language.js"; describe("language", () => { - it("maps .ts to typescript", () => { - expect(languageId("file.ts")).toBe("typescript"); - }); + it("maps .ts to typescript", () => { + expect(languageId("file.ts")).toBe("typescript"); + }); - it("maps .tsx to typescriptreact", () => { - expect(languageId("file.tsx")).toBe("typescriptreact"); - }); + it("maps .tsx to typescriptreact", () => { + expect(languageId("file.tsx")).toBe("typescriptreact"); + }); - it("maps .js to javascript", () => { - expect(languageId("file.js")).toBe("javascript"); - }); + it("maps .js to javascript", () => { + expect(languageId("file.js")).toBe("javascript"); + }); - it("maps .luau to luau", () => { - expect(languageId("file.luau")).toBe("luau"); - }); + it("maps .luau to luau", () => { + expect(languageId("file.luau")).toBe("luau"); + }); - it("returns unknown for unrecognized extensions", () => { - expect(languageId("file.xyz")).toBe("unknown"); - }); + it("returns unknown for unrecognized extensions", () => { + expect(languageId("file.xyz")).toBe("unknown"); + }); - it("returns unknown for files without extensions", () => { - expect(languageId("Makefile")).toBe("unknown"); - }); + it("returns unknown for files without extensions", () => { + expect(languageId("Makefile")).toBe("unknown"); + }); }); diff --git a/packages/lsp/src/language.ts b/packages/lsp/src/language.ts index a10dbed..cd5b5fd 100644 --- a/packages/lsp/src/language.ts +++ b/packages/lsp/src/language.ts @@ -3,50 +3,50 @@ */ const extensionMap: Record<string, string> = { - ".ts": "typescript", - ".tsx": "typescriptreact", - ".mts": "typescript", - ".cts": "typescript", - ".js": "javascript", - ".jsx": "javascriptreact", - ".mjs": "javascript", - ".cjs": "javascript", - ".json": "json", - ".lua": "lua", - ".luau": "luau", - ".py": "python", - ".rs": "rust", - ".go": "go", - ".md": "markdown", - ".yaml": "yaml", - ".yml": "yaml", - ".toml": "toml", - ".css": "css", - ".html": "html", - ".sh": "shellscript", - ".bash": "shellscript", - ".zsh": "shellscript", - ".rb": "ruby", - ".rbs": "ruby", - ".c": "c", - ".h": "c", - ".cpp": "cpp", - ".cc": "cpp", - ".cxx": "cpp", - ".hpp": "cpp", - ".hxx": "cpp", - ".java": "java", - ".kt": "kotlin", - ".swift": "swift", - ".php": "php", - ".cs": "csharp", - ".sql": "sql", - ".dockerfile": "dockerfile", + ".ts": "typescript", + ".tsx": "typescriptreact", + ".mts": "typescript", + ".cts": "typescript", + ".js": "javascript", + ".jsx": "javascriptreact", + ".mjs": "javascript", + ".cjs": "javascript", + ".json": "json", + ".lua": "lua", + ".luau": "luau", + ".py": "python", + ".rs": "rust", + ".go": "go", + ".md": "markdown", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".css": "css", + ".html": "html", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".rb": "ruby", + ".rbs": "ruby", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".hpp": "cpp", + ".hxx": "cpp", + ".java": "java", + ".kt": "kotlin", + ".swift": "swift", + ".php": "php", + ".cs": "csharp", + ".sql": "sql", + ".dockerfile": "dockerfile", }; export function languageId(filePath: string): string { - const dotIdx = filePath.lastIndexOf("."); - if (dotIdx === -1) return "unknown"; - const ext = filePath.slice(dotIdx).toLowerCase(); - return extensionMap[ext] ?? "unknown"; + const dotIdx = filePath.lastIndexOf("."); + if (dotIdx === -1) return "unknown"; + const ext = filePath.slice(dotIdx).toLowerCase(); + return extensionMap[ext] ?? "unknown"; } diff --git a/packages/lsp/src/manager.test.ts b/packages/lsp/src/manager.test.ts index d8cba4f..613ded6 100644 --- a/packages/lsp/src/manager.test.ts +++ b/packages/lsp/src/manager.test.ts @@ -4,449 +4,449 @@ import { encode } from "./framing.js"; import { LspManager } from "./manager.js"; function makeAutoHandshakeSpawn(): SpawnProcess { - return () => { - let messageHandler: ((data: Uint8Array) => void) | null = null; - - const proc: SpawnedProcess = { - stdin: { - write: (bytes: Uint8Array) => { - // Parse the message to handle initialize handshake - const decoded = new TextDecoder().decode(bytes); - const headerEnd = decoded.indexOf("\r\n\r\n"); - if (headerEnd === -1) return; - const json = decoded.slice(headerEnd + 4); - try { - const msg = JSON.parse(json); - if (msg.method === "initialize") { - // Send back initialize response - setTimeout(() => { - const response = JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { capabilities: {} }, - }); - messageHandler?.(encode(response)); - }, 5); - } - // Ignore other messages - } catch { - // ignore parse errors - } - }, - }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - messageHandler = cb; - }, - }, - pid: 12345, - kill: () => {}, - }; - return proc; - }; + return () => { + let messageHandler: ((data: Uint8Array) => void) | null = null; + + const proc: SpawnedProcess = { + stdin: { + write: (bytes: Uint8Array) => { + // Parse the message to handle initialize handshake + const decoded = new TextDecoder().decode(bytes); + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const json = decoded.slice(headerEnd + 4); + try { + const msg = JSON.parse(json); + if (msg.method === "initialize") { + // Send back initialize response + setTimeout(() => { + const response = JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { capabilities: {} }, + }); + messageHandler?.(encode(response)); + }, 5); + } + // Ignore other messages + } catch { + // ignore parse errors + } + }, + }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + messageHandler = cb; + }, + }, + pid: 12345, + kill: () => {}, + }; + return proc; + }; } function noopFileWatcher(): FileWatcher { - return () => ({ close: () => {} }); + return () => ({ close: () => {} }); } function fakeFs(files: Record<string, string> = {}): FsAccess { - return { - readText: async (path) => files[path] ?? "", - exists: async (path) => path in files, - }; + return { + readText: async (path) => files[path] ?? "", + exists: async (path) => path in files, + }; } describe("manager", () => { - it("status(cwd) lazy-spawns matching servers and reports connected", async () => { - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp", "--stdio"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - const statuses = await manager.status("/project"); - expect(statuses).toHaveLength(1); - expect(statuses[0]?.id).toBe("test"); - expect(statuses[0]?.state).toBe("connected"); - expect(statuses[0]?.root).toBe("/project"); - }, 10000); - - it("concurrent status for the same root spawns one process", async () => { - let spawnCount = 0; - const countingSpawn: SpawnProcess = () => { - spawnCount++; - return makeAutoHandshakeSpawn()([], { cwd: "/project" }); - }; - - const manager = new LspManager({ - spawn: countingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - const [s1, s2] = await Promise.all([manager.status("/project"), manager.status("/project")]); - - expect(s1).toHaveLength(1); - expect(s2).toHaveLength(1); - expect(spawnCount).toBe(1); - }, 10000); - - it("a failing spawn reports state:error and is not retried", async () => { - let spawnCount = 0; - const failingSpawn: SpawnProcess = () => { - spawnCount++; - throw new Error("spawn failed"); - }; - - const manager = new LspManager({ - spawn: failingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - const s1 = await manager.status("/project"); - expect(s1[0]?.state).toBe("error"); - expect(spawnCount).toBe(1); - - // Second call should not retry - const s2 = await manager.status("/project"); - expect(s2[0]?.state).toBe("error"); - expect(spawnCount).toBe(1); - }); - - it("shutdownAll kills all spawned processes (incl. sidecars)", async () => { - let killed = false; - const trackableSpawn: SpawnProcess = () => { - const proc = makeAutoHandshakeSpawn()([], { cwd: "/project" }); - return { - ...proc, - kill: () => { - killed = true; - }, - }; - }; - - const manager = new LspManager({ - spawn: trackableSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["test-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }), - }); - - await manager.status("/project"); - manager.shutdownAll(); - expect(killed).toBe(true); - }, 10000); - - it("resolves config per cwd (distinct cwds, opencode.json fallback)", async () => { - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/proj-a/.dispatch/lsp.json": JSON.stringify({ - servers: { a: { command: ["a-lsp"], extensions: [".a"], rootMarkers: [] } }, - }), - "/proj-b/opencode.json": JSON.stringify({ - lsp: { b: { command: ["b-lsp"], extensions: [".b"] } }, - }), - }), - }); - - const a = await manager.status("/proj-a"); - const b = await manager.status("/proj-b"); - expect(a.map((s) => s.id)).toEqual(["a"]); - expect(b.map((s) => s.id)).toEqual(["b"]); - }, 10000); - - it("manager: broken server recovers after config is fixed (no shutdownAll)", async () => { - const files: Record<string, string> = { - "/project/tsconfig.json": "{}", - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - test: { - command: ["bad-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }), - }; - - const spawn: SpawnProcess = (command, opts) => { - if (command[0] === "bad-lsp") throw new Error("spawn failed"); - return makeAutoHandshakeSpawn()(command, opts); - }; - - const manager = new LspManager({ - spawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs(files), - now: () => 0, - }); - - // Bad config → spawn fails → broken. - const s1 = await manager.status("/project"); - expect(s1[0]?.state).toBe("error"); - - // Fix the config: switch the command to a working one. NO shutdownAll. - files["/project/.dispatch/lsp.json"] = JSON.stringify({ - servers: { - test: { - command: ["good-lsp"], - extensions: [".ts"], - rootMarkers: ["tsconfig.json"], - }, - }, - }); - - // Next status() re-reads config, detects the change, and re-spawns. - const s2 = await manager.status("/project"); - expect(s2[0]?.state).toBe("connected"); - }, 10000); - - it("manager: no retry storm — repeated status() with no config change does not re-spawn a broken server in a loop", async () => { - let spawnCount = 0; - const failingSpawn: SpawnProcess = () => { - spawnCount++; - throw new Error("spawn failed"); - }; - - const manager = new LspManager({ - spawn: failingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, - }), - }), - // Frozen clock: the bounded backoff never elapses, so a config-unchanged - // broken server is never retried in a loop. - now: () => 0, - }); - - await manager.status("/project"); - await manager.status("/project"); - await manager.status("/project"); - await manager.status("/project"); - - expect(spawnCount).toBe(1); - }); - - it("manager: configSource reaches status()", async () => { - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/d/.dispatch/lsp.json": JSON.stringify({ - servers: { d: { command: ["d-lsp"], extensions: [".d"], rootMarkers: [] } }, - }), - "/o/opencode.json": JSON.stringify({ - lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, - }), - "/b/tsconfig.json": "{}", - }), - now: () => 0, - }); - - const d = await manager.status("/d"); - expect(d[0]?.configSource).toBe(".dispatch/lsp.json"); - - const o = await manager.status("/o"); - expect(o[0]?.configSource).toBe("opencode.json"); - - const b = await manager.status("/b"); - expect(b[0]?.configSource).toBe("built-in"); - }, 10000); - - it("config: shadow warning logged when .dispatch/lsp.json present and opencode.json also has lsp", async () => { - const warns: Array<{ - msg: string; - attrs?: Record<string, string | number | boolean | null>; - }> = []; - - const manager = new LspManager({ - spawn: makeAutoHandshakeSpawn(), - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { d: { command: ["d-lsp"], extensions: [".d"] } }, - }), - "/project/opencode.json": JSON.stringify({ - lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, - }), - }), - logger: { - info: () => {}, - warn: (msg, attrs) => warns.push({ msg, attrs }), - error: () => {}, - }, - now: () => 0, - }); - - await manager.status("/project"); - - const shadow = warns.find((w) => w.msg.includes("shadowing")); - expect(shadow).toBeDefined(); - expect(shadow?.attrs?.cwd).toBe("/project"); - - // A second status() over the SAME (still-shadowed) cwd does not re-warn. - const before = warns.length; - await manager.status("/project"); - expect(warns.length).toBe(before); - }, 10000); - - it("status: error string includes config source on spawn failure", async () => { - const failingSpawn: SpawnProcess = () => { - throw new Error("spawn failed"); - }; - - const manager = new LspManager({ - spawn: failingSpawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, - }), - }), - now: () => 0, - }); - - const s = await manager.status("/project"); - expect(s[0]?.state).toBe("error"); - expect(s[0]?.configSource).toBe(".dispatch/lsp.json"); - expect(s[0]?.error).toContain("[from .dispatch/lsp.json]"); - expect(s[0]?.error).toContain("spawn failed"); - }); - - it("a client that dies after connecting is skipped + re-spawned after backoff (no storm, no eternal hang)", async () => { - // A spawn that completes the initialize handshake AND lets the test - // simulate process death via the captured onExit handler. - const exitHandlers: Array<(info: { code: number | null; signal?: string }) => void> = []; - let spawnCount = 0; - const spawn: SpawnProcess = () => { - spawnCount++; - let messageHandler: ((data: Uint8Array) => void) | null = null; - const proc: SpawnedProcess = { - stdin: { - write: (bytes: Uint8Array) => { - const decoded = new TextDecoder().decode(bytes); - const headerEnd = decoded.indexOf("\r\n\r\n"); - if (headerEnd === -1) return; - const json = decoded.slice(headerEnd + 4); - try { - const msg = JSON.parse(json); - if (msg.method === "initialize") { - setTimeout(() => { - const response = JSON.stringify({ - jsonrpc: "2.0", - id: msg.id, - result: { capabilities: {} }, - }); - messageHandler?.(encode(response)); - }, 1); - } - } catch { - // ignore - } - }, - }, - stdout: { - on: (_event: string, cb: (data: Uint8Array) => void) => { - messageHandler = cb; - }, - }, - pid: 1000 + spawnCount, - kill: () => {}, - onExit: (handler) => { - exitHandlers.push(handler); - }, - }; - return proc; - }; - - const clock = { now: 0 }; - const manager = new LspManager({ - spawn, - fileWatcher: noopFileWatcher(), - fs: fakeFs({ - "/project/.dispatch/lsp.json": JSON.stringify({ - servers: { - steep: { - command: ["steep", "--stdio"], - extensions: [".rb"], - rootMarkers: [], - }, - }, - }), - }), - now: () => clock.now, - }); - - // 1) Connects. - const s1 = await manager.status("/project"); - expect(s1[0]?.state).toBe("connected"); - expect(spawnCount).toBe(1); - - // 2) Simulate the process dying (user kill / crash) via onExit. - exitHandlers[0]?.({ code: 1 }); - const clientAfterDeath = manager.getClient("steep", "/project"); - expect(clientAfterDeath?.getState()).toBe("error"); - - // 3) status() now reports error (and seeds a broken entry for backoff). - // Backoff not elapsed yet (clock frozen at 0) → NOT re-spawned. - const s2 = await manager.status("/project"); - expect(s2[0]?.state).toBe("error"); - expect(s2[0]?.error).toMatch(/process exited/); - expect(spawnCount).toBe(1); // no retry storm before backoff - - // 4) After the backoff elapses, status() re-spawns a fresh server. - clock.now = 31_000; - const s3 = await manager.status("/project"); - expect(s3[0]?.state).toBe("connected"); - expect(spawnCount).toBe(2); // re-spawned exactly once - }); + it("status(cwd) lazy-spawns matching servers and reports connected", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp", "--stdio"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const statuses = await manager.status("/project"); + expect(statuses).toHaveLength(1); + expect(statuses[0]?.id).toBe("test"); + expect(statuses[0]?.state).toBe("connected"); + expect(statuses[0]?.root).toBe("/project"); + }, 10000); + + it("concurrent status for the same root spawns one process", async () => { + let spawnCount = 0; + const countingSpawn: SpawnProcess = () => { + spawnCount++; + return makeAutoHandshakeSpawn()([], { cwd: "/project" }); + }; + + const manager = new LspManager({ + spawn: countingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const [s1, s2] = await Promise.all([manager.status("/project"), manager.status("/project")]); + + expect(s1).toHaveLength(1); + expect(s2).toHaveLength(1); + expect(spawnCount).toBe(1); + }, 10000); + + it("a failing spawn reports state:error and is not retried", async () => { + let spawnCount = 0; + const failingSpawn: SpawnProcess = () => { + spawnCount++; + throw new Error("spawn failed"); + }; + + const manager = new LspManager({ + spawn: failingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const s1 = await manager.status("/project"); + expect(s1[0]?.state).toBe("error"); + expect(spawnCount).toBe(1); + + // Second call should not retry + const s2 = await manager.status("/project"); + expect(s2[0]?.state).toBe("error"); + expect(spawnCount).toBe(1); + }); + + it("shutdownAll kills all spawned processes (incl. sidecars)", async () => { + let killed = false; + const trackableSpawn: SpawnProcess = () => { + const proc = makeAutoHandshakeSpawn()([], { cwd: "/project" }); + return { + ...proc, + kill: () => { + killed = true; + }, + }; + }; + + const manager = new LspManager({ + spawn: trackableSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + await manager.status("/project"); + manager.shutdownAll(); + expect(killed).toBe(true); + }, 10000); + + it("resolves config per cwd (distinct cwds, opencode.json fallback)", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/proj-a/.dispatch/lsp.json": JSON.stringify({ + servers: { a: { command: ["a-lsp"], extensions: [".a"], rootMarkers: [] } }, + }), + "/proj-b/opencode.json": JSON.stringify({ + lsp: { b: { command: ["b-lsp"], extensions: [".b"] } }, + }), + }), + }); + + const a = await manager.status("/proj-a"); + const b = await manager.status("/proj-b"); + expect(a.map((s) => s.id)).toEqual(["a"]); + expect(b.map((s) => s.id)).toEqual(["b"]); + }, 10000); + + it("manager: broken server recovers after config is fixed (no shutdownAll)", async () => { + const files: Record<string, string> = { + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["bad-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }; + + const spawn: SpawnProcess = (command, opts) => { + if (command[0] === "bad-lsp") throw new Error("spawn failed"); + return makeAutoHandshakeSpawn()(command, opts); + }; + + const manager = new LspManager({ + spawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs(files), + now: () => 0, + }); + + // Bad config → spawn fails → broken. + const s1 = await manager.status("/project"); + expect(s1[0]?.state).toBe("error"); + + // Fix the config: switch the command to a working one. NO shutdownAll. + files["/project/.dispatch/lsp.json"] = JSON.stringify({ + servers: { + test: { + command: ["good-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }); + + // Next status() re-reads config, detects the change, and re-spawns. + const s2 = await manager.status("/project"); + expect(s2[0]?.state).toBe("connected"); + }, 10000); + + it("manager: no retry storm — repeated status() with no config change does not re-spawn a broken server in a loop", async () => { + let spawnCount = 0; + const failingSpawn: SpawnProcess = () => { + spawnCount++; + throw new Error("spawn failed"); + }; + + const manager = new LspManager({ + spawn: failingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, + }), + }), + // Frozen clock: the bounded backoff never elapses, so a config-unchanged + // broken server is never retried in a loop. + now: () => 0, + }); + + await manager.status("/project"); + await manager.status("/project"); + await manager.status("/project"); + await manager.status("/project"); + + expect(spawnCount).toBe(1); + }); + + it("manager: configSource reaches status()", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/d/.dispatch/lsp.json": JSON.stringify({ + servers: { d: { command: ["d-lsp"], extensions: [".d"], rootMarkers: [] } }, + }), + "/o/opencode.json": JSON.stringify({ + lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, + }), + "/b/tsconfig.json": "{}", + }), + now: () => 0, + }); + + const d = await manager.status("/d"); + expect(d[0]?.configSource).toBe(".dispatch/lsp.json"); + + const o = await manager.status("/o"); + expect(o[0]?.configSource).toBe("opencode.json"); + + const b = await manager.status("/b"); + expect(b[0]?.configSource).toBe("built-in"); + }, 10000); + + it("config: shadow warning logged when .dispatch/lsp.json present and opencode.json also has lsp", async () => { + const warns: Array<{ + msg: string; + attrs?: Record<string, string | number | boolean | null>; + }> = []; + + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { d: { command: ["d-lsp"], extensions: [".d"] } }, + }), + "/project/opencode.json": JSON.stringify({ + lsp: { o: { command: ["o-lsp"], extensions: [".o"] } }, + }), + }), + logger: { + info: () => {}, + warn: (msg, attrs) => warns.push({ msg, attrs }), + error: () => {}, + }, + now: () => 0, + }); + + await manager.status("/project"); + + const shadow = warns.find((w) => w.msg.includes("shadowing")); + expect(shadow).toBeDefined(); + expect(shadow?.attrs?.cwd).toBe("/project"); + + // A second status() over the SAME (still-shadowed) cwd does not re-warn. + const before = warns.length; + await manager.status("/project"); + expect(warns.length).toBe(before); + }, 10000); + + it("status: error string includes config source on spawn failure", async () => { + const failingSpawn: SpawnProcess = () => { + throw new Error("spawn failed"); + }; + + const manager = new LspManager({ + spawn: failingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { test: { command: ["test-lsp"], extensions: [".ts"] } }, + }), + }), + now: () => 0, + }); + + const s = await manager.status("/project"); + expect(s[0]?.state).toBe("error"); + expect(s[0]?.configSource).toBe(".dispatch/lsp.json"); + expect(s[0]?.error).toContain("[from .dispatch/lsp.json]"); + expect(s[0]?.error).toContain("spawn failed"); + }); + + it("a client that dies after connecting is skipped + re-spawned after backoff (no storm, no eternal hang)", async () => { + // A spawn that completes the initialize handshake AND lets the test + // simulate process death via the captured onExit handler. + const exitHandlers: Array<(info: { code: number | null; signal?: string }) => void> = []; + let spawnCount = 0; + const spawn: SpawnProcess = () => { + spawnCount++; + let messageHandler: ((data: Uint8Array) => void) | null = null; + const proc: SpawnedProcess = { + stdin: { + write: (bytes: Uint8Array) => { + const decoded = new TextDecoder().decode(bytes); + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const json = decoded.slice(headerEnd + 4); + try { + const msg = JSON.parse(json); + if (msg.method === "initialize") { + setTimeout(() => { + const response = JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { capabilities: {} }, + }); + messageHandler?.(encode(response)); + }, 1); + } + } catch { + // ignore + } + }, + }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + messageHandler = cb; + }, + }, + pid: 1000 + spawnCount, + kill: () => {}, + onExit: (handler) => { + exitHandlers.push(handler); + }, + }; + return proc; + }; + + const clock = { now: 0 }; + const manager = new LspManager({ + spawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + steep: { + command: ["steep", "--stdio"], + extensions: [".rb"], + rootMarkers: [], + }, + }, + }), + }), + now: () => clock.now, + }); + + // 1) Connects. + const s1 = await manager.status("/project"); + expect(s1[0]?.state).toBe("connected"); + expect(spawnCount).toBe(1); + + // 2) Simulate the process dying (user kill / crash) via onExit. + exitHandlers[0]?.({ code: 1 }); + const clientAfterDeath = manager.getClient("steep", "/project"); + expect(clientAfterDeath?.getState()).toBe("error"); + + // 3) status() now reports error (and seeds a broken entry for backoff). + // Backoff not elapsed yet (clock frozen at 0) → NOT re-spawned. + const s2 = await manager.status("/project"); + expect(s2[0]?.state).toBe("error"); + expect(s2[0]?.error).toMatch(/process exited/); + expect(spawnCount).toBe(1); // no retry storm before backoff + + // 4) After the backoff elapses, status() re-spawns a fresh server. + clock.now = 31_000; + const s3 = await manager.status("/project"); + expect(s3[0]?.state).toBe("connected"); + expect(spawnCount).toBe(2); // re-spawned exactly once + }); }); diff --git a/packages/lsp/src/manager.ts b/packages/lsp/src/manager.ts index bc84479..273011f 100644 --- a/packages/lsp/src/manager.ts +++ b/packages/lsp/src/manager.ts @@ -5,38 +5,38 @@ import { join } from "node:path"; import { - type ClientDeps, - type FileWatcher, - type FsAccess, - LanguageServerClient, - type SpawnProcess, + type ClientDeps, + type FileWatcher, + type FsAccess, + LanguageServerClient, + type SpawnProcess, } from "./client.js"; import { configFingerprint, type ResolvedServer, resolveServers } from "./config.js"; import { findRoot } from "./root.js"; import type { LspServerState, LspServerStatus } from "./types.js"; export type Logger = { - readonly info: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void; - readonly warn: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void; - readonly error: (msg: string, attrs?: Record<string, unknown>) => void; + readonly info: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void; + readonly warn: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void; + readonly error: (msg: string, attrs?: Record<string, unknown>) => void; }; export interface ManagerDeps { - readonly spawn: SpawnProcess; - readonly fileWatcher: FileWatcher; - readonly fs: FsAccess; - readonly logger?: Logger | undefined; - /** - * Injected clock (epoch-ms) for bounded-backoff bookkeeping. Defaults to - * `Date.now`; injected in tests so backoff is deterministic. - */ - readonly now?: (() => number) | undefined; + readonly spawn: SpawnProcess; + readonly fileWatcher: FileWatcher; + readonly fs: FsAccess; + readonly logger?: Logger | undefined; + /** + * Injected clock (epoch-ms) for bounded-backoff bookkeeping. Defaults to + * `Date.now`; injected in tests so backoff is deterministic. + */ + readonly now?: (() => number) | undefined; } type ClientEntry = { - readonly client: LanguageServerClient; - readonly server: ResolvedServer; - readonly promise: Promise<void>; + readonly client: LanguageServerClient; + readonly server: ResolvedServer; + readonly promise: Promise<void>; }; /** @@ -49,251 +49,251 @@ type ClientEntry = { * broken. */ type BrokenEntry = { - readonly configFingerprint: string; - readonly brokenAt: number; - readonly error: string; + readonly configFingerprint: string; + readonly brokenAt: number; + readonly error: string; }; /** Bounded backoff before a transient (config-unchanged) failure is retried. */ const BACKOFF_MS = 30_000; export class LspManager { - private clients = new Map<string, ClientEntry>(); - private broken = new Map<string, BrokenEntry>(); - private spawning = new Map<string, Promise<void>>(); - private shadowWarned = new Set<string>(); - private readonly deps: ManagerDeps; - private readonly now: () => number; + private clients = new Map<string, ClientEntry>(); + private broken = new Map<string, BrokenEntry>(); + private spawning = new Map<string, Promise<void>>(); + private shadowWarned = new Set<string>(); + private readonly deps: ManagerDeps; + private readonly now: () => number; - constructor(deps: ManagerDeps) { - this.deps = deps; - this.now = deps.now ?? Date.now; - } + constructor(deps: ManagerDeps) { + this.deps = deps; + this.now = deps.now ?? Date.now; + } - async status(cwd: string): Promise<readonly LspServerStatus[]> { - // Config is resolved PER cwd: a different conversation cwd (e.g. a Roblox - // project) gets its own .dispatch/lsp.json or opencode.json, not a global one. - const dispatchLspJson = await this.readOrNull(join(cwd, ".dispatch", "lsp.json")); - const opencodeJson = await this.readOrNull(join(cwd, "opencode.json")); - const { servers, shadowed } = await resolveServers({ - cwd, - dispatchLspJson, - opencodeJson, - exists: this.deps.fs.exists, - }); + async status(cwd: string): Promise<readonly LspServerStatus[]> { + // Config is resolved PER cwd: a different conversation cwd (e.g. a Roblox + // project) gets its own .dispatch/lsp.json or opencode.json, not a global one. + const dispatchLspJson = await this.readOrNull(join(cwd, ".dispatch", "lsp.json")); + const opencodeJson = await this.readOrNull(join(cwd, "opencode.json")); + const { servers, shadowed } = await resolveServers({ + cwd, + dispatchLspJson, + opencodeJson, + exists: this.deps.fs.exists, + }); - // A present `.dispatch/lsp.json` silently shadows `opencode.json`'s lsp - // key — warn once per cwd so a broken shadow names itself (an agent - // running inside dispatch can only see `status()`, not the journal). - if (shadowed && !this.shadowWarned.has(cwd)) { - this.shadowWarned.add(cwd); - this.deps.logger?.warn( - `.dispatch/lsp.json is shadowing the opencode.json "lsp" config — its servers take precedence and the opencode.json lsp entry is ignored`, - { cwd }, - ); - } + // A present `.dispatch/lsp.json` silently shadows `opencode.json`'s lsp + // key — warn once per cwd so a broken shadow names itself (an agent + // running inside dispatch can only see `status()`, not the journal). + if (shadowed && !this.shadowWarned.has(cwd)) { + this.shadowWarned.add(cwd); + this.deps.logger?.warn( + `.dispatch/lsp.json is shadowing the opencode.json "lsp" config — its servers take precedence and the opencode.json lsp entry is ignored`, + { cwd }, + ); + } - const results: LspServerStatus[] = []; + const results: LspServerStatus[] = []; - for (const server of servers) { - const root = await findRoot(cwd, cwd, server.rootMarkers, this.deps.fs.exists); - const key = `${server.id}:${root}`; + for (const server of servers) { + const root = await findRoot(cwd, cwd, server.rootMarkers, this.deps.fs.exists); + const key = `${server.id}:${root}`; - const brokenEntry = this.broken.get(key); - if (brokenEntry) { - // Recovery, storm-safe: a config change is a discrete event, so it - // cannot loop. Transient failures (config unchanged) are retried - // only after a bounded backoff — never in a tight loop. - const configChanged = configFingerprint(server) !== brokenEntry.configFingerprint; - const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS; - if (configChanged || backoffElapsed) { - this.broken.delete(key); - // Discard the stale client entry (and any leaked process) from - // the failed spawn so status() re-spawns fresh. - const stale = this.clients.get(key); - if (stale) { - this.clients.delete(key); - stale.client.shutdown(); - } - // fall through to (re)spawn - } else { - results.push({ - id: server.id, - name: server.name, - root, - extensions: server.extensions, - state: "error", - error: brokenEntry.error, - configSource: server.configSource, - }); - continue; - } - } + const brokenEntry = this.broken.get(key); + if (brokenEntry) { + // Recovery, storm-safe: a config change is a discrete event, so it + // cannot loop. Transient failures (config unchanged) are retried + // only after a bounded backoff — never in a tight loop. + const configChanged = configFingerprint(server) !== brokenEntry.configFingerprint; + const backoffElapsed = this.now() - brokenEntry.brokenAt >= BACKOFF_MS; + if (configChanged || backoffElapsed) { + this.broken.delete(key); + // Discard the stale client entry (and any leaked process) from + // the failed spawn so status() re-spawns fresh. + const stale = this.clients.get(key); + if (stale) { + this.clients.delete(key); + stale.client.shutdown(); + } + // fall through to (re)spawn + } else { + results.push({ + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: "error", + error: brokenEntry.error, + configSource: server.configSource, + }); + continue; + } + } - const existing = this.clients.get(key); - if (existing) { - const state = existing.client.getState(); - const stateError = existing.client.getStateError(); - // A client that died or corrupted AFTER connecting flipped its - // own state to "error" (client.ts handleExit/markBroken). Spawn - // succeeded so there's no broken entry yet — seed one so the - // bounded-backoff path above re-spawns it, instead of reporting - // error forever (and so getDiagnostics' "connected" filter skips - // it, avoiding a per-edit hang on the corpse). - if (state === "error" && !this.broken.has(key)) { - this.broken.set(key, { - configFingerprint: configFingerprint(server), - brokenAt: this.now(), - error: enrichError(server, stateError ?? "server unavailable"), - }); - } - const status: LspServerStatus = { - id: server.id, - name: server.name, - root, - extensions: server.extensions, - state: mapState(state), - configSource: server.configSource, - }; - if (stateError !== undefined) { - (status as { error?: string }).error = enrichError(server, stateError); - } - results.push(status); - continue; - } + const existing = this.clients.get(key); + if (existing) { + const state = existing.client.getState(); + const stateError = existing.client.getStateError(); + // A client that died or corrupted AFTER connecting flipped its + // own state to "error" (client.ts handleExit/markBroken). Spawn + // succeeded so there's no broken entry yet — seed one so the + // bounded-backoff path above re-spawns it, instead of reporting + // error forever (and so getDiagnostics' "connected" filter skips + // it, avoiding a per-edit hang on the corpse). + if (state === "error" && !this.broken.has(key)) { + this.broken.set(key, { + configFingerprint: configFingerprint(server), + brokenAt: this.now(), + error: enrichError(server, stateError ?? "server unavailable"), + }); + } + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: mapState(state), + configSource: server.configSource, + }; + if (stateError !== undefined) { + (status as { error?: string }).error = enrichError(server, stateError); + } + results.push(status); + continue; + } - try { - await this.spawnClient(server, root, key); - const entry = this.clients.get(key); - if (entry) { - const state = entry.client.getState(); - const stateError = entry.client.getStateError(); - const status: LspServerStatus = { - id: server.id, - name: server.name, - root, - extensions: server.extensions, - state: mapState(state), - configSource: server.configSource, - }; - if (stateError !== undefined) { - (status as { error?: string }).error = enrichError(server, stateError); - } - results.push(status); - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - this.broken.set(key, { - configFingerprint: configFingerprint(server), - brokenAt: this.now(), - error: enrichError(server, message), - }); - results.push({ - id: server.id, - name: server.name, - root, - extensions: server.extensions, - state: "error", - error: enrichError(server, message), - configSource: server.configSource, - }); - } - } + try { + await this.spawnClient(server, root, key); + const entry = this.clients.get(key); + if (entry) { + const state = entry.client.getState(); + const stateError = entry.client.getStateError(); + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: mapState(state), + configSource: server.configSource, + }; + if (stateError !== undefined) { + (status as { error?: string }).error = enrichError(server, stateError); + } + results.push(status); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.broken.set(key, { + configFingerprint: configFingerprint(server), + brokenAt: this.now(), + error: enrichError(server, message), + }); + results.push({ + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: "error", + error: enrichError(server, message), + configSource: server.configSource, + }); + } + } - return results; - } + return results; + } - getClient(serverId: string, root: string): LanguageServerClient | undefined { - const key = `${serverId}:${root}`; - return this.clients.get(key)?.client; - } + getClient(serverId: string, root: string): LanguageServerClient | undefined { + const key = `${serverId}:${root}`; + return this.clients.get(key)?.client; + } - /** Read a config file's contents, or null if it is absent/unreadable. */ - private async readOrNull(path: string): Promise<string | null> { - if (!(await this.deps.fs.exists(path))) return null; - try { - return await this.deps.fs.readText(path); - } catch { - return null; - } - } + /** Read a config file's contents, or null if it is absent/unreadable. */ + private async readOrNull(path: string): Promise<string | null> { + if (!(await this.deps.fs.exists(path))) return null; + try { + return await this.deps.fs.readText(path); + } catch { + return null; + } + } - private async spawnClient(server: ResolvedServer, root: string, key: string): Promise<void> { - const existingSpawn = this.spawning.get(key); - if (existingSpawn) return existingSpawn; + private async spawnClient(server: ResolvedServer, root: string, key: string): Promise<void> { + const existingSpawn = this.spawning.get(key); + if (existingSpawn) return existingSpawn; - const spawnPromise = this.doSpawn(server, root, key); - this.spawning.set(key, spawnPromise); + const spawnPromise = this.doSpawn(server, root, key); + this.spawning.set(key, spawnPromise); - try { - await spawnPromise; - } finally { - this.spawning.delete(key); - } - } + try { + await spawnPromise; + } finally { + this.spawning.delete(key); + } + } - private async doSpawn(server: ResolvedServer, root: string, key: string): Promise<void> { - const clientDeps: ClientDeps = { - spawn: this.deps.spawn, - fileWatcher: this.deps.fileWatcher, - fs: this.deps.fs, - command: server.command, - root, - serverId: server.id, - }; - if (server.env) { - (clientDeps as { env?: Readonly<Record<string, string>> }).env = server.env; - } - if (server.initialization) { - (clientDeps as { initialization?: Readonly<Record<string, unknown>> }).initialization = - server.initialization; - } + private async doSpawn(server: ResolvedServer, root: string, key: string): Promise<void> { + const clientDeps: ClientDeps = { + spawn: this.deps.spawn, + fileWatcher: this.deps.fileWatcher, + fs: this.deps.fs, + command: server.command, + root, + serverId: server.id, + }; + if (server.env) { + (clientDeps as { env?: Readonly<Record<string, string>> }).env = server.env; + } + if (server.initialization) { + (clientDeps as { initialization?: Readonly<Record<string, unknown>> }).initialization = + server.initialization; + } - const client = new LanguageServerClient(clientDeps); - const entry: ClientEntry = { - client, - server, - promise: client.start(), - }; + const client = new LanguageServerClient(clientDeps); + const entry: ClientEntry = { + client, + server, + promise: client.start(), + }; - this.clients.set(key, entry); - await entry.promise; + this.clients.set(key, entry); + await entry.promise; - if (client.getState() === "error") { - const message = client.getStateError() ?? "unknown"; - this.broken.set(key, { - configFingerprint: configFingerprint(server), - brokenAt: this.now(), - error: enrichError(server, message), - }); - this.deps.logger?.warn("LSP server failed to start", { - serverId: server.id, - root, - configSource: server.configSource ?? "unknown", - error: message, - }); - } else { - this.deps.logger?.info("LSP server connected", { - serverId: server.id, - root, - }); - } - } + if (client.getState() === "error") { + const message = client.getStateError() ?? "unknown"; + this.broken.set(key, { + configFingerprint: configFingerprint(server), + brokenAt: this.now(), + error: enrichError(server, message), + }); + this.deps.logger?.warn("LSP server failed to start", { + serverId: server.id, + root, + configSource: server.configSource ?? "unknown", + error: message, + }); + } else { + this.deps.logger?.info("LSP server connected", { + serverId: server.id, + root, + }); + } + } - shutdownAll(): void { - for (const [key, entry] of this.clients) { - entry.client.shutdown(); - this.deps.logger?.info("LSP server shutdown", { key }); - } - this.clients.clear(); - this.broken.clear(); - this.spawning.clear(); - this.shadowWarned.clear(); - } + shutdownAll(): void { + for (const [key, entry] of this.clients) { + entry.client.shutdown(); + this.deps.logger?.info("LSP server shutdown", { key }); + } + this.clients.clear(); + this.broken.clear(); + this.spawning.clear(); + this.shadowWarned.clear(); + } } function mapState(state: LspServerState): LspServerState { - return state; + return state; } /** @@ -302,6 +302,6 @@ function mapState(state: LspServerState): LspServerState { * `ruby-lsp [from .dispatch/lsp.json]: spawn failed`). */ function enrichError(server: ResolvedServer, message: string): string { - const source = server.configSource ?? "unknown"; - return `${server.id} [from ${source}]: ${message}`; + const source = server.configSource ?? "unknown"; + return `${server.id} [from ${source}]: ${message}`; } diff --git a/packages/lsp/src/root.test.ts b/packages/lsp/src/root.test.ts index ffe2e31..77d2881 100644 --- a/packages/lsp/src/root.test.ts +++ b/packages/lsp/src/root.test.ts @@ -2,50 +2,50 @@ import { describe, expect, it } from "vitest"; import { findRoot } from "./root.js"; describe("root", () => { - it("findRoot returns nearest marker ancestor bounded by cwd", async () => { - const existingFiles = new Set([ - "/project/src/components/tsconfig.json", - "/project/tsconfig.json", - ]); - - const result = await findRoot( - "/project/src/components/widgets", - "/project", - ["tsconfig.json"], - async (path) => existingFiles.has(path), - ); - - expect(result).toBe("/project/src/components"); - }); - - it("falls back to cwd", async () => { - const result = await findRoot( - "/project/src/deep/nested", - "/project", - ["tsconfig.json"], - async () => false, - ); - - expect(result).toBe("/project"); - }); - - it("finds marker at start directory", async () => { - const existingFiles = new Set(["/project/tsconfig.json"]); - - const result = await findRoot("/project", "/project", ["tsconfig.json"], async (path) => - existingFiles.has(path), - ); - - expect(result).toBe("/project"); - }); - - it("respects cwd boundary", async () => { - const existingFiles = new Set(["/tsconfig.json"]); - - const result = await findRoot("/project/src", "/project", ["tsconfig.json"], async (path) => - existingFiles.has(path), - ); - - expect(result).toBe("/project"); - }); + it("findRoot returns nearest marker ancestor bounded by cwd", async () => { + const existingFiles = new Set([ + "/project/src/components/tsconfig.json", + "/project/tsconfig.json", + ]); + + const result = await findRoot( + "/project/src/components/widgets", + "/project", + ["tsconfig.json"], + async (path) => existingFiles.has(path), + ); + + expect(result).toBe("/project/src/components"); + }); + + it("falls back to cwd", async () => { + const result = await findRoot( + "/project/src/deep/nested", + "/project", + ["tsconfig.json"], + async () => false, + ); + + expect(result).toBe("/project"); + }); + + it("finds marker at start directory", async () => { + const existingFiles = new Set(["/project/tsconfig.json"]); + + const result = await findRoot("/project", "/project", ["tsconfig.json"], async (path) => + existingFiles.has(path), + ); + + expect(result).toBe("/project"); + }); + + it("respects cwd boundary", async () => { + const existingFiles = new Set(["/tsconfig.json"]); + + const result = await findRoot("/project/src", "/project", ["tsconfig.json"], async (path) => + existingFiles.has(path), + ); + + expect(result).toBe("/project"); + }); }); diff --git a/packages/lsp/src/root.ts b/packages/lsp/src/root.ts index fc7814e..b0801fd 100644 --- a/packages/lsp/src/root.ts +++ b/packages/lsp/src/root.ts @@ -3,42 +3,42 @@ */ export async function findRoot( - startDir: string, - cwd: string, - markers: readonly string[], - exists: (path: string) => Promise<boolean>, + startDir: string, + cwd: string, + markers: readonly string[], + exists: (path: string) => Promise<boolean>, ): Promise<string> { - const normalizedStart = normalizePath(startDir); - const normalizedCwd = normalizePath(cwd); + const normalizedStart = normalizePath(startDir); + const normalizedCwd = normalizePath(cwd); - let current = normalizedStart; - while (true) { - for (const marker of markers) { - const markerPath = current === "/" ? `/${marker}` : `${current}/${marker}`; - if (await exists(markerPath)) { - return current; - } - } - if (current === normalizedCwd || current === "/") { - return normalizedCwd; - } - const parent = getParent(current); - if (parent === current) return normalizedCwd; - current = parent; - } + let current = normalizedStart; + while (true) { + for (const marker of markers) { + const markerPath = current === "/" ? `/${marker}` : `${current}/${marker}`; + if (await exists(markerPath)) { + return current; + } + } + if (current === normalizedCwd || current === "/") { + return normalizedCwd; + } + const parent = getParent(current); + if (parent === current) return normalizedCwd; + current = parent; + } } function normalizePath(p: string): string { - let normalized = p.replace(/\\/g, "/"); - if (normalized.length > 1 && normalized.endsWith("/")) { - normalized = normalized.slice(0, -1); - } - return normalized || "/"; + let normalized = p.replace(/\\/g, "/"); + if (normalized.length > 1 && normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + return normalized || "/"; } function getParent(p: string): string { - if (p === "/") return "/"; - const lastSlash = p.lastIndexOf("/"); - if (lastSlash <= 0) return "/"; - return p.slice(0, lastSlash) || "/"; + if (p === "/") return "/"; + const lastSlash = p.lastIndexOf("/"); + if (lastSlash <= 0) return "/"; + return p.slice(0, lastSlash) || "/"; } diff --git a/packages/lsp/src/rpc.test.ts b/packages/lsp/src/rpc.test.ts index 7b22ec5..5db5f97 100644 --- a/packages/lsp/src/rpc.test.ts +++ b/packages/lsp/src/rpc.test.ts @@ -2,111 +2,111 @@ import { describe, expect, it } from "vitest"; import { JsonRpcConnection } from "./rpc.js"; function makeConnection(): { conn: JsonRpcConnection; messages: string[] } { - const messages: string[] = []; - const conn = new JsonRpcConnection((bytes) => { - const decoded = new TextDecoder().decode(bytes); - // Extract JSON from the LSP-framed message - const headerEnd = decoded.indexOf("\r\n\r\n"); - if (headerEnd !== -1) { - messages.push(decoded.slice(headerEnd + 4)); - } - }); - return { conn, messages }; + const messages: string[] = []; + const conn = new JsonRpcConnection((bytes) => { + const decoded = new TextDecoder().decode(bytes); + // Extract JSON from the LSP-framed message + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd !== -1) { + messages.push(decoded.slice(headerEnd + 4)); + } + }); + return { conn, messages }; } function frameResponse(id: number, result: unknown): string { - return JSON.stringify({ jsonrpc: "2.0", id, result }); + return JSON.stringify({ jsonrpc: "2.0", id, result }); } describe("rpc", () => { - it("sendRequest resolves by matching id", async () => { - const { conn, messages } = makeConnection(); - - const promise = conn.sendRequest("test/method", { key: "value" }); - expect(messages).toHaveLength(1); - - const rawSent = messages[0]; - if (rawSent === undefined) throw new Error("expected a sent message"); - const sent = JSON.parse(rawSent); - expect(sent.method).toBe("test/method"); - expect(sent.params).toEqual({ key: "value" }); - expect(sent.id).toBe(1); - - conn.handleMessage(frameResponse(1, { ok: true })); - const result = await promise; - expect(result).toEqual({ ok: true }); - }); - - it("onNotification dispatches by method", () => { - const { conn } = makeConnection(); - let received: unknown = null; - conn.onNotification("test/notify", (params) => { - received = params; - }); - - conn.handleMessage( - JSON.stringify({ jsonrpc: "2.0", method: "test/notify", params: { data: 42 } }), - ); - expect(received).toEqual({ data: 42 }); - }); - - it("onRequest replies to a server-to-client request", async () => { - const { conn, messages } = makeConnection(); - - conn.onRequest("workspace/configuration", (params) => { - const { items } = params as { readonly items: readonly { readonly section?: string }[] }; - return items.map(() => ({ setting: true })); - }); - - await conn.handleMessage( - JSON.stringify({ - jsonrpc: "2.0", - id: 100, - method: "workspace/configuration", - params: { items: [{ section: "test" }] }, - }), - ); - - // The response should be sent back - expect(messages).toHaveLength(1); - const rawResponse = messages[0]; - if (rawResponse === undefined) throw new Error("expected a response message"); - const response = JSON.parse(rawResponse); - expect(response.id).toBe(100); - expect(response.result).toEqual([{ setting: true }]); - }); + it("sendRequest resolves by matching id", async () => { + const { conn, messages } = makeConnection(); + + const promise = conn.sendRequest("test/method", { key: "value" }); + expect(messages).toHaveLength(1); + + const rawSent = messages[0]; + if (rawSent === undefined) throw new Error("expected a sent message"); + const sent = JSON.parse(rawSent); + expect(sent.method).toBe("test/method"); + expect(sent.params).toEqual({ key: "value" }); + expect(sent.id).toBe(1); + + conn.handleMessage(frameResponse(1, { ok: true })); + const result = await promise; + expect(result).toEqual({ ok: true }); + }); + + it("onNotification dispatches by method", () => { + const { conn } = makeConnection(); + let received: unknown = null; + conn.onNotification("test/notify", (params) => { + received = params; + }); + + conn.handleMessage( + JSON.stringify({ jsonrpc: "2.0", method: "test/notify", params: { data: 42 } }), + ); + expect(received).toEqual({ data: 42 }); + }); + + it("onRequest replies to a server-to-client request", async () => { + const { conn, messages } = makeConnection(); + + conn.onRequest("workspace/configuration", (params) => { + const { items } = params as { readonly items: readonly { readonly section?: string }[] }; + return items.map(() => ({ setting: true })); + }); + + await conn.handleMessage( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "workspace/configuration", + params: { items: [{ section: "test" }] }, + }), + ); + + // The response should be sent back + expect(messages).toHaveLength(1); + const rawResponse = messages[0]; + if (rawResponse === undefined) throw new Error("expected a response message"); + const response = JSON.parse(rawResponse); + expect(response.id).toBe(100); + expect(response.result).toEqual([{ setting: true }]); + }); }); it("handleMessage does not throw on malformed JSON", async () => { - const { conn } = makeConnection(); - // A corrupted/truncated LSP message — must not throw or reject. - await expect(conn.handleMessage("{ broken json")).resolves.toBeUndefined(); - await expect(conn.handleMessage("")).resolves.toBeUndefined(); - await expect(conn.handleMessage("not json at all")).resolves.toBeUndefined(); + const { conn } = makeConnection(); + // A corrupted/truncated LSP message — must not throw or reject. + await expect(conn.handleMessage("{ broken json")).resolves.toBeUndefined(); + await expect(conn.handleMessage("")).resolves.toBeUndefined(); + await expect(conn.handleMessage("not json at all")).resolves.toBeUndefined(); }); describe("sendRequest timeout", () => { - it("rejects with a timeout error when no response arrives within timeoutMs", async () => { - const { conn } = makeConnection(); - const promise = conn.sendRequest("textDocument/hover", {}, 50); - await expect(promise).rejects.toThrow(/LSP request timed out after 50ms: textDocument\/hover/); - }); - - it("clears the timer on a normal response (no unhandled rejection)", async () => { - const { conn } = makeConnection(); - const promise = conn.sendRequest("textDocument/hover", {}, 5000); - conn.handleMessage(frameResponse(1, { ok: true })); - await expect(promise).resolves.toEqual({ ok: true }); - // Give the (now-cleared) timer window ample time to prove it never fires. - await new Promise((r) => setTimeout(r, 80)); - }); - - it("does not time out when no timeoutMs is given (initialize handshake path)", async () => { - const { conn } = makeConnection(); - const promise = conn.sendRequest("initialize", {}); - // A late response well past any plausible default still resolves. - await new Promise((r) => setTimeout(r, 60)); - conn.handleMessage(frameResponse(1, { capabilities: {} })); - await expect(promise).resolves.toEqual({ capabilities: {} }); - }); + it("rejects with a timeout error when no response arrives within timeoutMs", async () => { + const { conn } = makeConnection(); + const promise = conn.sendRequest("textDocument/hover", {}, 50); + await expect(promise).rejects.toThrow(/LSP request timed out after 50ms: textDocument\/hover/); + }); + + it("clears the timer on a normal response (no unhandled rejection)", async () => { + const { conn } = makeConnection(); + const promise = conn.sendRequest("textDocument/hover", {}, 5000); + conn.handleMessage(frameResponse(1, { ok: true })); + await expect(promise).resolves.toEqual({ ok: true }); + // Give the (now-cleared) timer window ample time to prove it never fires. + await new Promise((r) => setTimeout(r, 80)); + }); + + it("does not time out when no timeoutMs is given (initialize handshake path)", async () => { + const { conn } = makeConnection(); + const promise = conn.sendRequest("initialize", {}); + // A late response well past any plausible default still resolves. + await new Promise((r) => setTimeout(r, 60)); + conn.handleMessage(frameResponse(1, { capabilities: {} })); + await expect(promise).resolves.toEqual({ capabilities: {} }); + }); }); diff --git a/packages/lsp/src/rpc.ts b/packages/lsp/src/rpc.ts index 95157de..442176a 100644 --- a/packages/lsp/src/rpc.ts +++ b/packages/lsp/src/rpc.ts @@ -10,157 +10,157 @@ import { encode } from "./framing.js"; export type WriteFn = (bytes: Uint8Array) => void; export interface PendingRequest { - readonly resolve: (value: unknown) => void; - readonly reject: (reason: unknown) => void; + readonly resolve: (value: unknown) => void; + readonly reject: (reason: unknown) => void; } export type RequestHandler = (params: unknown) => unknown | Promise<unknown>; export type NotificationHandler = (params: unknown) => void; export interface JsonRpcMessage { - readonly jsonrpc: "2.0"; - readonly id?: number | string | undefined; - readonly method?: string | undefined; - readonly params?: unknown; - readonly result?: unknown; - readonly error?: - | { readonly code: number; readonly message: string; readonly data?: unknown } - | undefined; + readonly jsonrpc: "2.0"; + readonly id?: number | string | undefined; + readonly method?: string | undefined; + readonly params?: unknown; + readonly result?: unknown; + readonly error?: + | { readonly code: number; readonly message: string; readonly data?: unknown } + | undefined; } export class JsonRpcConnection { - private nextId = 1; - private pending = new Map<number | string, PendingRequest>(); - private requestHandlers = new Map<string, RequestHandler>(); - private notificationHandlers = new Map<string, NotificationHandler>(); - private write: WriteFn; - - constructor(write: WriteFn) { - this.write = write; - } - - /** - * Send a request and await the correlated response. If `timeoutMs` is given, - * the promise rejects with a timeout error after that long — so a dead/slow - * server can't hang the caller forever (hover/definition/references). - * No `timeoutMs` = wait indefinitely (used by the initialize handshake, which - * has its own 45s race). - */ - sendRequest(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> { - const id = this.nextId++; - const msg: JsonRpcMessage = { jsonrpc: "2.0", id, method, params }; - return new Promise((resolve, reject) => { - let timer: ReturnType<typeof setTimeout> | undefined; - // Wrap resolve/reject so the timer is cleared on a normal response - // (or on dispose) — no dangling timer after completion. - const finish = (fn: () => void): void => { - if (timer) clearTimeout(timer); - fn(); - }; - const entry: PendingRequest = { - resolve: (value: unknown) => finish(() => resolve(value)), - reject: (reason: unknown) => finish(() => reject(reason)), - }; - if (timeoutMs !== undefined) { - timer = setTimeout(() => { - if (this.pending.delete(id)) { - reject(new Error(`LSP request timed out after ${timeoutMs}ms: ${method}`)); - } - }, timeoutMs); - } - this.pending.set(id, entry); - this.sendMessage(msg); - }); - } - - sendNotification(method: string, params?: unknown): void { - const msg: JsonRpcMessage = { jsonrpc: "2.0", method, params }; - this.sendMessage(msg); - } - - onRequest(method: string, handler: RequestHandler): void { - this.requestHandlers.set(method, handler); - } - - onNotification(method: string, handler: NotificationHandler): void { - this.notificationHandlers.set(method, handler); - } - - async handleMessage(json: string): Promise<void> { - let msg: JsonRpcMessage; - try { - msg = JSON.parse(json) as JsonRpcMessage; - } catch { - // A malformed LSP message must never crash the server. The most - // common cause is a multi-byte UTF-8 character split across stdout - // chunks (see FrameDecoder). Log and skip — the language server - // will re-send diagnostics on the next file change. - return; - } - const { id, method } = msg; - - if (id !== undefined && method !== undefined) { - await this.handleIncomingRequest(id, method, msg.params); - } else if (method !== undefined) { - this.handleIncomingNotification(method, msg.params); - } else if (id !== undefined) { - this.handleResponse(id, msg); - } - } - - private sendMessage(msg: JsonRpcMessage): void { - this.write(encode(JSON.stringify(msg))); - } - - private handleResponse(id: number | string, msg: JsonRpcMessage): void { - const entry = this.pending.get(id); - if (!entry) return; - this.pending.delete(id); - if (msg.error) { - entry.reject(new Error(msg.error.message)); - } else { - entry.resolve(msg.result); - } - } - - private async handleIncomingRequest( - id: number | string, - method: string, - params: unknown, - ): Promise<void> { - const handler = this.requestHandlers.get(method); - if (!handler) { - this.sendMessage({ - jsonrpc: "2.0", - id, - error: { code: -32601, message: `Method not found: ${method}` }, - }); - return; - } - try { - const result = await handler(params); - this.sendMessage({ jsonrpc: "2.0", id, result }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - this.sendMessage({ - jsonrpc: "2.0", - id, - error: { code: -32603, message }, - }); - } - } - - private handleIncomingNotification(method: string, params: unknown): void { - const handler = this.notificationHandlers.get(method); - if (handler) { - handler(params); - } - } - - dispose(): void { - for (const entry of this.pending.values()) { - entry.reject(new Error("Connection closed")); - } - this.pending.clear(); - } + private nextId = 1; + private pending = new Map<number | string, PendingRequest>(); + private requestHandlers = new Map<string, RequestHandler>(); + private notificationHandlers = new Map<string, NotificationHandler>(); + private write: WriteFn; + + constructor(write: WriteFn) { + this.write = write; + } + + /** + * Send a request and await the correlated response. If `timeoutMs` is given, + * the promise rejects with a timeout error after that long — so a dead/slow + * server can't hang the caller forever (hover/definition/references). + * No `timeoutMs` = wait indefinitely (used by the initialize handshake, which + * has its own 45s race). + */ + sendRequest(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> { + const id = this.nextId++; + const msg: JsonRpcMessage = { jsonrpc: "2.0", id, method, params }; + return new Promise((resolve, reject) => { + let timer: ReturnType<typeof setTimeout> | undefined; + // Wrap resolve/reject so the timer is cleared on a normal response + // (or on dispose) — no dangling timer after completion. + const finish = (fn: () => void): void => { + if (timer) clearTimeout(timer); + fn(); + }; + const entry: PendingRequest = { + resolve: (value: unknown) => finish(() => resolve(value)), + reject: (reason: unknown) => finish(() => reject(reason)), + }; + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + if (this.pending.delete(id)) { + reject(new Error(`LSP request timed out after ${timeoutMs}ms: ${method}`)); + } + }, timeoutMs); + } + this.pending.set(id, entry); + this.sendMessage(msg); + }); + } + + sendNotification(method: string, params?: unknown): void { + const msg: JsonRpcMessage = { jsonrpc: "2.0", method, params }; + this.sendMessage(msg); + } + + onRequest(method: string, handler: RequestHandler): void { + this.requestHandlers.set(method, handler); + } + + onNotification(method: string, handler: NotificationHandler): void { + this.notificationHandlers.set(method, handler); + } + + async handleMessage(json: string): Promise<void> { + let msg: JsonRpcMessage; + try { + msg = JSON.parse(json) as JsonRpcMessage; + } catch { + // A malformed LSP message must never crash the server. The most + // common cause is a multi-byte UTF-8 character split across stdout + // chunks (see FrameDecoder). Log and skip — the language server + // will re-send diagnostics on the next file change. + return; + } + const { id, method } = msg; + + if (id !== undefined && method !== undefined) { + await this.handleIncomingRequest(id, method, msg.params); + } else if (method !== undefined) { + this.handleIncomingNotification(method, msg.params); + } else if (id !== undefined) { + this.handleResponse(id, msg); + } + } + + private sendMessage(msg: JsonRpcMessage): void { + this.write(encode(JSON.stringify(msg))); + } + + private handleResponse(id: number | string, msg: JsonRpcMessage): void { + const entry = this.pending.get(id); + if (!entry) return; + this.pending.delete(id); + if (msg.error) { + entry.reject(new Error(msg.error.message)); + } else { + entry.resolve(msg.result); + } + } + + private async handleIncomingRequest( + id: number | string, + method: string, + params: unknown, + ): Promise<void> { + const handler = this.requestHandlers.get(method); + if (!handler) { + this.sendMessage({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }); + return; + } + try { + const result = await handler(params); + this.sendMessage({ jsonrpc: "2.0", id, result }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.sendMessage({ + jsonrpc: "2.0", + id, + error: { code: -32603, message }, + }); + } + } + + private handleIncomingNotification(method: string, params: unknown): void { + const handler = this.notificationHandlers.get(method); + if (handler) { + handler(params); + } + } + + dispose(): void { + for (const entry of this.pending.values()) { + entry.reject(new Error("Connection closed")); + } + this.pending.clear(); + } } diff --git a/packages/lsp/src/tool.test.ts b/packages/lsp/src/tool.test.ts index efd1514..e4c72ed 100644 --- a/packages/lsp/src/tool.test.ts +++ b/packages/lsp/src/tool.test.ts @@ -3,274 +3,274 @@ import type { LspManager } from "./manager.js"; import { createLspTool } from "./tool.js"; function stubManager(overrides?: Partial<LspManager>): LspManager { - return { - status: async () => [], - getClient: () => undefined, - shutdownAll: () => {}, - ...overrides, - } as unknown as LspManager; + return { + status: async () => [], + getClient: () => undefined, + shutdownAll: () => {}, + ...overrides, + } as unknown as LspManager; } describe("tool", () => { - it("diagnostics formats errors", async () => { - const tool = createLspTool(stubManager()); - const result = await tool.execute( - { operation: "diagnostics", path: "test.ts" }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); - expect(result.isError).toBe(true); - expect(result.content).toContain("No language server configured"); - }); + it("diagnostics formats errors", async () => { + const tool = createLspTool(stubManager()); + const result = await tool.execute( + { operation: "diagnostics", path: "test.ts" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); + expect(result.isError).toBe(true); + expect(result.content).toContain("No language server configured"); + }); - it("position ops convert 1-based to 0-based", async () => { - let receivedPosition: { line: number; character: number } | null = null; + it("position ops convert 1-based to 0-based", async () => { + let receivedPosition: { line: number; character: number } | null = null; - const mockClient = { - getState: () => "connected" as const, - getStateError: () => undefined, - request: async (method: string, params: unknown) => { - if (method === "textDocument/hover") { - receivedPosition = (params as { position: { line: number; character: number } }).position; - return { contents: { value: "hover result" } }; - } - return null; - }, - waitForDiagnostics: async () => "", - }; + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async (method: string, params: unknown) => { + if (method === "textDocument/hover") { + receivedPosition = (params as { position: { line: number; character: number } }).position; + return { contents: { value: "hover result" } }; + } + return null; + }, + waitForDiagnostics: async () => "", + }; - const tool = createLspTool( - stubManager({ - status: async () => [ - { - id: "ts", - name: "TypeScript", - root: "/project", - extensions: [".ts"], - state: "connected", - }, - ], - getClient: () => mockClient as never, - }), - ); + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); - const result = await tool.execute( - { operation: "hover", path: "test.ts", line: 5, character: 10 }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); + const result = await tool.execute( + { operation: "hover", path: "test.ts", line: 5, character: 10 }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); - expect(receivedPosition).toEqual({ line: 4, character: 9 }); - expect(result.content).toBe("hover result"); - }); + expect(receivedPosition).toEqual({ line: 4, character: 9 }); + expect(result.content).toBe("hover result"); + }); - it("path resolved against ctx.cwd", async () => { - let receivedUri: string | null = null; + it("path resolved against ctx.cwd", async () => { + let receivedUri: string | null = null; - const mockClient = { - getState: () => "connected" as const, - getStateError: () => undefined, - request: async (method: string, params: unknown) => { - if (method === "textDocument/hover") { - receivedUri = (params as { textDocument: { uri: string } }).textDocument.uri; - return { contents: { value: "ok" } }; - } - return null; - }, - waitForDiagnostics: async () => "", - }; + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async (method: string, params: unknown) => { + if (method === "textDocument/hover") { + receivedUri = (params as { textDocument: { uri: string } }).textDocument.uri; + return { contents: { value: "ok" } }; + } + return null; + }, + waitForDiagnostics: async () => "", + }; - const tool = createLspTool( - stubManager({ - status: async () => [ - { - id: "ts", - name: "TypeScript", - root: "/project", - extensions: [".ts"], - state: "connected", - }, - ], - getClient: () => mockClient as never, - }), - ); + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); - await tool.execute( - { operation: "hover", path: "src/test.ts", line: 1, character: 1 }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); + await tool.execute( + { operation: "hover", path: "src/test.ts", line: 1, character: 1 }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); - expect(receivedUri).toBe("file:///project/src/test.ts"); - }); + expect(receivedUri).toBe("file:///project/src/test.ts"); + }); - it("position op without line/character returns isError", async () => { - const tool = createLspTool( - stubManager({ - status: async () => [ - { - id: "ts", - name: "TypeScript", - root: "/project", - extensions: [".ts"], - state: "connected", - }, - ], - }), - ); + it("position op without line/character returns isError", async () => { + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + }), + ); - const result = await tool.execute( - { operation: "hover", path: "test.ts" }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); + const result = await tool.execute( + { operation: "hover", path: "test.ts" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); - expect(result.isError).toBe(true); - expect(result.content).toContain("requires both"); - }); + expect(result.isError).toBe(true); + expect(result.content).toContain("requires both"); + }); - it("diagnostics op: a server that times out is skipped with a raise-to-user notice", async () => { - const mockClient = { - getState: () => "connected" as const, - getStateError: () => undefined, - request: async () => null, - waitForDiagnostics: async () => ({ formatted: "", slow: false, timedOut: true }), - }; + it("diagnostics op: a server that times out is skipped with a raise-to-user notice", async () => { + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async () => null, + waitForDiagnostics: async () => ({ formatted: "", slow: false, timedOut: true }), + }; - const tool = createLspTool( - stubManager({ - status: async () => [ - { - id: "steep", - name: "Steep", - root: "/project", - extensions: [".rb"], - state: "connected", - }, - ], - getClient: () => mockClient as never, - }), - ); + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "steep", + name: "Steep", + root: "/project", + extensions: [".rb"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); - const result = await tool.execute( - { operation: "diagnostics", path: "game.rb" }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); + const result = await tool.execute( + { operation: "diagnostics", path: "game.rb" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); - expect(result.isError).not.toBe(true); - expect(result.content).toContain("[Steep]"); - expect(result.content).toContain("took too long"); - expect(result.content).toContain(">10s"); - expect(result.content).toContain("raise this to the user"); - }); + expect(result.isError).not.toBe(true); + expect(result.content).toContain("[Steep]"); + expect(result.content).toContain("took too long"); + expect(result.content).toContain(">10s"); + expect(result.content).toContain("raise this to the user"); + }); - it("diagnostics op: responding servers' diagnostics are merged, tagged by source", async () => { - const mockClient = { - getState: () => "connected" as const, - getStateError: () => undefined, - request: async () => null, - waitForDiagnostics: async () => ({ - formatted: "ERROR L1:1: boom", - slow: false, - timedOut: false, - }), - }; + it("diagnostics op: responding servers' diagnostics are merged, tagged by source", async () => { + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async () => null, + waitForDiagnostics: async () => ({ + formatted: "ERROR L1:1: boom", + slow: false, + timedOut: false, + }), + }; - const tool = createLspTool( - stubManager({ - status: async () => [ - { - id: "steep", - name: "Steep", - root: "/project", - extensions: [".rb"], - state: "connected", - }, - ], - getClient: () => mockClient as never, - }), - ); + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "steep", + name: "Steep", + root: "/project", + extensions: [".rb"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); - const result = await tool.execute( - { operation: "diagnostics", path: "game.rb" }, - { - toolCallId: "test", - onOutput: () => {}, - signal: AbortSignal.timeout(5000), - log: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - child: () => ({}) as never, - span: () => ({}) as never, - }, - cwd: "/project", - }, - ); + const result = await tool.execute( + { operation: "diagnostics", path: "game.rb" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); - expect(result.content).toContain("[Steep]"); - expect(result.content).toContain("boom"); - }); + expect(result.content).toContain("[Steep]"); + expect(result.content).toContain("boom"); + }); }); diff --git a/packages/lsp/src/tool.ts b/packages/lsp/src/tool.ts index be0d269..bd10a82 100644 --- a/packages/lsp/src/tool.ts +++ b/packages/lsp/src/tool.ts @@ -14,250 +14,250 @@ type Operation = "diagnostics" | "hover" | "definition" | "references" | "docume const POSITION_OPS: ReadonlySet<string> = new Set(["hover", "definition", "references"]); interface ValidatedArgs { - readonly operation: Operation; - readonly path: string; - readonly line?: number | undefined; - readonly character?: number | undefined; + readonly operation: Operation; + readonly path: string; + readonly line?: number | undefined; + readonly character?: number | undefined; } function validateArgs(args: unknown): { readonly error: string } | ValidatedArgs { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object." }; - } - const obj = args as Record<string, unknown>; + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record<string, unknown>; - const rawOp = obj.operation; - if (typeof rawOp !== "string") { - return { error: 'Error: Missing "operation" parameter (must be a string).' }; - } - const validOps: ReadonlySet<string> = new Set([ - "diagnostics", - "hover", - "definition", - "references", - "documentSymbol", - ]); - if (!validOps.has(rawOp)) { - return { - error: `Error: Invalid operation "${rawOp}". Must be one of: diagnostics, hover, definition, references, documentSymbol.`, - }; - } - const operation = rawOp as Operation; + const rawOp = obj.operation; + if (typeof rawOp !== "string") { + return { error: 'Error: Missing "operation" parameter (must be a string).' }; + } + const validOps: ReadonlySet<string> = new Set([ + "diagnostics", + "hover", + "definition", + "references", + "documentSymbol", + ]); + if (!validOps.has(rawOp)) { + return { + error: `Error: Invalid operation "${rawOp}". Must be one of: diagnostics, hover, definition, references, documentSymbol.`, + }; + } + const operation = rawOp as Operation; - const rawPath = obj.path; - if (typeof rawPath !== "string" || rawPath.trim().length === 0) { - return { error: 'Error: Missing or empty "path" parameter (must be a non-empty string).' }; - } + const rawPath = obj.path; + if (typeof rawPath !== "string" || rawPath.trim().length === 0) { + return { error: 'Error: Missing or empty "path" parameter (must be a non-empty string).' }; + } - let line: number | undefined; - let character: number | undefined; + let line: number | undefined; + let character: number | undefined; - if (obj.line !== undefined) { - const n = Number(obj.line); - if (!Number.isFinite(n) || n < 1) { - return { error: 'Error: Invalid "line" parameter (must be a positive number, 1-based).' }; - } - line = Math.floor(n); - } + if (obj.line !== undefined) { + const n = Number(obj.line); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "line" parameter (must be a positive number, 1-based).' }; + } + line = Math.floor(n); + } - if (obj.character !== undefined) { - const n = Number(obj.character); - if (!Number.isFinite(n) || n < 1) { - return { - error: 'Error: Invalid "character" parameter (must be a positive number, 1-based).', - }; - } - character = Math.floor(n); - } + if (obj.character !== undefined) { + const n = Number(obj.character); + if (!Number.isFinite(n) || n < 1) { + return { + error: 'Error: Invalid "character" parameter (must be a positive number, 1-based).', + }; + } + character = Math.floor(n); + } - const result: ValidatedArgs = { operation, path: rawPath }; - if (line !== undefined) { - (result as { line?: number }).line = line; - } - if (character !== undefined) { - (result as { character?: number }).character = character; - } - return result; + const result: ValidatedArgs = { operation, path: rawPath }; + if (line !== undefined) { + (result as { line?: number }).line = line; + } + if (character !== undefined) { + (result as { character?: number }).character = character; + } + return result; } function resolveFilePath(filePath: string, cwd: string): string { - const resolved = resolve(cwd, filePath); - const normalizedCwd = resolve(cwd); - if (!resolved.startsWith(normalizedCwd)) { - return normalizedCwd; - } - return resolved; + const resolved = resolve(cwd, filePath); + const normalizedCwd = resolve(cwd); + if (!resolved.startsWith(normalizedCwd)) { + return normalizedCwd; + } + return resolved; } /** Convert validated 1-based line/character to an LSP 0-based position. */ function toPosition( - line: number | undefined, - character: number | undefined, + line: number | undefined, + character: number | undefined, ): { readonly line: number; readonly character: number } { - if (line === undefined || character === undefined) { - throw new Error("Position operations require both line and character."); - } - return { line: line - 1, character: character - 1 }; + if (line === undefined || character === undefined) { + throw new Error("Position operations require both line and character."); + } + return { line: line - 1, character: character - 1 }; } export function createLspTool(manager: LspManager): ToolContract { - return { - name: "lsp", - description: - "Query language servers for diagnostics, hover information, symbol definitions, references, and document symbols.", - parameters: { - type: "object", - properties: { - operation: { - type: "string", - enum: ["diagnostics", "hover", "definition", "references", "documentSymbol"], - description: "The LSP operation to perform.", - }, - path: { - type: "string", - description: "File path relative to the workspace.", - }, - line: { - type: "number", - description: "Line number (1-based). Required for hover, definition, references.", - }, - character: { - type: "number", - description: "Character position (1-based). Required for hover, definition, references.", - }, - }, - required: ["operation", "path"], - }, - concurrencySafe: true, - async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> { - const validated = validateArgs(args); - if ("error" in validated) { - return { content: validated.error, isError: true }; - } + return { + name: "lsp", + description: + "Query language servers for diagnostics, hover information, symbol definitions, references, and document symbols.", + parameters: { + type: "object", + properties: { + operation: { + type: "string", + enum: ["diagnostics", "hover", "definition", "references", "documentSymbol"], + description: "The LSP operation to perform.", + }, + path: { + type: "string", + description: "File path relative to the workspace.", + }, + line: { + type: "number", + description: "Line number (1-based). Required for hover, definition, references.", + }, + character: { + type: "number", + description: "Character position (1-based). Required for hover, definition, references.", + }, + }, + required: ["operation", "path"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } - const { operation, path: filePath, line, character } = validated; - const cwd = ctx.cwd ?? process.cwd(); - const absolutePath = resolveFilePath(filePath, cwd); + const { operation, path: filePath, line, character } = validated; + const cwd = ctx.cwd ?? process.cwd(); + const absolutePath = resolveFilePath(filePath, cwd); - if (POSITION_OPS.has(operation)) { - if (line === undefined || character === undefined) { - return { - content: `Error: "${operation}" requires both "line" and "character" parameters (1-based).`, - isError: true, - }; - } - } + if (POSITION_OPS.has(operation)) { + if (line === undefined || character === undefined) { + return { + content: `Error: "${operation}" requires both "line" and "character" parameters (1-based).`, + isError: true, + }; + } + } - try { - const statuses = await manager.status(cwd); - if (statuses.length === 0) { - return { content: "No language server configured for this workspace.", isError: true }; - } + try { + const statuses = await manager.status(cwd); + if (statuses.length === 0) { + return { content: "No language server configured for this workspace.", isError: true }; + } - const fileExt = extname(absolutePath).toLowerCase(); + const fileExt = extname(absolutePath).toLowerCase(); - switch (operation) { - case "diagnostics": { - // 10s hard ceiling per server (same policy as the edit path). - const DIAGNOSTICS_TIMEOUT_MS = 10_000; - // Query ALL connected servers whose extensions match this file. - const matching = statuses.filter( - (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), - ); + switch (operation) { + case "diagnostics": { + // 10s hard ceiling per server (same policy as the edit path). + const DIAGNOSTICS_TIMEOUT_MS = 10_000; + // Query ALL connected servers whose extensions match this file. + const matching = statuses.filter( + (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), + ); - if (matching.length === 0) { - // No matching server — fall back to any connected server. - const connected = statuses.find((s) => s.state === "connected"); - if (!connected) { - const first = statuses[0]; - const detail = first - ? `"${first.name}" is not connected (state: ${first.state})` - : "is not connected"; - return { - content: `Language server ${detail}.`, - isError: true, - }; - } - const client = manager.getClient(connected.id, connected.root); - if (!client) { - return { content: "Language server client not available.", isError: true }; - } - const result = await client.waitForDiagnostics(absolutePath, { - timeoutMs: DIAGNOSTICS_TIMEOUT_MS, - }); - if (result.timedOut) { - return { - content: `⚠️ [${connected.name}] LSP took too long (>10s), diagnostics skipped — please raise this to the user.`, - }; - } - return { content: result.formatted || "No diagnostics found." }; - } + if (matching.length === 0) { + // No matching server — fall back to any connected server. + const connected = statuses.find((s) => s.state === "connected"); + if (!connected) { + const first = statuses[0]; + const detail = first + ? `"${first.name}" is not connected (state: ${first.state})` + : "is not connected"; + return { + content: `Language server ${detail}.`, + isError: true, + }; + } + const client = manager.getClient(connected.id, connected.root); + if (!client) { + return { content: "Language server client not available.", isError: true }; + } + const result = await client.waitForDiagnostics(absolutePath, { + timeoutMs: DIAGNOSTICS_TIMEOUT_MS, + }); + if (result.timedOut) { + return { + content: `⚠️ [${connected.name}] LSP took too long (>10s), diagnostics skipped — please raise this to the user.`, + }; + } + return { content: result.formatted || "No diagnostics found." }; + } - // Query matching servers concurrently, each capped at 10s; - // a non-responding server is skipped with a notice. - const agg = await aggregateDiagnostics( - (id, root) => manager.getClient(id, root), - matching, - absolutePath, - DIAGNOSTICS_TIMEOUT_MS, - {}, - ); - return { content: agg.formatted || "No diagnostics found." }; - } - case "hover": { - const client = await getFirstMatchingClient(manager, statuses, fileExt); - if (!client) return { content: "No language server available.", isError: true }; - const result = await client.request("textDocument/hover", { - textDocument: { uri: `file://${absolutePath}` }, - position: toPosition(line, character), - }); - if (!result) return { content: "No hover information available." }; - const hover = result as { readonly contents?: { readonly value?: string } | string }; - const content = - typeof hover.contents === "string" - ? hover.contents - : (hover.contents?.value ?? "No hover information available."); - return { content }; - } - case "definition": { - const client = await getFirstMatchingClient(manager, statuses, fileExt); - if (!client) return { content: "No language server available.", isError: true }; - const result = await client.request("textDocument/definition", { - textDocument: { uri: `file://${absolutePath}` }, - position: toPosition(line, character), - }); - if (!result) return { content: "No definition found." }; - return { content: JSON.stringify(result) }; - } - case "references": { - const client = await getFirstMatchingClient(manager, statuses, fileExt); - if (!client) return { content: "No language server available.", isError: true }; - const result = await client.request("textDocument/references", { - textDocument: { uri: `file://${absolutePath}` }, - position: toPosition(line, character), - context: { includeDeclaration: true }, - }); - if (!result) return { content: "No references found." }; - return { content: JSON.stringify(result) }; - } - case "documentSymbol": { - const client = await getFirstMatchingClient(manager, statuses, fileExt); - if (!client) return { content: "No language server available.", isError: true }; - const result = await client.request("textDocument/documentSymbol", { - textDocument: { uri: `file://${absolutePath}` }, - }); - if (!result) return { content: "No symbols found." }; - return { content: JSON.stringify(result) }; - } - } - } catch (err: unknown) { - return { - content: `Error: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - }, - }; + // Query matching servers concurrently, each capped at 10s; + // a non-responding server is skipped with a notice. + const agg = await aggregateDiagnostics( + (id, root) => manager.getClient(id, root), + matching, + absolutePath, + DIAGNOSTICS_TIMEOUT_MS, + {}, + ); + return { content: agg.formatted || "No diagnostics found." }; + } + case "hover": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; + const result = await client.request("textDocument/hover", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + }); + if (!result) return { content: "No hover information available." }; + const hover = result as { readonly contents?: { readonly value?: string } | string }; + const content = + typeof hover.contents === "string" + ? hover.contents + : (hover.contents?.value ?? "No hover information available."); + return { content }; + } + case "definition": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; + const result = await client.request("textDocument/definition", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + }); + if (!result) return { content: "No definition found." }; + return { content: JSON.stringify(result) }; + } + case "references": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; + const result = await client.request("textDocument/references", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + context: { includeDeclaration: true }, + }); + if (!result) return { content: "No references found." }; + return { content: JSON.stringify(result) }; + } + case "documentSymbol": { + const client = await getFirstMatchingClient(manager, statuses, fileExt); + if (!client) return { content: "No language server available.", isError: true }; + const result = await client.request("textDocument/documentSymbol", { + textDocument: { uri: `file://${absolutePath}` }, + }); + if (!result) return { content: "No symbols found." }; + return { content: JSON.stringify(result) }; + } + } + } catch (err: unknown) { + return { + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + }, + }; } /** @@ -266,22 +266,22 @@ export function createLspTool(manager: LspManager): ToolContract { * Used by hover/definition/references/documentSymbol (single-server ops). */ async function getFirstMatchingClient( - manager: LspManager, - statuses: readonly { - readonly id: string; - readonly name: string; - readonly root: string; - readonly extensions: readonly string[]; - readonly state: string; - }[], - fileExt: string, + manager: LspManager, + statuses: readonly { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: string; + }[], + fileExt: string, ): Promise< - { readonly request: (method: string, params?: unknown) => Promise<unknown> } | undefined + { readonly request: (method: string, params?: unknown) => Promise<unknown> } | undefined > { - const matching = statuses.filter( - (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), - ); - const target = matching[0] ?? statuses.find((s) => s.state === "connected"); - if (!target) return undefined; - return manager.getClient(target.id, target.root); + const matching = statuses.filter( + (s) => s.state === "connected" && s.extensions.some((ext) => ext === fileExt), + ); + const target = matching[0] ?? statuses.find((s) => s.state === "connected"); + if (!target) return undefined; + return manager.getClient(target.id, target.root); } diff --git a/packages/lsp/src/types.ts b/packages/lsp/src/types.ts index 1f72bdf..40d2ced 100644 --- a/packages/lsp/src/types.ts +++ b/packages/lsp/src/types.ts @@ -5,53 +5,53 @@ export type LspServerState = "connected" | "starting" | "error" | "not-started"; export interface LspServerStatus { - readonly id: string; - readonly name: string; - readonly root: string; - readonly extensions: readonly string[]; - readonly state: LspServerState; - readonly error?: string | undefined; - /** - * Which config source this server was resolved from: `".dispatch/lsp.json"`, - * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). - * Mirrors the wire `LspServerInfo.configSource` so a broken config file - * names itself in the status response. - */ - readonly configSource?: string | undefined; + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: LspServerState; + readonly error?: string | undefined; + /** + * Which config source this server was resolved from: `".dispatch/lsp.json"`, + * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). + * Mirrors the wire `LspServerInfo.configSource` so a broken config file + * names itself in the status response. + */ + readonly configSource?: string | undefined; } export interface DiagnosticsResult { - /** Formatted diagnostic string (filtered by minSeverity). Empty if none. */ - readonly formatted: string; - /** True if diagnostics took >10s. */ - readonly slow: boolean; - /** True if the 60s timeout was hit before all servers responded. */ - readonly timedOut: boolean; + /** Formatted diagnostic string (filtered by minSeverity). Empty if none. */ + readonly formatted: string; + /** True if diagnostics took >10s. */ + readonly slow: boolean; + /** True if the 60s timeout was hit before all servers responded. */ + readonly timedOut: boolean; } export interface GetDiagnosticsOpts { - readonly filePath: string; - /** Post-edit buffer content. If omitted, the server reads from disk. */ - readonly text?: string; - readonly cwd: string; - readonly timeoutMs?: number; - /** Only include diagnostics with severity ≤ this (1=Error, 2=Warning). Omit for all. */ - readonly minSeverity?: number; + readonly filePath: string; + /** Post-edit buffer content. If omitted, the server reads from disk. */ + readonly text?: string; + readonly cwd: string; + readonly timeoutMs?: number; + /** Only include diagnostics with severity ≤ this (1=Error, 2=Warning). Omit for all. */ + readonly minSeverity?: number; } export interface LspService { - /** - * Resolve the language servers configured for `cwd`, ensure each is spawned + - * initialized (lazy connect), and report live state. Never throws for a single - * server's failure — reflect it as state:"error" with a short `error`. - */ - status(cwd: string): Promise<readonly LspServerStatus[]>; + /** + * Resolve the language servers configured for `cwd`, ensure each is spawned + + * initialized (lazy connect), and report live state. Never throws for a single + * server's failure — reflect it as state:"error" with a short `error`. + */ + status(cwd: string): Promise<readonly LspServerStatus[]>; - /** - * Query ALL connected language servers matching the file's extension for - * diagnostics. Merges results tagged by source server. Sends didOpen/didChange - * with the provided text (post-edit buffer) so the server checks the in-memory - * version, not stale disk content. - */ - getDiagnostics(opts: GetDiagnosticsOpts): Promise<DiagnosticsResult>; + /** + * Query ALL connected language servers matching the file's extension for + * diagnostics. Merges results tagged by source server. Sends didOpen/didChange + * with the provided text (post-edit buffer) so the server checks the in-memory + * version, not stale disk content. + */ + getDiagnostics(opts: GetDiagnosticsOpts): Promise<DiagnosticsResult>; } diff --git a/packages/lsp/src/watched-files.test.ts b/packages/lsp/src/watched-files.test.ts index 4e598e1..46298ad 100644 --- a/packages/lsp/src/watched-files.test.ts +++ b/packages/lsp/src/watched-files.test.ts @@ -2,80 +2,80 @@ import { describe, expect, it } from "vitest"; import { FileChangeType, globMatch, WatchedFilesRegistry } from "./watched-files.js"; describe("watched-files", () => { - it("register stores workspace/didChangeWatchedFiles watchers", () => { - const registry = new WatchedFilesRegistry(); + it("register stores workspace/didChangeWatchedFiles watchers", () => { + const registry = new WatchedFilesRegistry(); - registry.applyRegister({ - id: "reg-1", - method: "workspace/didChangeWatchedFiles", - registerOptions: { - watchers: [{ globPattern: "**/*.luau" }, { globPattern: "sourcemap.json" }], - }, - }); + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }, { globPattern: "sourcemap.json" }], + }, + }); - const watchers = registry.getAllWatchers(); - expect(watchers).toHaveLength(2); - expect(watchers[0]?.globPattern).toBe("**/*.luau"); - expect(watchers[1]?.globPattern).toBe("sourcemap.json"); - }); + const watchers = registry.getAllWatchers(); + expect(watchers).toHaveLength(2); + expect(watchers[0]?.globPattern).toBe("**/*.luau"); + expect(watchers[1]?.globPattern).toBe("sourcemap.json"); + }); - it("a changed path matching a registered glob is forwarded as a didChangeWatchedFiles notification with the correct FileChangeType", () => { - const registry = new WatchedFilesRegistry(); + it("a changed path matching a registered glob is forwarded as a didChangeWatchedFiles notification with the correct FileChangeType", () => { + const registry = new WatchedFilesRegistry(); - registry.applyRegister({ - id: "reg-1", - method: "workspace/didChangeWatchedFiles", - registerOptions: { - watchers: [{ globPattern: "**/*.luau" }], - }, - }); + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }); - expect(registry.matches("src/main.luau")).toBe(true); - expect(registry.matches("src/nested/deep/file.luau")).toBe(true); - expect(registry.matches("src/main.ts")).toBe(false); + expect(registry.matches("src/main.luau")).toBe(true); + expect(registry.matches("src/nested/deep/file.luau")).toBe(true); + expect(registry.matches("src/main.ts")).toBe(false); - expect(FileChangeType.Created).toBe(1); - expect(FileChangeType.Changed).toBe(2); - expect(FileChangeType.Deleted).toBe(3); - }); + expect(FileChangeType.Created).toBe(1); + expect(FileChangeType.Changed).toBe(2); + expect(FileChangeType.Deleted).toBe(3); + }); - it("unregisterCapability stops forwarding", () => { - const registry = new WatchedFilesRegistry(); + it("unregisterCapability stops forwarding", () => { + const registry = new WatchedFilesRegistry(); - registry.applyRegister({ - id: "reg-1", - method: "workspace/didChangeWatchedFiles", - registerOptions: { - watchers: [{ globPattern: "**/*.luau" }], - }, - }); + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }); - expect(registry.matches("src/main.luau")).toBe(true); + expect(registry.matches("src/main.luau")).toBe(true); - registry.applyUnregister({ - id: "reg-1", - method: "workspace/didChangeWatchedFiles", - }); + registry.applyUnregister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + }); - expect(registry.matches("src/main.luau")).toBe(false); - expect(registry.getAllWatchers()).toHaveLength(0); - }); + expect(registry.matches("src/main.luau")).toBe(false); + expect(registry.getAllWatchers()).toHaveLength(0); + }); - it("glob matching covers double-star-star.luau, sourcemap.json, double-star/sourcemap.json", () => { - // **/*.luau - expect(globMatch("**/*.luau", "src/main.luau")).toBe(true); - expect(globMatch("**/*.luau", "deep/nested/file.luau")).toBe(true); - expect(globMatch("**/*.luau", "file.luau")).toBe(true); - expect(globMatch("**/*.luau", "src/main.ts")).toBe(false); + it("glob matching covers double-star-star.luau, sourcemap.json, double-star/sourcemap.json", () => { + // **/*.luau + expect(globMatch("**/*.luau", "src/main.luau")).toBe(true); + expect(globMatch("**/*.luau", "deep/nested/file.luau")).toBe(true); + expect(globMatch("**/*.luau", "file.luau")).toBe(true); + expect(globMatch("**/*.luau", "src/main.ts")).toBe(false); - // sourcemap.json (literal) - expect(globMatch("sourcemap.json", "sourcemap.json")).toBe(true); - expect(globMatch("sourcemap.json", "other.json")).toBe(false); + // sourcemap.json (literal) + expect(globMatch("sourcemap.json", "sourcemap.json")).toBe(true); + expect(globMatch("sourcemap.json", "other.json")).toBe(false); - // **/sourcemap.json - expect(globMatch("**/sourcemap.json", "sourcemap.json")).toBe(true); - expect(globMatch("**/sourcemap.json", "build/sourcemap.json")).toBe(true); - expect(globMatch("**/sourcemap.json", "deep/nested/sourcemap.json")).toBe(true); - expect(globMatch("**/sourcemap.json", "other.json")).toBe(false); - }); + // **/sourcemap.json + expect(globMatch("**/sourcemap.json", "sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "build/sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "deep/nested/sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "other.json")).toBe(false); + }); }); diff --git a/packages/lsp/src/watched-files.ts b/packages/lsp/src/watched-files.ts index e23df89..db010b7 100644 --- a/packages/lsp/src/watched-files.ts +++ b/packages/lsp/src/watched-files.ts @@ -10,101 +10,101 @@ */ export interface FileSystemWatcher { - readonly globPattern: string; - readonly kind?: number; + readonly globPattern: string; + readonly kind?: number; } export interface DidChangeWatchedFilesRegistrationOptions { - readonly watchers: readonly FileSystemWatcher[]; + readonly watchers: readonly FileSystemWatcher[]; } export interface Registration { - readonly id: string; - readonly method: string; - readonly registerOptions?: DidChangeWatchedFilesRegistrationOptions; + readonly id: string; + readonly method: string; + readonly registerOptions?: DidChangeWatchedFilesRegistrationOptions; } export const FileChangeType = { - Created: 1, - Changed: 2, - Deleted: 3, + Created: 1, + Changed: 2, + Deleted: 3, } as const; export type FileChangeTypeValue = (typeof FileChangeType)[keyof typeof FileChangeType]; export class WatchedFilesRegistry { - private watchers = new Map<string, readonly FileSystemWatcher[]>(); + private watchers = new Map<string, readonly FileSystemWatcher[]>(); - applyRegister(registration: Registration): void { - if (registration.method !== "workspace/didChangeWatchedFiles") return; - const opts = registration.registerOptions; - if (!opts?.watchers) return; - this.watchers.set(registration.id, opts.watchers); - } + applyRegister(registration: Registration): void { + if (registration.method !== "workspace/didChangeWatchedFiles") return; + const opts = registration.registerOptions; + if (!opts?.watchers) return; + this.watchers.set(registration.id, opts.watchers); + } - applyUnregister(unregistration: { readonly id: string; readonly method: string }): void { - if (unregistration.method !== "workspace/didChangeWatchedFiles") return; - this.watchers.delete(unregistration.id); - } + applyUnregister(unregistration: { readonly id: string; readonly method: string }): void { + if (unregistration.method !== "workspace/didChangeWatchedFiles") return; + this.watchers.delete(unregistration.id); + } - matches(filePath: string): boolean { - for (const watchers of this.watchers.values()) { - for (const w of watchers) { - if (globMatch(w.globPattern, filePath)) return true; - } - } - return false; - } + matches(filePath: string): boolean { + for (const watchers of this.watchers.values()) { + for (const w of watchers) { + if (globMatch(w.globPattern, filePath)) return true; + } + } + return false; + } - getAllWatchers(): readonly FileSystemWatcher[] { - const result: FileSystemWatcher[] = []; - for (const watchers of this.watchers.values()) { - for (const w of watchers) { - result.push(w); - } - } - return result; - } + getAllWatchers(): readonly FileSystemWatcher[] { + const result: FileSystemWatcher[] = []; + for (const watchers of this.watchers.values()) { + for (const w of watchers) { + result.push(w); + } + } + return result; + } } export function globMatch(pattern: string, filePath: string): boolean { - const normalizedPath = filePath.replace(/^\/+/, "").replace(/\\/g, "/"); - const normalizedPattern = pattern.replace(/^\/+/, "").replace(/\\/g, "/"); - const regex = globToRegex(normalizedPattern); - return regex.test(normalizedPath); + const normalizedPath = filePath.replace(/^\/+/, "").replace(/\\/g, "/"); + const normalizedPattern = pattern.replace(/^\/+/, "").replace(/\\/g, "/"); + const regex = globToRegex(normalizedPattern); + return regex.test(normalizedPath); } function globToRegex(glob: string): RegExp { - let regex = "^"; - let i = 0; - while (i < glob.length) { - const ch = glob[i] ?? ""; - if (ch === "*" && glob[i + 1] === "*") { - if (glob[i + 2] === "/") { - regex += "(?:.+/)?"; - i += 3; - } else { - regex += ".*"; - i += 2; - } - } else if (ch === "*") { - regex += "[^/]*"; - i++; - } else if (ch === "?") { - regex += "[^/]"; - i++; - } else if (ch === ".") { - regex += "\\."; - i++; - } else { - regex += escapeRegex(ch); - i++; - } - } - regex += "$"; - return new RegExp(regex); + let regex = "^"; + let i = 0; + while (i < glob.length) { + const ch = glob[i] ?? ""; + if (ch === "*" && glob[i + 1] === "*") { + if (glob[i + 2] === "/") { + regex += "(?:.+/)?"; + i += 3; + } else { + regex += ".*"; + i += 2; + } + } else if (ch === "*") { + regex += "[^/]*"; + i++; + } else if (ch === "?") { + regex += "[^/]"; + i++; + } else if (ch === ".") { + regex += "\\."; + i++; + } else { + regex += escapeRegex(ch); + i++; + } + } + regex += "$"; + return new RegExp(regex); } function escapeRegex(ch: string): string { - return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } |
