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/lsp/manager.ts | |
| 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/lsp/manager.ts')
| -rw-r--r-- | packages/core/src/lsp/manager.ts | 220 |
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(() => {}))); + } +} |
