diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 17:52:14 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 17:52:14 +0900 |
| commit | 062d01bd2f5c3ab6de7747dc5028e66b81dac6f5 (patch) | |
| tree | 6097df0d53265f1a5e734aadab75c0334cb8e0e7 /packages/core/tests/lsp | |
| parent | b3aca3efe9e8cda79db6e2c7fa20482880ed16c3 (diff) | |
| download | dispatch-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.ts | 146 | ||||
| -rw-r--r-- | packages/core/tests/lsp/diagnostic.test.ts | 67 | ||||
| -rw-r--r-- | packages/core/tests/lsp/luau-lsp.smoke.test.ts | 63 | ||||
| -rw-r--r-- | packages/core/tests/lsp/manager.test.ts | 120 | ||||
| -rw-r--r-- | packages/core/tests/lsp/server.test.ts | 41 |
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"]); + }); +}); |
