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 | |
| 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')
| -rw-r--r-- | packages/core/src/lsp/client.ts | 658 | ||||
| -rw-r--r-- | packages/core/src/lsp/diagnostic.ts | 41 | ||||
| -rw-r--r-- | packages/core/src/lsp/index.ts | 18 | ||||
| -rw-r--r-- | packages/core/src/lsp/language.ts | 72 | ||||
| -rw-r--r-- | packages/core/src/lsp/manager.ts | 220 | ||||
| -rw-r--r-- | packages/core/src/lsp/server.ts | 68 |
6 files changed, 1077 insertions, 0 deletions
diff --git a/packages/core/src/lsp/client.ts b/packages/core/src/lsp/client.ts new file mode 100644 index 0000000..da0c916 --- /dev/null +++ b/packages/core/src/lsp/client.ts @@ -0,0 +1,658 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { extname, isAbsolute, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + createMessageConnection, + type MessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node"; +import type { Diagnostic } from "vscode-languageserver-types"; +import { languageIdForExtension } from "./language.js"; + +export type { Diagnostic } from "vscode-languageserver-types"; + +// ─── Timing constants (mirrors opencode) ───────────────────────── +const DIAGNOSTICS_DEBOUNCE_MS = 150; +const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000; +const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000; +const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000; +const INITIALIZE_TIMEOUT_MS = 45_000; + +// ─── LSP spec constants ────────────────────────────────────────── +const FILE_CHANGE_CREATED = 1; +const FILE_CHANGE_CHANGED = 2; +const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2; + +/** + * A live spawned language-server process plus the `initializationOptions` to + * hand it. Produced by the server-spawning layer (`server.ts`) and consumed by + * `createLspClient`. + */ +export interface LspServerHandle { + process: ChildProcessWithoutNullStreams; + initialization?: Record<string, unknown>; +} + +interface ServerCapabilities { + textDocumentSync?: number | { change?: number }; + diagnosticProvider?: unknown; + [key: string]: unknown; +} + +interface DiagnosticRequestResult { + handled: boolean; + matched: boolean; + byFile: Map<string, Diagnostic[]>; +} + +interface CapabilityRegistration { + id: string; + method: string; + registerOptions?: { + identifier?: string; + workspaceDiagnostics?: boolean; + }; +} + +type DocumentDiagnosticReport = { + items?: Diagnostic[]; + relatedDocuments?: Record<string, DocumentDiagnosticReport>; +}; + +type WorkspaceDiagnosticReport = { + items?: { uri?: string; items?: Diagnostic[] }[]; +}; + +/** Public shape of a connected LSP client. */ +export interface LspClient { + readonly serverID: string; + readonly root: string; + readonly connection: MessageConnection; + /** + * Open (or re-sync) a file with the server. Returns the document version + * sent — pass it to `waitForDiagnostics` to wait for diagnostics matching + * this exact sync. + */ + notifyOpen(path: string): Promise<number>; + /** Snapshot of all known diagnostics keyed by absolute file path. */ + readonly diagnostics: Map<string, Diagnostic[]>; + /** Wait until diagnostics for `path` settle (push and/or pull). */ + waitForDiagnostics(request: { + path: string; + version: number; + mode?: "document" | "full"; + after?: number; + }): Promise<void>; + /** Generic LSP request passthrough (hover, definition, references, …). */ + request<T = unknown>(method: string, params: unknown): Promise<T | null>; + /** Shut the connection and child process down. */ + shutdown(): Promise<void>; +} + +function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> { + return new Promise<T>((resolvePromise, reject) => { + const timer = setTimeout(() => reject(new Error(`LSP request timed out after ${ms}ms`)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolvePromise(value); + }, + (err) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} + +function getFilePath(uri: string): string | undefined { + if (!uri.startsWith("file://")) return undefined; + return fileURLToPath(uri); +} + +function getSyncKind(capabilities?: ServerCapabilities): number | undefined { + if (!capabilities) return undefined; + const sync = capabilities.textDocumentSync; + if (typeof sync === "number") return sync; + return sync?.change; +} + +function endPosition(text: string) { + const lines = text.split(/\r\n|\r|\n/); + return { line: lines.length - 1, character: lines.at(-1)?.length ?? 0 }; +} + +function dedupeDiagnostics(items: Diagnostic[]): Diagnostic[] { + const seen = new Set<string>(); + return items.filter((item) => { + const key = JSON.stringify({ + code: item.code, + severity: item.severity, + message: item.message, + source: item.source, + range: item.range, + }); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function configurationValue(settings: unknown, section?: string): unknown { + if (!section) return settings ?? null; + const result = section.split(".").reduce<unknown>((acc, key) => { + if (!acc || typeof acc !== "object" || !(key in acc)) return undefined; + return (acc as Record<string, unknown>)[key]; + }, settings); + return result ?? null; +} + +/** + * Create and initialize an LSP client over a spawned server's stdio. + * + * Performs the full `initialize`/`initialized` handshake (with a 45s timeout), + * wires push (`textDocument/publishDiagnostics`) and pull + * (`textDocument/diagnostic`, `workspace/diagnostic`) diagnostics, answers the + * `workspace/configuration`, `workspaceFolders`, and capability-registration + * requests servers commonly make, and returns a small client surface used by + * the manager and tools. Plain-TypeScript port of opencode's `lsp/client.ts`. + */ +export async function createLspClient(input: { + serverID: string; + server: LspServerHandle; + root: string; + directory: string; +}): Promise<LspClient> { + const { serverID, server, root, directory } = input; + + const connection = createMessageConnection( + new StreamMessageReader(server.process.stdout), + new StreamMessageWriter(server.process.stdin), + ); + + // Server stderr is routine for many tools (luau-lsp logs sourcemap status + // there). Keep it quiet unless debugging. + server.process.stderr?.on("data", () => { + /* swallowed — see opencode: stderr is mostly informational */ + }); + + // ─── Connection state ─── + const pushDiagnostics = new Map<string, Diagnostic[]>(); + const pullDiagnostics = new Map<string, Diagnostic[]>(); + const published = new Map<string, { at: number; version?: number }>(); + const diagnosticRegistrations = new Map<string, CapabilityRegistration>(); + const registrationListeners = new Set<() => void>(); + const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>(); + const files: Record<string, { version: number; text: string }> = {}; + + const mergedDiagnostics = (filePath: string) => + dedupeDiagnostics([ + ...(pushDiagnostics.get(filePath) ?? []), + ...(pullDiagnostics.get(filePath) ?? []), + ]); + const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { + pushDiagnostics.set(filePath, next); + for (const listener of diagnosticListeners) listener({ path: filePath, serverID }); + }; + const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { + pullDiagnostics.set(filePath, next); + }; + const emitRegistrationChange = () => { + for (const listener of [...registrationListeners]) listener(); + }; + + // ─── Notification / request handlers ─── + connection.onNotification( + "textDocument/publishDiagnostics", + (params: { uri: string; diagnostics: Diagnostic[]; version?: number }) => { + const filePath = getFilePath(params.uri); + if (!filePath) return; + published.set(filePath, { + at: Date.now(), + version: typeof params.version === "number" ? params.version : undefined, + }); + updatePushDiagnostics(filePath, params.diagnostics); + }, + ); + connection.onRequest("window/workDoneProgress/create", () => null); + connection.onRequest("workspace/configuration", (params: { items?: { section?: string }[] }) => { + const items = params.items ?? []; + return items.map((item) => configurationValue(server.initialization, item.section)); + }); + connection.onRequest( + "client/registerCapability", + (params: { registrations?: CapabilityRegistration[] }) => { + const registrations = params.registrations ?? []; + let changed = false; + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue; + diagnosticRegistrations.set(registration.id, registration); + changed = true; + } + if (changed) emitRegistrationChange(); + return null; + }, + ); + connection.onRequest( + "client/unregisterCapability", + (params: { unregisterations?: { id: string; method: string }[] }) => { + const registrations = params.unregisterations ?? []; + let changed = false; + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue; + diagnosticRegistrations.delete(registration.id); + changed = true; + } + if (changed) emitRegistrationChange(); + return null; + }, + ); + connection.onRequest("workspace/workspaceFolders", () => [ + { name: "workspace", uri: pathToFileURL(root).href }, + ]); + connection.onRequest("workspace/diagnostic/refresh", () => null); + connection.listen(); + + // ─── Initialize handshake ─── + const initialized = await withTimeout( + connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", { + rootUri: pathToFileURL(root).href, + processId: server.process.pid ?? null, + workspaceFolders: [{ name: "workspace", uri: pathToFileURL(root).href }], + initializationOptions: { ...server.initialization }, + capabilities: { + window: { workDoneProgress: true }, + workspace: { + configuration: true, + didChangeWatchedFiles: { dynamicRegistration: true }, + diagnostics: { refreshSupport: false }, + }, + textDocument: { + synchronization: { didOpen: true, didChange: true }, + diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true }, + publishDiagnostics: { versionSupport: false }, + }, + }, + }), + INITIALIZE_TIMEOUT_MS, + ); + + const syncKind = getSyncKind(initialized.capabilities); + const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider); + + await connection.sendNotification("initialized", {}); + if (server.initialization) { + await connection.sendNotification("workspace/didChangeConfiguration", { + settings: server.initialization, + }); + } + + // ─── Pull-diagnostics helpers ─── + const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => { + const handled = results.some((r) => r.handled); + const matched = results.some((r) => r.matched); + if (!handled) return { handled: false, matched: false }; + + const merged = new Map<string, Diagnostic[]>(); + for (const result of results) { + for (const [target, items] of result.byFile.entries()) { + merged.set(target, (merged.get(target) ?? []).concat(items)); + } + } + if (matched && !merged.has(filePath)) merged.set(filePath, []); + for (const [target, items] of merged.entries()) { + updatePullDiagnostics(target, dedupeDiagnostics(items)); + } + return { handled, matched }; + }; + + async function requestDiagnosticReport( + filePath: string, + identifier?: string, + ): Promise<DiagnosticRequestResult> { + const report = await withTimeout( + connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", { + ...(identifier ? { identifier } : {}), + textDocument: { uri: pathToFileURL(filePath).href }, + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null); + const empty: DiagnosticRequestResult = { + handled: false, + matched: false, + byFile: new Map(), + }; + if (!report) return empty; + + const byFile = new Map<string, Diagnostic[]>(); + const push = (target: string, items: Diagnostic[]) => { + byFile.set(target, (byFile.get(target) ?? []).concat(items)); + }; + let handled = false; + let matched = false; + if (Array.isArray(report.items)) { + push(filePath, report.items); + handled = true; + matched = true; + } + for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) { + const relatedPath = getFilePath(uri); + if (!relatedPath || !Array.isArray(related.items)) continue; + push(relatedPath, related.items); + handled = true; + matched = matched || relatedPath === filePath; + } + return { handled, matched, byFile }; + } + + async function requestWorkspaceDiagnosticReport( + filePath: string, + identifier?: string, + ): Promise<DiagnosticRequestResult> { + const report = await withTimeout( + connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", { + ...(identifier ? { identifier } : {}), + previousResultIds: [], + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null); + if (!report) return { handled: false, matched: false, byFile: new Map() }; + + const byFile = new Map<string, Diagnostic[]>(); + let matched = false; + for (const item of report.items ?? []) { + const relatedPath = item.uri ? getFilePath(item.uri) : undefined; + if (!relatedPath || !Array.isArray(item.items)) continue; + byFile.set(relatedPath, (byFile.get(relatedPath) ?? []).concat(item.items)); + matched = matched || relatedPath === filePath; + } + return { handled: true, matched, byFile }; + } + + function documentPullState() { + const documentRegistrations = [...diagnosticRegistrations.values()].filter( + (r) => r.registerOptions?.workspaceDiagnostics !== true, + ); + return { + documentIdentifiers: [ + ...new Set(documentRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])), + ], + supported: hasStaticPullDiagnostics || documentRegistrations.length > 0, + }; + } + + function workspacePullState() { + const workspaceRegistrations = [...diagnosticRegistrations.values()].filter( + (r) => r.registerOptions?.workspaceDiagnostics === true, + ); + return { + workspaceIdentifiers: [ + ...new Set(workspaceRegistrations.flatMap((r) => r.registerOptions?.identifier ?? [])), + ], + supported: workspaceRegistrations.length > 0, + }; + } + + const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) => + results.some((r) => (r.byFile.get(filePath)?.length ?? 0) > 0); + + async function requestDiagnostics( + filePath: string, + requests: Promise<DiagnosticRequestResult>[], + done: (results: DiagnosticRequestResult[]) => boolean, + ) { + if (!requests.length) return { handled: false, matched: false }; + const results: DiagnosticRequestResult[] = []; + return new Promise<{ handled: boolean; matched: boolean }>((resolvePromise) => { + let pending = requests.length; + let resolved = false; + const finish = (merged: { handled: boolean; matched: boolean }, force = false) => { + if (resolved) return; + if (!force && !done(results)) return; + resolved = true; + resolvePromise(merged); + }; + for (const request of requests) { + request.then((result) => { + results.push(result); + pending -= 1; + const merged = mergeResults(filePath, results); + finish(merged); + if (pending === 0) finish(merged, true); + }); + } + }); + } + + async function requestDocumentDiagnostics(filePath: string) { + const state = documentPullState(); + if (!state.supported) return { handled: false, matched: false }; + return requestDiagnostics( + filePath, + [ + requestDiagnosticReport(filePath), + ...state.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)), + ], + (results) => hasCurrentFileDiagnostics(filePath, results), + ); + } + + async function requestFullDiagnostics(filePath: string) { + const documentState = documentPullState(); + const workspaceState = workspacePullState(); + if (!documentState.supported && !workspaceState.supported) { + return { handled: false, matched: false }; + } + return mergeResults( + filePath, + await Promise.all([ + ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []), + ...documentState.documentIdentifiers.map((id) => requestDiagnosticReport(filePath, id)), + ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []), + ...workspaceState.workspaceIdentifiers.map((id) => + requestWorkspaceDiagnosticReport(filePath, id), + ), + ]), + ); + } + + function waitForRegistrationChange(timeout: number) { + if (timeout <= 0) return Promise.resolve(false); + return new Promise<boolean>((resolvePromise) => { + let finished = false; + let timer: ReturnType<typeof setTimeout> | undefined; + const finish = (result: boolean) => { + if (finished) return; + finished = true; + if (timer) clearTimeout(timer); + registrationListeners.delete(listener); + resolvePromise(result); + }; + const listener = () => finish(true); + registrationListeners.add(listener); + timer = setTimeout(() => finish(false), timeout); + }); + } + + function waitForFreshPush(request: { + path: string; + version: number; + after: number; + timeout: number; + }) { + if (request.timeout <= 0) return Promise.resolve(false); + return new Promise<boolean>((resolvePromise) => { + let finished = false; + let debounceTimer: ReturnType<typeof setTimeout> | undefined; + let timeoutTimer: ReturnType<typeof setTimeout> | undefined; + let unsub: (() => void) | undefined; + const finish = (result: boolean) => { + if (finished) return; + finished = true; + if (debounceTimer) clearTimeout(debounceTimer); + if (timeoutTimer) clearTimeout(timeoutTimer); + unsub?.(); + resolvePromise(result); + }; + const schedule = () => { + const hit = published.get(request.path); + if (!hit) return; + if (typeof hit.version === "number" && hit.version !== request.version) return; + if (hit.at < request.after && hit.version !== request.version) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout( + () => finish(true), + Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)), + ); + }; + timeoutTimer = setTimeout(() => finish(false), request.timeout); + const listener = (event: { path: string; serverID: string }) => { + if (event.path !== request.path || event.serverID !== serverID) return; + schedule(); + }; + diagnosticListeners.add(listener); + unsub = () => diagnosticListeners.delete(listener); + schedule(); + }); + } + + async function waitForDocumentDiagnostics(request: { + path: string; + version: number; + after?: number; + }) { + const startedAt = request.after ?? Date.now(); + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS, + }); + while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) { + const result = await requestDocumentDiagnostics(request.path); + if (result.matched) return; + const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt); + if (remaining <= 0) return; + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : "timeout")), + waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")), + ]); + if (next !== "registration") return; + } + } + + async function waitForFullDiagnostics(request: { + path: string; + version: number; + after?: number; + }) { + const startedAt = request.after ?? Date.now(); + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS, + }); + while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) { + const result = await requestFullDiagnostics(request.path); + if (result.handled || result.matched) return; + const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt); + if (remaining <= 0) return; + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : "timeout")), + waitForRegistrationChange(remaining).then((c) => (c ? "registration" : "timeout")), + ]); + if (next !== "registration") return; + } + } + + const normalize = (p: string) => (isAbsolute(p) ? p : resolve(directory, p)); + + // ─── Public surface ─── + const client: LspClient = { + serverID, + root, + connection, + async notifyOpen(path: string) { + const filePath = normalize(path); + const text = await readFile(filePath, "utf8"); + const languageId = languageIdForExtension(extname(filePath)); + const uri = pathToFileURL(filePath).href; + const document = files[filePath]; + + if (document !== undefined) { + await connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [{ uri, type: FILE_CHANGE_CHANGED }], + }); + const next = document.version + 1; + files[filePath] = { version: next, text }; + await connection.sendNotification("textDocument/didChange", { + textDocument: { uri, version: next }, + contentChanges: + syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL + ? [ + { + range: { + start: { line: 0, character: 0 }, + end: endPosition(document.text), + }, + text, + }, + ] + : [{ text }], + }); + return next; + } + + await connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [{ uri, type: FILE_CHANGE_CREATED }], + }); + pushDiagnostics.delete(filePath); + pullDiagnostics.delete(filePath); + await connection.sendNotification("textDocument/didOpen", { + textDocument: { uri, languageId, version: 0, text }, + }); + files[filePath] = { version: 0, text }; + return 0; + }, + get diagnostics() { + const result = new Map<string, Diagnostic[]>(); + for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) { + result.set(key, mergedDiagnostics(key)); + } + return result; + }, + async waitForDiagnostics(request) { + const normalizedPath = normalize(request.path); + if (request.mode === "document") { + await waitForDocumentDiagnostics({ + path: normalizedPath, + version: request.version, + after: request.after, + }); + return; + } + await waitForFullDiagnostics({ + path: normalizedPath, + version: request.version, + after: request.after, + }); + }, + async request<T = unknown>(method: string, params: unknown): Promise<T | null> { + return connection.sendRequest<T>(method, params).catch(() => null); + }, + async shutdown() { + try { + connection.end(); + connection.dispose(); + } catch { + /* connection may already be closed */ + } + server.process.kill(); + }, + }; + + return client; +} diff --git a/packages/core/src/lsp/diagnostic.ts b/packages/core/src/lsp/diagnostic.ts new file mode 100644 index 0000000..1ad4d0f --- /dev/null +++ b/packages/core/src/lsp/diagnostic.ts @@ -0,0 +1,41 @@ +import type { Diagnostic } from "vscode-languageserver-types"; + +/** + * Diagnostic formatting helpers. Ported from opencode's `lsp/diagnostic.ts`. + * + * LSP positions are 0-based on the wire; we render them 1-based (editor-style) + * so they line up with what `read_file` shows and what editors report. + */ + +/** Max diagnostics rendered per file before truncating with a "… and N more". */ +const MAX_PER_FILE = 20; + +const SEVERITY_LABEL: Record<number, string> = { + 1: "ERROR", + 2: "WARN", + 3: "INFO", + 4: "HINT", +}; + +/** Render a single diagnostic as `SEVERITY [line:col] message` (1-based). */ +export function pretty(diagnostic: Diagnostic): string { + const severity = SEVERITY_LABEL[diagnostic.severity ?? 1] ?? "ERROR"; + const line = diagnostic.range.start.line + 1; + const col = diagnostic.range.start.character + 1; + return `${severity} [${line}:${col}] ${diagnostic.message}`; +} + +/** + * Build a `<diagnostics file="…">` block for a file's ERROR-severity + * diagnostics, or `""` when there are none. Errors only — warnings/info/hints + * are intentionally omitted so the model is nudged toward the things that + * actually break the build (matching opencode's behavior). + */ +export function report(file: string, issues: Diagnostic[]): string { + const errors = issues.filter((item) => item.severity === 1); + if (errors.length === 0) return ""; + const limited = errors.slice(0, MAX_PER_FILE); + const more = errors.length - MAX_PER_FILE; + const suffix = more > 0 ? `\n... and ${more} more` : ""; + return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`; +} diff --git a/packages/core/src/lsp/index.ts b/packages/core/src/lsp/index.ts new file mode 100644 index 0000000..fd43c2f --- /dev/null +++ b/packages/core/src/lsp/index.ts @@ -0,0 +1,18 @@ +// LSP (Language Server Protocol) integration. +// +// Config-driven only: servers are declared in `dispatch.toml`'s `[lsp.<id>]` +// block (see `LspServerConfig` in `../types`). There is no builtin server +// registry and no auto-download. The primary model-facing surface is +// diagnostics-on-write (the host passes a write hook that calls `touchFile` + +// `report`); an on-demand `lsp` tool exposes hover/definition/references too. + +export { + createLspClient, + type Diagnostic, + type LspClient, + type LspServerHandle, +} from "./client.js"; +export { pretty, report } from "./diagnostic.js"; +export { LANGUAGE_EXTENSIONS, languageIdForExtension } from "./language.js"; +export { LspManager } from "./manager.js"; +export { type ResolvedLspServer, resolveServersFromConfig } from "./server.js"; diff --git a/packages/core/src/lsp/language.ts b/packages/core/src/lsp/language.ts new file mode 100644 index 0000000..3e9fe68 --- /dev/null +++ b/packages/core/src/lsp/language.ts @@ -0,0 +1,72 @@ +/** + * File-extension → LSP `languageId` map. + * + * The LSP `textDocument/didOpen` notification carries a `languageId` string + * that tells the server how to parse the document. This table is a trimmed + * port of opencode's `lsp/language.ts`, with one critical addition for this + * project: `.luau` → `"luau"`. Roblox Luau sources use the `.luau` extension, + * which standard Lua tooling does not recognise — luau-lsp expects the + * `"luau"` languageId. + * + * Extensions are looked up with their leading dot (e.g. `".luau"`). Unknown + * extensions fall back to `"plaintext"` at the call site. + */ +export const LANGUAGE_EXTENSIONS: Record<string, string> = { + // Luau (Roblox) — the reason this module exists. Keep first for visibility. + ".luau": "luau", + ".lua": "lua", + // A pragmatic subset of common languages, mirroring opencode's table so a + // user can point an arbitrary LSP server at this codebase and have the + // right languageId reported. + ".c": "c", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".h": "c", + ".hpp": "cpp", + ".cs": "csharp", + ".css": "css", + ".dart": "dart", + ".go": "go", + ".html": "html", + ".htm": "html", + ".java": "java", + ".js": "javascript", + ".jsx": "javascriptreact", + ".json": "json", + ".jsonc": "jsonc", + ".kt": "kotlin", + ".kts": "kotlin", + ".md": "markdown", + ".markdown": "markdown", + ".php": "php", + ".py": "python", + ".rb": "ruby", + ".rs": "rust", + ".scss": "scss", + ".sass": "sass", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".sql": "sql", + ".svelte": "svelte", + ".swift": "swift", + ".toml": "toml", + ".ts": "typescript", + ".tsx": "typescriptreact", + ".mts": "typescript", + ".cts": "typescript", + ".vue": "vue", + ".xml": "xml", + ".yaml": "yaml", + ".yml": "yaml", + ".zig": "zig", +}; + +/** + * Resolve the LSP `languageId` for a file path's extension, falling back to + * `"plaintext"` when the extension is unknown. + */ +export function languageIdForExtension(extension: string): string { + return LANGUAGE_EXTENSIONS[extension] ?? "plaintext"; +} 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(() => {}))); + } +} diff --git a/packages/core/src/lsp/server.ts b/packages/core/src/lsp/server.ts new file mode 100644 index 0000000..1fb002e --- /dev/null +++ b/packages/core/src/lsp/server.ts @@ -0,0 +1,68 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import type { LspServerConfig } from "../types/index.js"; +import type { LspServerHandle } from "./client.js"; + +/** + * A resolved, ready-to-spawn LSP server derived from a `dispatch.toml` + * `[lsp.<id>]` entry. Config-driven only — dispatch ships no builtin server + * registry and performs no auto-download (unlike opencode). The declared + * executable (`command[0]`) must already be on PATH. + */ +export interface ResolvedLspServer { + id: string; + /** Extensions (with leading dot) this server attaches to, e.g. `".luau"`. */ + extensions: string[]; + /** Launch the server over stdio rooted at `root`. */ + spawn(root: string): LspServerHandle; +} + +/** + * Spawn a child process for an LSP server over stdio. Inherits `process.env` + * (so a PATH-resident `rojo` is visible to luau-lsp's sourcemap autogenerate) + * and merges any `env` from the server config on top. + */ +function spawnServer( + command: string[], + cwd: string, + env: Record<string, string> | undefined, + initialization: Record<string, unknown> | undefined, +): LspServerHandle { + const [cmd, ...args] = command; + if (!cmd) throw new Error("LSP server command is empty"); + const proc = spawn(cmd, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["pipe", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams; + return { + process: proc, + ...(initialization ? { initialization } : {}), + }; +} + +/** + * Turn the parsed `dispatch.toml` `lsp` block into a list of spawnable + * servers. Disabled entries are dropped. Entries with no `command`/`extensions` + * are skipped defensively (the config validator already enforces these, but we + * guard here too so a hand-built config object can't crash the manager). + */ +export function resolveServersFromConfig( + lsp: Record<string, LspServerConfig> | undefined, +): ResolvedLspServer[] { + if (!lsp) return []; + const servers: ResolvedLspServer[] = []; + for (const [id, entry] of Object.entries(lsp)) { + if (entry.disabled) continue; + if (!entry.command || entry.command.length === 0) continue; + if (!entry.extensions || entry.extensions.length === 0) continue; + const command = entry.command; + const env = entry.env; + const initialization = entry.initialization; + servers.push({ + id, + extensions: entry.extensions, + spawn: (root: string) => spawnServer(command, root, env, initialization), + }); + } + return servers; +} |
