summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/lsp/manager.ts
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/lsp/manager.ts
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/lsp/manager.ts')
-rw-r--r--packages/core/src/lsp/manager.ts220
1 files changed, 220 insertions, 0 deletions
diff --git a/packages/core/src/lsp/manager.ts b/packages/core/src/lsp/manager.ts
new file mode 100644
index 0000000..db8b68e
--- /dev/null
+++ b/packages/core/src/lsp/manager.ts
@@ -0,0 +1,220 @@
+import { extname } from "node:path";
+import { createLspClient, type Diagnostic, type LspClient } from "./client.js";
+import type { ResolvedLspServer } from "./server.js";
+
+/**
+ * Process-wide owner of LSP client lifecycles.
+ *
+ * Clients are keyed by `root + serverID` and spawned lazily on the first file
+ * that matches a server's extensions, then reused. Concurrent spawns for the
+ * same key are de-duplicated via an in-flight map, and servers that fail to
+ * start are remembered in `broken` so we don't spawn-spam. Modeled on
+ * opencode's `lsp/lsp.ts` `getClients` flow, minus the Effect machinery.
+ *
+ * The manager is config-agnostic: callers resolve `ResolvedLspServer[]` from a
+ * tab's working-directory config (`resolveServersFromConfig`) and pass them in
+ * alongside the `root`. This keeps per-working-directory config out of the
+ * manager while letting it own all the long-lived processes for the process.
+ */
+export class LspManager {
+ private clients = new Map<string, LspClient>();
+ private spawning = new Map<string, Promise<LspClient | undefined>>();
+ private broken = new Set<string>();
+
+ private key(root: string, serverID: string): string {
+ return `${root}\u0000${serverID}`;
+ }
+
+ private serversForFile(file: string, servers: ResolvedLspServer[]): ResolvedLspServer[] {
+ const extension = extname(file) || file;
+ return servers.filter(
+ (server) => server.extensions.length === 0 || server.extensions.includes(extension),
+ );
+ }
+
+ /**
+ * True if any provided server is configured to attach to this file's
+ * extension (regardless of whether it has spawned yet). Used to decide
+ * whether an LSP operation is even applicable to a file.
+ */
+ hasServerForFile(file: string, servers: ResolvedLspServer[]): boolean {
+ return this.serversForFile(file, servers).length > 0;
+ }
+
+ /**
+ * Get (spawning if needed) all clients that should attach to `file` at
+ * `root`. Spawn failures are swallowed (logged via `broken`) and simply
+ * yield fewer clients — callers degrade gracefully to "no diagnostics".
+ */
+ async getClients(input: {
+ file: string;
+ root: string;
+ servers: ResolvedLspServer[];
+ }): Promise<LspClient[]> {
+ const { file, root, servers } = input;
+ const matching = this.serversForFile(file, servers);
+ const result: LspClient[] = [];
+
+ for (const server of matching) {
+ const key = this.key(root, server.id);
+ if (this.broken.has(key)) continue;
+
+ const existing = this.clients.get(key);
+ if (existing) {
+ result.push(existing);
+ continue;
+ }
+
+ const inflight = this.spawning.get(key);
+ if (inflight) {
+ const client = await inflight;
+ if (client) result.push(client);
+ continue;
+ }
+
+ const task = this.spawn(server, root, key);
+ this.spawning.set(key, task);
+ task.finally(() => {
+ if (this.spawning.get(key) === task) this.spawning.delete(key);
+ });
+ const client = await task;
+ if (client) result.push(client);
+ }
+
+ return result;
+ }
+
+ private async spawn(
+ server: ResolvedLspServer,
+ root: string,
+ key: string,
+ ): Promise<LspClient | undefined> {
+ let handle: ReturnType<ResolvedLspServer["spawn"]>;
+ try {
+ handle = server.spawn(root);
+ } catch (err) {
+ this.broken.add(key);
+ console.warn(
+ `dispatch: failed to spawn LSP server "${server.id}": ${err instanceof Error ? err.message : String(err)}`,
+ );
+ return undefined;
+ }
+
+ // A spawn that fails asynchronously (e.g. ENOENT — binary not on PATH)
+ // emits `error` on the child process; mark broken so we don't retry it.
+ handle.process.on("error", (err) => {
+ this.broken.add(key);
+ console.warn(`dispatch: LSP server "${server.id}" process error: ${err.message}`);
+ });
+
+ try {
+ const client = await createLspClient({
+ serverID: server.id,
+ server: handle,
+ root,
+ directory: root,
+ });
+ // A racing caller may have created the same client; prefer the
+ // existing one and discard ours.
+ const existing = this.clients.get(key);
+ if (existing) {
+ await client.shutdown();
+ return existing;
+ }
+ this.clients.set(key, client);
+ return client;
+ } catch (err) {
+ this.broken.add(key);
+ try {
+ handle.process.kill();
+ } catch {
+ /* already dead */
+ }
+ console.warn(
+ `dispatch: failed to initialize LSP client "${server.id}": ${err instanceof Error ? err.message : String(err)}`,
+ );
+ return undefined;
+ }
+ }
+
+ /**
+ * Open/sync a file with its clients and (optionally) wait for diagnostics
+ * to settle. `mode: "document"` waits for the file's own diagnostics;
+ * `"full"` also waits on workspace diagnostics; omitted just syncs.
+ */
+ async touchFile(input: {
+ file: string;
+ root: string;
+ servers: ResolvedLspServer[];
+ mode?: "document" | "full";
+ }): Promise<void> {
+ const clients = await this.getClients(input);
+ await Promise.all(
+ clients.map(async (client) => {
+ const after = Date.now();
+ const version = await client.notifyOpen(input.file);
+ if (!input.mode) return;
+ await client.waitForDiagnostics({
+ path: input.file,
+ version,
+ mode: input.mode,
+ after,
+ });
+ }),
+ ).catch((err) => {
+ console.warn(
+ `dispatch: failed to touch file for LSP: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ });
+ }
+
+ /**
+ * Merged diagnostics for a single file across all of its clients, keyed by
+ * absolute file path. Includes related-file diagnostics a client surfaced
+ * (e.g. workspace pulls), so the result map may contain more than `file`.
+ */
+ getDiagnostics(input: {
+ root: string;
+ servers: ResolvedLspServer[];
+ file: string;
+ }): Record<string, Diagnostic[]> {
+ const results: Record<string, Diagnostic[]> = {};
+ const matching = this.serversForFile(input.file, input.servers);
+ for (const server of matching) {
+ const client = this.clients.get(this.key(input.root, server.id));
+ if (!client) continue;
+ for (const [path, diags] of client.diagnostics.entries()) {
+ results[path] = (results[path] ?? []).concat(diags);
+ }
+ }
+ return results;
+ }
+
+ /**
+ * Run a positional LSP request (hover/definition/references/etc.) against
+ * every client for the file and flatten the (non-null) results. `line`/
+ * `character` are 0-based here — the caller converts from editor 1-based.
+ */
+ async request(input: {
+ file: string;
+ root: string;
+ servers: ResolvedLspServer[];
+ method: string;
+ params: Record<string, unknown>;
+ }): Promise<unknown[]> {
+ const clients = await this.getClients(input);
+ const results = await Promise.all(
+ clients.map((client) => client.request(input.method, input.params)),
+ );
+ return results.filter((r) => r !== null && r !== undefined);
+ }
+
+ /** Shut down every live client and clear all state. */
+ async shutdownAll(): Promise<void> {
+ const clients = [...this.clients.values()];
+ this.clients.clear();
+ this.spawning.clear();
+ this.broken.clear();
+ await Promise.all(clients.map((client) => client.shutdown().catch(() => {})));
+ }
+}