summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests/lsp
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 17:52:14 +0900
committerAdam Malczewski <[email protected]>2026-06-02 17:52:14 +0900
commit062d01bd2f5c3ab6de7747dc5028e66b81dac6f5 (patch)
tree6097df0d53265f1a5e734aadab75c0334cb8e0e7 /packages/core/tests/lsp
parentb3aca3efe9e8cda79db6e2c7fa20482880ed16c3 (diff)
downloaddispatch-062d01bd2f5c3ab6de7747dc5028e66b81dac6f5.tar.gz
dispatch-062d01bd2f5c3ab6de7747dc5028e66b81dac6f5.zip
feat(lsp): add config-driven LSP support (Roblox Luau via luau-lsp)
Add Language Server Protocol integration modeled on opencode's, wired for this codebase's plain-TypeScript tool/agent architecture. Core (@dispatch/core): - lsp/client.ts: LSP/JSON-RPC client over stdio (vscode-jsonrpc) with the initialize handshake, didOpen/didChange sync, push + pull diagnostics (textDocument/diagnostic, workspace/diagnostic), and a generic request() passthrough for hover/definition/references/documentSymbol. - lsp/server.ts: resolves dispatch.toml [lsp] entries into spawn specs. Config-driven only — no builtin registry, no auto-download. - lsp/manager.ts: process-wide LspManager owning client lifecycles, keyed by root+serverID, lazy spawn + reuse + graceful shutdown. - lsp/language.ts: extension->languageId map incl. .luau -> "luau". - lsp/diagnostic.ts: error-only <diagnostics> block formatting (1-based). - tools/lsp.ts: on-demand 'lsp' tool (1-based coords -> 0-based wire). - write-file.ts: optional onAfterWrite hook for diagnostics-on-write. - config schema: validate [lsp] block; DispatchConfig.lsp + LspServerConfig. API (@dispatch/api): - AgentManager owns one LspManager; per-working-directory server cache cleared on config reload; diagnostics appended to write_file results; 'lsp' tool gated by new perm_lsp setting; shutdownAll on destroy(). Config: - dispatch.toml: documented, commented [lsp.luau-lsp] Roblox example. Tests: fake-lsp-server fixture + client/manager/server/diagnostic/schema/ tool/write-hook suites, plus an opt-in real-binary luau-lsp smoke test (auto-skipped when luau-lsp is absent). 652 pass; biome + 3 typechecks green.
Diffstat (limited to 'packages/core/tests/lsp')
-rw-r--r--packages/core/tests/lsp/client.test.ts146
-rw-r--r--packages/core/tests/lsp/diagnostic.test.ts67
-rw-r--r--packages/core/tests/lsp/luau-lsp.smoke.test.ts63
-rw-r--r--packages/core/tests/lsp/manager.test.ts120
-rw-r--r--packages/core/tests/lsp/server.test.ts41
5 files changed, 437 insertions, 0 deletions
diff --git a/packages/core/tests/lsp/client.test.ts b/packages/core/tests/lsp/client.test.ts
new file mode 100644
index 0000000..8daf8ab
--- /dev/null
+++ b/packages/core/tests/lsp/client.test.ts
@@ -0,0 +1,146 @@
+import { spawn } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { Diagnostic } from "vscode-languageserver-types";
+import { createLspClient, type LspServerHandle } from "../../src/lsp/client.js";
+
+const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js");
+
+function spawnFakeServer(): LspServerHandle {
+ const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" });
+ return { process: proc as LspServerHandle["process"] };
+}
+
+const ERROR_DIAG: Diagnostic = {
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
+ severity: 1,
+ message: "fake type error",
+ source: "Fake",
+};
+
+describe("lsp/client (fake server)", () => {
+ let workDir: string;
+
+ beforeEach(async () => {
+ workDir = await mkdtemp(join(tmpdir(), "dispatch-lsp-"));
+ });
+ afterEach(async () => {
+ await rm(workDir, { recursive: true, force: true });
+ });
+
+ it("completes the initialize handshake and forwards initializationOptions", async () => {
+ const handle = spawnFakeServer();
+ handle.initialization = { "luau-lsp": { platform: { type: "roblox" } } };
+ const client = await createLspClient({
+ serverID: "fake",
+ server: handle,
+ root: workDir,
+ directory: workDir,
+ });
+
+ const params = await client.connection.sendRequest<{ initializationOptions?: unknown }>(
+ "test/get-initialize-params",
+ {},
+ );
+ expect(params.initializationOptions).toEqual({
+ "luau-lsp": { platform: { type: "roblox" } },
+ });
+ await client.shutdown();
+ });
+
+ it("opens a file and receives push diagnostics", async () => {
+ const handle = spawnFakeServer();
+ const client = await createLspClient({
+ serverID: "fake",
+ server: handle,
+ root: workDir,
+ directory: workDir,
+ });
+
+ const file = join(workDir, "a.luau");
+ await writeFile(file, "local x = 1\n");
+ const version = await client.notifyOpen(file);
+ expect(version).toBe(0);
+
+ // Drive a push from the fake server, then assert it lands in the map.
+ await client.connection.sendRequest("test/publish-diagnostics", {
+ uri: pathToFileURL(file).href,
+ diagnostics: [ERROR_DIAG],
+ });
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(client.diagnostics.get(file)?.[0]?.message).toBe("fake type error");
+ await client.shutdown();
+ });
+
+ it("bumps the document version on re-open (didChange)", async () => {
+ const handle = spawnFakeServer();
+ const client = await createLspClient({
+ serverID: "fake",
+ server: handle,
+ root: workDir,
+ directory: workDir,
+ });
+ const file = join(workDir, "a.luau");
+ await writeFile(file, "local x = 1\n");
+ expect(await client.notifyOpen(file)).toBe(0);
+ await writeFile(file, "local x = 2\n");
+ expect(await client.notifyOpen(file)).toBe(1);
+
+ const lastChange = await client.connection.sendRequest<{ textDocument?: { version?: number } }>(
+ "test/get-last-change",
+ {},
+ );
+ expect(lastChange?.textDocument?.version).toBe(1);
+ await client.shutdown();
+ });
+
+ it("waits for pull diagnostics when the server advertises a diagnostic provider", async () => {
+ const handle = spawnFakeServer();
+ const client = await createLspClient({
+ serverID: "fake",
+ server: handle,
+ root: workDir,
+ directory: workDir,
+ });
+ // Tell the fake server (before initialize? no — it persists) to answer
+ // pull requests. We configure AFTER connect; the static provider flag is
+ // read at initialize, so this test exercises the dynamic registration
+ // path instead.
+ await client.connection.sendRequest("test/configure-pull-diagnostics", {
+ registerOn: "didOpen",
+ registrations: [{ id: "d1", registerOptions: { identifier: "fake" } }],
+ documentDiagnostics: [ERROR_DIAG],
+ });
+
+ const file = join(workDir, "a.luau");
+ await writeFile(file, "bad\n");
+ const version = await client.notifyOpen(file);
+ await client.waitForDiagnostics({ path: file, version, mode: "document" });
+
+ expect(client.diagnostics.get(file)?.some((d) => d.message === "fake type error")).toBe(true);
+ await client.shutdown();
+ });
+
+ it("request() passes through to the server (hover)", async () => {
+ const handle = spawnFakeServer();
+ const client = await createLspClient({
+ serverID: "fake",
+ server: handle,
+ root: workDir,
+ directory: workDir,
+ });
+ const file = join(workDir, "a.luau");
+ await writeFile(file, "local x = 1\n");
+ await client.notifyOpen(file);
+ const hover = await client.request<{ contents?: { value?: string } }>("textDocument/hover", {
+ textDocument: { uri: pathToFileURL(file).href },
+ position: { line: 0, character: 6 },
+ });
+ expect(hover?.contents?.value).toBe("fake hover");
+ await client.shutdown();
+ });
+});
diff --git a/packages/core/tests/lsp/diagnostic.test.ts b/packages/core/tests/lsp/diagnostic.test.ts
new file mode 100644
index 0000000..93ffde9
--- /dev/null
+++ b/packages/core/tests/lsp/diagnostic.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import type { Diagnostic } from "vscode-languageserver-types";
+import { pretty, report } from "../../src/lsp/diagnostic.js";
+
+function diag(partial: Partial<Diagnostic> & { message: string }): Diagnostic {
+ return {
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
+ severity: 1,
+ ...partial,
+ };
+}
+
+describe("lsp/diagnostic", () => {
+ describe("pretty", () => {
+ it("renders 1-based line/col with severity label", () => {
+ const out = pretty(
+ diag({
+ message: "Expected number",
+ range: { start: { line: 4, character: 2 }, end: { line: 4, character: 8 } },
+ }),
+ );
+ expect(out).toBe("ERROR [5:3] Expected number");
+ });
+
+ it("maps severities to labels", () => {
+ expect(pretty(diag({ message: "w", severity: 2 }))).toMatch(/^WARN /);
+ expect(pretty(diag({ message: "i", severity: 3 }))).toMatch(/^INFO /);
+ expect(pretty(diag({ message: "h", severity: 4 }))).toMatch(/^HINT /);
+ });
+
+ it("defaults missing severity to ERROR", () => {
+ expect(pretty(diag({ message: "x", severity: undefined }))).toMatch(/^ERROR /);
+ });
+ });
+
+ describe("report", () => {
+ it("returns empty string when there are no errors", () => {
+ expect(report("a.luau", [])).toBe("");
+ // Warnings only → still empty (errors-only).
+ expect(report("a.luau", [diag({ message: "w", severity: 2 })])).toBe("");
+ });
+
+ it("wraps errors in a <diagnostics file> block", () => {
+ const out = report("src/a.luau", [diag({ message: "boom" })]);
+ expect(out).toContain('<diagnostics file="src/a.luau">');
+ expect(out).toContain("ERROR [1:1] boom");
+ expect(out).toContain("</diagnostics>");
+ });
+
+ it("filters out non-error severities", () => {
+ const out = report("a.luau", [
+ diag({ message: "err" }),
+ diag({ message: "warn", severity: 2 }),
+ ]);
+ expect(out).toContain("err");
+ expect(out).not.toContain("warn");
+ });
+
+ it("caps at 20 and notes the remainder", () => {
+ const issues = Array.from({ length: 25 }, (_, i) => diag({ message: `e${i}` }));
+ const out = report("a.luau", issues);
+ expect(out).toContain("... and 5 more");
+ expect(out).toContain("e0");
+ expect(out).not.toContain("e24");
+ });
+ });
+});
diff --git a/packages/core/tests/lsp/luau-lsp.smoke.test.ts b/packages/core/tests/lsp/luau-lsp.smoke.test.ts
new file mode 100644
index 0000000..381435b
--- /dev/null
+++ b/packages/core/tests/lsp/luau-lsp.smoke.test.ts
@@ -0,0 +1,63 @@
+import { execSync } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { LspManager } from "../../src/lsp/manager.js";
+import { resolveServersFromConfig } from "../../src/lsp/server.js";
+
+/**
+ * Opt-in smoke test against the REAL luau-lsp binary. Skipped automatically
+ * (never fails CI) when `luau-lsp` is not on PATH — mirrors opencode's
+ * platform-guarded launch test. When the binary IS present, it proves the
+ * end-to-end path: spawn → initialize handshake → didOpen → real diagnostics.
+ */
+function hasLuauLsp(): boolean {
+ try {
+ execSync("luau-lsp --version", { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+const RUN = hasLuauLsp();
+
+describe.skipIf(!RUN)("luau-lsp real-binary smoke", () => {
+ let root: string;
+ let manager: LspManager;
+
+ beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), "dispatch-luau-smoke-"));
+ manager = new LspManager();
+ });
+ afterEach(async () => {
+ await manager.shutdownAll();
+ await rm(root, { recursive: true, force: true });
+ });
+
+ it("reports a real type error for a bad .luau file", async () => {
+ const servers = resolveServersFromConfig({
+ "luau-lsp": {
+ command: ["luau-lsp", "lsp"],
+ extensions: [".luau"],
+ initialization: {
+ "luau-lsp": {
+ platform: { type: "roblox" },
+ diagnostics: { strictDatamodelTypes: false },
+ },
+ },
+ },
+ });
+
+ const file = join(root, "bad.luau");
+ await writeFile(file, 'local x: number = "not a number"\nprint(x)\n');
+
+ await manager.touchFile({ file, root, servers, mode: "document" });
+ const diagnostics = manager.getDiagnostics({ root, servers, file });
+ const messages = (diagnostics[file] ?? []).map((d) => d.message).join("\n");
+
+ expect(messages.length).toBeGreaterThan(0);
+ expect(messages.toLowerCase()).toContain("number");
+ }, 60_000);
+});
diff --git a/packages/core/tests/lsp/manager.test.ts b/packages/core/tests/lsp/manager.test.ts
new file mode 100644
index 0000000..e720413
--- /dev/null
+++ b/packages/core/tests/lsp/manager.test.ts
@@ -0,0 +1,120 @@
+import { spawn } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { Diagnostic } from "vscode-languageserver-types";
+import { LspManager } from "../../src/lsp/manager.js";
+import type { ResolvedLspServer } from "../../src/lsp/server.js";
+
+const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js");
+
+function makeServer(id: string, extensions: string[]) {
+ const counter = { count: 0 };
+ const server: ResolvedLspServer = {
+ id,
+ extensions,
+ spawn() {
+ counter.count += 1;
+ const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" });
+ return { process: proc as never };
+ },
+ };
+ return { server, counter };
+}
+
+describe("lsp/manager (fake server)", () => {
+ let root: string;
+ let manager: LspManager;
+
+ beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), "dispatch-lspmgr-"));
+ manager = new LspManager();
+ });
+ afterEach(async () => {
+ await manager.shutdownAll();
+ await rm(root, { recursive: true, force: true });
+ });
+
+ it("hasServerForFile matches by extension", () => {
+ const { server } = makeServer("fake", [".luau"]);
+ expect(manager.hasServerForFile(join(root, "a.luau"), [server])).toBe(true);
+ expect(manager.hasServerForFile(join(root, "a.ts"), [server])).toBe(false);
+ });
+
+ it("spawns lazily and reuses the client across calls", async () => {
+ const { server, counter } = makeServer("fake", [".luau"]);
+ const file = join(root, "a.luau");
+ await writeFile(file, "local x = 1\n");
+
+ const c1 = await manager.getClients({ file, root, servers: [server] });
+ const c2 = await manager.getClients({ file, root, servers: [server] });
+ expect(c1).toHaveLength(1);
+ expect(c2).toHaveLength(1);
+ expect(c1[0]).toBe(c2[0]);
+ expect(counter.count).toBe(1);
+ });
+
+ it("does not spawn for a non-matching extension", async () => {
+ const { server, counter } = makeServer("fake", [".luau"]);
+ const file = join(root, "a.ts");
+ await writeFile(file, "const x = 1\n");
+ const clients = await manager.getClients({ file, root, servers: [server] });
+ expect(clients).toHaveLength(0);
+ expect(counter.count).toBe(0);
+ });
+
+ it("touchFile + getDiagnostics surfaces a pushed diagnostic", async () => {
+ const { server } = makeServer("fake", [".luau"]);
+ const file = join(root, "a.luau");
+ await writeFile(file, "bad code\n");
+
+ await manager.touchFile({ file, root, servers: [server] });
+ const [client] = await manager.getClients({ file, root, servers: [server] });
+ // Drive a push through the fake server.
+ const diag: Diagnostic = {
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } },
+ severity: 1,
+ message: "manager error",
+ };
+ await client.connection.sendRequest("test/publish-diagnostics", {
+ uri: pathToFileURL(file).href,
+ diagnostics: [diag],
+ });
+ await new Promise((r) => setTimeout(r, 50));
+
+ const result = manager.getDiagnostics({ root, servers: [server], file });
+ expect(result[file]?.[0]?.message).toBe("manager error");
+ });
+
+ it("request() forwards to clients and flattens results", async () => {
+ const { server } = makeServer("fake", [".luau"]);
+ const file = join(root, "a.luau");
+ await writeFile(file, "local x = 1\n");
+ await manager.touchFile({ file, root, servers: [server] });
+
+ const results = await manager.request({
+ file,
+ root,
+ servers: [server],
+ method: "textDocument/definition",
+ params: {
+ textDocument: { uri: pathToFileURL(file).href },
+ position: { line: 0, character: 6 },
+ },
+ });
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it("shutdownAll clears state so the next call respawns", async () => {
+ const { server, counter } = makeServer("fake", [".luau"]);
+ const file = join(root, "a.luau");
+ await writeFile(file, "local x = 1\n");
+ await manager.getClients({ file, root, servers: [server] });
+ expect(counter.count).toBe(1);
+ await manager.shutdownAll();
+ await manager.getClients({ file, root, servers: [server] });
+ expect(counter.count).toBe(2);
+ });
+});
diff --git a/packages/core/tests/lsp/server.test.ts b/packages/core/tests/lsp/server.test.ts
new file mode 100644
index 0000000..bdaf83d
--- /dev/null
+++ b/packages/core/tests/lsp/server.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { resolveServersFromConfig } from "../../src/lsp/server.js";
+
+describe("lsp/server resolveServersFromConfig", () => {
+ it("returns [] for undefined config", () => {
+ expect(resolveServersFromConfig(undefined)).toEqual([]);
+ });
+
+ it("resolves a server entry with id + extensions", () => {
+ const servers = resolveServersFromConfig({
+ "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"] },
+ });
+ expect(servers).toHaveLength(1);
+ expect(servers[0]?.id).toBe("luau-lsp");
+ expect(servers[0]?.extensions).toEqual([".luau"]);
+ expect(typeof servers[0]?.spawn).toBe("function");
+ });
+
+ it("skips disabled entries", () => {
+ const servers = resolveServersFromConfig({
+ "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"], disabled: true },
+ });
+ expect(servers).toEqual([]);
+ });
+
+ it("skips entries with empty command or extensions", () => {
+ const servers = resolveServersFromConfig({
+ noCommand: { command: [], extensions: [".luau"] },
+ noExt: { command: ["x"], extensions: [] },
+ });
+ expect(servers).toEqual([]);
+ });
+
+ it("resolves multiple servers", () => {
+ const servers = resolveServersFromConfig({
+ a: { command: ["a"], extensions: [".luau"] },
+ b: { command: ["b"], extensions: [".lua"] },
+ });
+ expect(servers.map((s) => s.id).sort()).toEqual(["a", "b"]);
+ });
+});