summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
commit61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch)
tree2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/lsp/src
parent63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff)
parent727c98c9dae516a2070eb950410314380a20c974 (diff)
downloaddispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz
dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/lsp/src')
-rw-r--r--packages/lsp/src/aggregate.test.ts222
-rw-r--r--packages/lsp/src/aggregate.ts86
-rw-r--r--packages/lsp/src/client.test.ts850
-rw-r--r--packages/lsp/src/client.ts1052
-rw-r--r--packages/lsp/src/config.test.ts394
-rw-r--r--packages/lsp/src/config.ts292
-rw-r--r--packages/lsp/src/diagnostics.test.ts82
-rw-r--r--packages/lsp/src/diagnostics.ts156
-rw-r--r--packages/lsp/src/diff.test.ts192
-rw-r--r--packages/lsp/src/diff.ts96
-rw-r--r--packages/lsp/src/extension.ts268
-rw-r--r--packages/lsp/src/framing.test.ts268
-rw-r--r--packages/lsp/src/framing.ts116
-rw-r--r--packages/lsp/src/index.ts30
-rw-r--r--packages/lsp/src/language.test.ts36
-rw-r--r--packages/lsp/src/language.ts86
-rw-r--r--packages/lsp/src/manager.test.ts872
-rw-r--r--packages/lsp/src/manager.ts478
-rw-r--r--packages/lsp/src/root.test.ts92
-rw-r--r--packages/lsp/src/root.ts60
-rw-r--r--packages/lsp/src/rpc.test.ts190
-rw-r--r--packages/lsp/src/rpc.ts288
-rw-r--r--packages/lsp/src/tool.test.ts492
-rw-r--r--packages/lsp/src/tool.ts466
-rw-r--r--packages/lsp/src/types.ts78
-rw-r--r--packages/lsp/src/watched-files.test.ts124
-rw-r--r--packages/lsp/src/watched-files.ts140
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, "\\$&");
}