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/src/config | |
| 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/src/config')
| -rw-r--r-- | packages/core/src/config/schema.ts | 107 |
1 files changed, 106 insertions, 1 deletions
diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index a459a4d..304ee10 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -1,4 +1,9 @@ -import type { ConfigError, DispatchConfig, KeyDefinition } from "../types/index.js"; +import type { + ConfigError, + DispatchConfig, + KeyDefinition, + LspServerConfig, +} from "../types/index.js"; function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -100,6 +105,99 @@ function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefi }; } +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === "string"); +} + +function validateLspServer( + raw: unknown, + path: string, + errors: ConfigError[], +): LspServerConfig | null { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return null; + } + + const disabled = raw.disabled === true; + + // `command` is required and must be a non-empty string array unless the + // entry is explicitly disabled (a disabled entry is skipped wholesale). + if (!disabled) { + if (!isStringArray(raw.command) || raw.command.length === 0) { + errors.push({ + path: `${path}.command`, + message: "must be a non-empty array of strings", + }); + return null; + } + // `extensions` is required for custom servers — without it the client + // cannot know which files should activate the server. + if (!isStringArray(raw.extensions) || raw.extensions.length === 0) { + errors.push({ + path: `${path}.extensions`, + message: 'must be a non-empty array of strings (e.g. [".luau"])', + }); + return null; + } + } else { + // Disabled entries still must not carry a malformed command/extensions + // if present, but we do not require them. + if (raw.command !== undefined && !isStringArray(raw.command)) { + errors.push({ path: `${path}.command`, message: "must be an array of strings" }); + return null; + } + if (raw.extensions !== undefined && !isStringArray(raw.extensions)) { + errors.push({ path: `${path}.extensions`, message: "must be an array of strings" }); + return null; + } + } + + if (raw.env !== undefined && !isStringRecord(raw.env)) { + errors.push({ + path: `${path}.env`, + message: "must be a flat string-keyed object", + }); + return null; + } + + if (raw.initialization !== undefined && !isRecord(raw.initialization)) { + errors.push({ + path: `${path}.initialization`, + message: "must be an object", + }); + return null; + } + + const server: LspServerConfig = { + command: (raw.command as string[] | undefined) ?? [], + extensions: (raw.extensions as string[] | undefined) ?? [], + ...(isStringRecord(raw.env) ? { env: raw.env } : {}), + ...(isRecord(raw.initialization) + ? { initialization: raw.initialization as Record<string, unknown> } + : {}), + ...(disabled ? { disabled: true } : {}), + }; + return server; +} + +function validateLsp( + raw: unknown, + path: string, + errors: ConfigError[], +): Record<string, LspServerConfig> | undefined { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return undefined; + } + const result: Record<string, LspServerConfig> = {}; + for (const [id, value] of Object.entries(raw)) { + const server = validateLspServer(value, `${path}.${id}`, errors); + if (server) result[id] = server; + } + return Object.keys(result).length > 0 ? result : undefined; +} + export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } { const errors: ConfigError[] = []; @@ -125,9 +223,16 @@ export function validateConfig(raw: unknown): { config: DispatchConfig; errors: } } + // lsp (optional) + let lsp: Record<string, LspServerConfig> | undefined; + if (raw.lsp !== undefined) { + lsp = validateLsp(raw.lsp, "lsp", errors); + } + const config: DispatchConfig = { permissions, ...(keys !== undefined && { keys }), + ...(lsp !== undefined && { lsp }), }; return { config, errors }; |
