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/fixture | |
| 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/fixture')
| -rw-r--r-- | packages/core/tests/fixture/lsp/fake-lsp-server.js | 195 |
1 files changed, 195 insertions, 0 deletions
diff --git a/packages/core/tests/fixture/lsp/fake-lsp-server.js b/packages/core/tests/fixture/lsp/fake-lsp-server.js new file mode 100644 index 0000000..d771ebd --- /dev/null +++ b/packages/core/tests/fixture/lsp/fake-lsp-server.js @@ -0,0 +1,195 @@ +// Minimal JSON-RPC 2.0 LSP-like fake server over stdio, for testing the LSP +// client without a real language server binary. Ported from opencode's +// test/fixture/lsp/fake-lsp-server.js (trimmed to what dispatch's client and +// manager exercise: initialize, didOpen/didChange, push + pull diagnostics). +// +// Test hooks (custom JSON-RPC methods the test driver can call): +// test/get-initialize-params → returns the params sent to `initialize` +// test/get-last-change → returns the last `didChange` params +// test/publish-diagnostics → forwards a `publishDiagnostics` push +// test/configure-pull-diagnostics → sets up pull-diagnostic responses +// test/get-diagnostic-request-count→ how many pull requests were received + +let nextId = 1; +let readBuffer = Buffer.alloc(0); +let lastChange = null; +let initializeParams = null; +let diagnosticRequestCount = 0; +let registeredCapability = false; +let pullConfig = { + registerOn: undefined, + registrations: [], + documentDiagnostics: [], + workspaceDiagnostics: [], + hasDiagnosticProvider: false, +}; + +function encode(message) { + const json = JSON.stringify(message); + const header = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n`; + return Buffer.concat([Buffer.from(header, "utf8"), Buffer.from(json, "utf8")]); +} + +function decodeFrames(buffer) { + const results = []; + while (true) { + const idx = buffer.indexOf("\r\n\r\n"); + if (idx === -1) break; + const header = buffer.slice(0, idx).toString("utf8"); + const match = /Content-Length:\s*(\d+)/i.exec(header); + const length = match ? parseInt(match[1], 10) : 0; + const bodyStart = idx + 4; + const bodyEnd = bodyStart + length; + if (buffer.length < bodyEnd) break; + results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8")); + buffer = buffer.slice(bodyEnd); + } + return { messages: results, rest: buffer }; +} + +function send(message) { + process.stdout.write(encode(message)); +} +function sendRequest(method, params) { + const id = nextId++; + send({ jsonrpc: "2.0", id, method, params }); + return id; +} +function sendResponse(id, result) { + send({ jsonrpc: "2.0", id, result }); +} +function sendNotification(method, params) { + send({ jsonrpc: "2.0", method, params }); +} + +function maybeRegister(method) { + if (pullConfig.registerOn !== method || registeredCapability) return; + registeredCapability = true; + sendRequest("client/registerCapability", { + registrations: pullConfig.registrations.map((registration, index) => ({ + id: registration.id ?? `pull-${index}`, + method: registration.method ?? "textDocument/diagnostic", + registerOptions: registration.registerOptions ?? registration, + })), + }); +} + +function handle(raw) { + let data; + try { + data = JSON.parse(raw); + } catch { + return; + } + + if (data.method === "initialize") { + initializeParams = data.params; + sendResponse(data.id, { + capabilities: { + textDocumentSync: { change: 2, openClose: true }, + ...(pullConfig.hasDiagnosticProvider + ? { + diagnosticProvider: { + identifier: "fake", + interFileDependencies: false, + workspaceDiagnostics: false, + }, + } + : {}), + }, + }); + return; + } + + if (data.method === "test/get-initialize-params") { + sendResponse(data.id, initializeParams); + return; + } + + if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") { + return; + } + + if (data.method === "textDocument/didOpen") { + maybeRegister("didOpen"); + return; + } + + if (data.method === "textDocument/didChange") { + lastChange = data.params; + maybeRegister("didChange"); + return; + } + + if (data.method === "workspace/didChangeWatchedFiles") { + return; + } + + if (data.method === "test/configure-pull-diagnostics") { + pullConfig = { + registerOn: data.params?.registerOn, + registrations: data.params?.registrations ?? [], + documentDiagnostics: data.params?.documentDiagnostics ?? [], + workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [], + hasDiagnosticProvider: data.params?.hasDiagnosticProvider ?? false, + }; + registeredCapability = false; + sendResponse(data.id, null); + return; + } + + if (data.method === "test/publish-diagnostics") { + sendNotification("textDocument/publishDiagnostics", data.params); + sendResponse(data.id, null); + return; + } + + if (data.method === "test/get-last-change") { + sendResponse(data.id, lastChange); + return; + } + + if (data.method === "test/get-diagnostic-request-count") { + sendResponse(data.id, diagnosticRequestCount); + return; + } + + if (data.method === "textDocument/diagnostic") { + diagnosticRequestCount += 1; + sendResponse(data.id, { kind: "full", items: pullConfig.documentDiagnostics }); + return; + } + + if (data.method === "workspace/diagnostic") { + diagnosticRequestCount += 1; + sendResponse(data.id, { items: pullConfig.workspaceDiagnostics }); + return; + } + + if (data.method === "textDocument/hover") { + sendResponse(data.id, { contents: { kind: "plaintext", value: "fake hover" } }); + return; + } + + if (data.method === "textDocument/definition") { + sendResponse(data.id, [ + { + uri: data.params?.textDocument?.uri, + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, + }, + ]); + return; + } + + // Default: respond null to any other request so the client never hangs. + if (typeof data.id !== "undefined") { + sendResponse(data.id, null); + } +} + +process.stdin.on("data", (chunk) => { + readBuffer = Buffer.concat([readBuffer, chunk]); + const { messages, rest } = decodeFrames(readBuffer); + readBuffer = rest; + for (const message of messages) handle(message); +}); |
