summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/config
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/src/config
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/src/config')
-rw-r--r--packages/core/src/config/schema.ts107
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 };