diff options
| author | Adam Malczewski <[email protected]> | 2026-06-11 21:12:03 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-11 21:12:03 +0900 |
| commit | e7eada4802ceebd86c83bcd6e3eca70152e7f331 (patch) | |
| tree | 447095fd60b43980358d1565506f3ae2430e5f29 /packages/lsp | |
| parent | 35937cee7f838e414eb8147c67205e01d85a4da0 (diff) | |
| download | dispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.tar.gz dispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.zip | |
feat(lsp,cwd): LSP integration + per-conversation cwd; fix cache-warming cache bust
LSP + per-conversation CWD feature:
- new bundled `lsp` extension: hand-rolled JSON-RPC codec (framing/rpc), lazy
one-server-per-(serverID,root), per-cwd config resolution, on-demand `lsp` tool
- `conversation-store`: getCwd/setCwd (cwdKey); `session-orchestrator` defaults a
turn's cwd from the store
- `transport-http`: cwd + lsp status endpoints; wire types in transport-contract
- host-bin: register lsp; config wiring
Cache-warming fix (the warm read 0% on the first reheat after a message):
- warm assembled tools under a different cwd than the real turn (a reheat sends no
cwd, and the warm service had no store fallback). The skills filter rewrites the
cwd-sensitive `load_skill` description, so the tools block — the first bytes of
the prompt-cache prefix — diverged and the cache missed entirely. Warm now
resolves cwd as opts.cwd ?? conversationStore.getCwd(), mirroring handleMessage.
- capture warm sends as `provider.request` spans flagged `warm:true` (thread a
child logger into providerOpts) so warm vs real bodies are diffable (obs §3.1).
- kernel logger: span-close now merges child-bound attrs like span-open, so a
`warm:true` query finds the closed span (with usage/status), not just the open.
Tests: warm forwards a warm-flagged logger; warm falls back to stored cwd; logger
open/close attr consistency. Full suite green (873).
Diffstat (limited to 'packages/lsp')
25 files changed, 2746 insertions, 0 deletions
diff --git a/packages/lsp/package.json b/packages/lsp/package.json new file mode 100644 index 0000000..2b5a360 --- /dev/null +++ b/packages/lsp/package.json @@ -0,0 +1,11 @@ +{ + "name": "@dispatch/lsp", + "version": "0.0.0", + "type": "module", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@dispatch/kernel": "workspace:*" + } +} diff --git a/packages/lsp/src/client.test.ts b/packages/lsp/src/client.test.ts new file mode 100644 index 0000000..681860f --- /dev/null +++ b/packages/lsp/src/client.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from "vitest"; +import { + type FileWatcher, + type FsAccess, + LanguageServerClient, + type SpawnProcess, +} from "./client.js"; +import { encode } from "./framing.js"; + +function makeClient(overrides?: { + readonly spawn?: SpawnProcess; + readonly fileWatcher?: FileWatcher; + readonly fs?: FsAccess; + readonly initialization?: Record<string, unknown>; +}): { + client: LanguageServerClient; + stdinChunks: Uint8Array[]; + serverResponses: (msg: string) => void; +} { + const stdinChunks: Uint8Array[] = []; + let serverMessageHandler: ((msg: string) => void) | null = null; + + const mockSpawn: SpawnProcess = () => ({ + stdin: { write: (bytes) => stdinChunks.push(bytes) }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + // We'll feed messages through serverResponses + serverMessageHandler = (msg: string) => { + cb(encode(msg)); + }; + }, + }, + pid: 123, + kill: () => {}, + }); + + const mockFileWatcher: FileWatcher = (_root, _onEvent) => ({ + close: () => {}, + }); + + const mockFs: FsAccess = { + readText: async (path) => `// content of ${path}`, + exists: async () => true, + }; + + const client = new LanguageServerClient({ + spawn: overrides?.spawn ?? mockSpawn, + fileWatcher: overrides?.fileWatcher ?? mockFileWatcher, + fs: overrides?.fs ?? mockFs, + command: ["test-lsp"], + root: "/project", + serverId: "test", + ...(overrides?.initialization ? { initialization: overrides.initialization } : {}), + }); + + return { + client, + stdinChunks, + serverResponses: (msg: string) => serverMessageHandler?.(msg), + }; +} + +describe("client", () => { + it("initialize declares didChangeWatchedFiles.dynamicRegistration true", async () => { + const { client, stdinChunks, serverResponses } = makeClient(); + + const startPromise = client.start(); + + // Wait for the initialize message to be sent + await new Promise((r) => setTimeout(r, 50)); + + // Parse the sent messages to find initialize + const sentMessages = stdinChunks.map((chunk) => { + const decoded = new TextDecoder().decode(chunk); + const headerEnd = decoded.indexOf("\r\n\r\n"); + return JSON.parse(decoded.slice(headerEnd + 4)); + }); + + const initMsg = sentMessages.find((m: { method?: string }) => m.method === "initialize"); + expect(initMsg).toBeDefined(); + expect(initMsg.params.capabilities.workspace.didChangeWatchedFiles.dynamicRegistration).toBe( + true, + ); + + // Send initialize response + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: initMsg.id, + result: { capabilities: {} }, + }), + ); + + await startPromise; + expect(client.getState()).toBe("connected"); + }); + + it("honors registerCapability for BOTH textDocument/diagnostic and workspace/didChangeWatchedFiles", async () => { + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Register workspace/didChangeWatchedFiles + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "client/registerCapability", + params: { + registrations: [ + { + id: "reg-watched", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }, + { + id: "reg-diag", + method: "textDocument/diagnostic", + registerOptions: {}, + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 50)); + + const registry = client.getWatchedFilesRegistry(); + expect(registry.matches("src/main.luau")).toBe(true); + expect(registry.matches("src/main.ts")).toBe(false); + }); + + it("an injected fs change for a registered glob sends workspace/didChangeWatchedFiles type=Changed (opencode-bug regression)", async () => { + const callbackHolder: { + cb: + | ((e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void) + | null; + } = { cb: null }; + + const trackingFileWatcher: FileWatcher = (_root, onEvent) => { + callbackHolder.cb = onEvent; + return { close: () => {} }; + }; + + const { client, stdinChunks, serverResponses } = makeClient({ + fileWatcher: trackingFileWatcher, + }); + + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Register a watcher for sourcemap.json + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "client/registerCapability", + params: { + registrations: [ + { + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "sourcemap.json" }], + }, + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 100)); + + // Simulate a file change + const onFsEvent = callbackHolder.cb; + if (!onFsEvent) throw new Error("file watcher callback was never registered"); + onFsEvent({ type: "change", path: "/project/sourcemap.json" }); + + await new Promise((r) => setTimeout(r, 100)); + + // Check that the notification was sent + const sentMessages = stdinChunks.map((chunk) => { + const decoded = new TextDecoder().decode(chunk); + const headerEnd = decoded.indexOf("\r\n\r\n"); + return JSON.parse(decoded.slice(headerEnd + 4)); + }); + + const didChangeMsg = sentMessages.find( + (m: { method?: string }) => m.method === "workspace/didChangeWatchedFiles", + ); + expect(didChangeMsg).toBeDefined(); + expect(didChangeMsg.params.changes[0].uri).toBe("file:///project/sourcemap.json"); + expect(didChangeMsg.params.changes[0].type).toBe(2); // Changed + }); + + it("publishDiagnostics are stored + returned", async () => { + const { client, serverResponses } = makeClient(); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { capabilities: {} }, + }), + ); + + await startPromise; + + // Send diagnostics + serverResponses( + JSON.stringify({ + jsonrpc: "2.0", + method: "textDocument/publishDiagnostics", + params: { + uri: "file:///project/test.ts", + diagnostics: [ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, + severity: 1, + message: "Test error", + }, + ], + }, + }), + ); + + await new Promise((r) => setTimeout(r, 50)); + + const store = client.getDiagnosticsStore(); + const formatted = store.format("file:///project/test.ts"); + expect(formatted).toContain("ERROR"); + expect(formatted).toContain("Test error"); + }); + + it("shutdown kills the process", async () => { + const state = { killed: false }; + const stdoutHolder: { cb: ((data: Uint8Array) => void) | null } = { cb: null }; + const killableSpawn: SpawnProcess = () => ({ + stdin: { write: () => {} }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + stdoutHolder.cb = cb; + }, + }, + pid: 123, + kill: () => { + state.killed = true; + }, + }); + + const { client } = makeClient({ spawn: killableSpawn }); + const startPromise = client.start(); + await new Promise((r) => setTimeout(r, 50)); + + // Deliver the initialize response through the spawned process's own + // stdout (the real read path), so start() can complete the handshake. + stdoutHolder.cb?.( + encode(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { capabilities: {} } })), + ); + + await startPromise; + + client.shutdown(); + expect(state.killed).toBe(true); + }); +}); diff --git a/packages/lsp/src/client.ts b/packages/lsp/src/client.ts new file mode 100644 index 0000000..114b8eb --- /dev/null +++ b/packages/lsp/src/client.ts @@ -0,0 +1,366 @@ +/** + * Language-server client — wires codec + rpc + edges; runs the + * initialize handshake, honors server→client requests, runs the + * FileWatcher, and forwards matching disk changes. + */ + +import { DiagnosticsStore, type PublishDiagnosticsParams } from "./diagnostics.js"; +import { FrameDecoder } from "./framing.js"; +import { JsonRpcConnection, type WriteFn } from "./rpc.js"; +import { FileChangeType, WatchedFilesRegistry } from "./watched-files.js"; + +export interface SpawnedProcess { + readonly stdin: { readonly write: (bytes: Uint8Array) => void }; + readonly stdout: + | AsyncIterable<Uint8Array> + | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }; + readonly stderr?: + | AsyncIterable<Uint8Array> + | { readonly on: (event: string, cb: (data: Uint8Array) => void) => void } + | undefined; + readonly pid: number | undefined; + readonly kill: () => void; +} + +export type SpawnProcess = ( + command: string[], + opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> | undefined }, +) => SpawnedProcess; + +export interface FileWatcherHandle { + readonly close: () => void; +} + +export type FileWatcher = ( + root: string, + onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, +) => FileWatcherHandle; + +export interface FsAccess { + readonly readText: (path: string) => Promise<string>; + readonly exists: (path: string) => Promise<boolean>; +} + +export interface ClientCapabilities { + readonly window: { readonly workDoneProgress: boolean }; + readonly workspace: { + readonly configuration: boolean; + readonly didChangeWatchedFiles: { readonly dynamicRegistration: boolean }; + readonly diagnostics: { readonly refreshSupport: boolean }; + }; + readonly textDocument: { + readonly synchronization: { readonly didOpen: boolean; readonly didChange: boolean }; + readonly diagnostic: { + readonly dynamicRegistration: boolean; + readonly relatedDocumentSupport: boolean; + }; + readonly publishDiagnostics: { readonly versionSupport: boolean }; + }; +} + +export const CLIENT_CAPABILITIES: ClientCapabilities = { + 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 }, + }, +}; + +export interface ClientDeps { + readonly spawn: SpawnProcess; + readonly fileWatcher: FileWatcher; + readonly fs: FsAccess; + readonly command: readonly string[]; + readonly env?: Readonly<Record<string, string>> | undefined; + readonly root: string; + readonly initialization?: Readonly<Record<string, unknown>> | undefined; + readonly serverId: string; +} + +export type ClientState = "starting" | "connected" | "error" | "not-started"; + +export class LanguageServerClient { + readonly serverId: string; + readonly root: string; + private process: SpawnedProcess | null = null; + private rpc: JsonRpcConnection | null = null; + private decoder = new FrameDecoder(); + private diagnostics = new DiagnosticsStore(); + private watchedFiles = new WatchedFilesRegistry(); + private fileWatcherHandle: FileWatcherHandle | null = null; + private state: ClientState = "not-started"; + private stateError: string | undefined; + private deps: ClientDeps; + private openDocuments = new Map<string, number>(); + + constructor(deps: ClientDeps) { + this.deps = deps; + this.serverId = deps.serverId; + this.root = deps.root; + } + + getState(): ClientState { + return this.state; + } + + getStateError(): string | undefined { + return this.stateError; + } + + async start(): Promise<void> { + this.state = "starting"; + try { + const spawnOpts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> } = { + cwd: this.root, + }; + if (this.deps.env) { + (spawnOpts as { env?: Readonly<Record<string, string>> }).env = this.deps.env; + } + const proc = this.deps.spawn(this.deps.command as string[], spawnOpts); + this.process = proc; + + const writeFn: WriteFn = (bytes) => proc.stdin.write(bytes); + const rpc = new JsonRpcConnection(writeFn); + this.rpc = rpc; + + this.setupServerHandlers(rpc); + + const stdoutSource = proc.stdout; + if (Symbol.asyncIterator in stdoutSource) { + this.readFromAsyncIterable(stdoutSource as AsyncIterable<Uint8Array>); + } else { + this.readFromEventSource( + stdoutSource as { readonly on: (event: string, cb: (data: Uint8Array) => void) => void }, + ); + } + + await this.initialize(rpc); + this.state = "connected"; + } catch (err: unknown) { + this.state = "error"; + this.stateError = err instanceof Error ? err.message : String(err); + } + } + + private readFromAsyncIterable(source: AsyncIterable<Uint8Array>): void { + (async () => { + try { + for await (const chunk of source) { + this.handleBytes(chunk); + } + } catch { + // process exited + } + })(); + } + + private readFromEventSource(source: { + readonly on: (event: string, cb: (data: Uint8Array) => void) => void; + }): void { + source.on("data", (data: Uint8Array) => { + this.handleBytes(data); + }); + } + + private handleBytes(chunk: Uint8Array): void { + const messages = this.decoder.decode(chunk); + for (const msg of messages) { + this.rpc?.handleMessage(msg); + } + } + + private setupServerHandlers(rpc: JsonRpcConnection): void { + rpc.onNotification("textDocument/publishDiagnostics", (params) => { + this.diagnostics.setPushDiagnostics(params as PublishDiagnosticsParams); + }); + + rpc.onRequest("workspace/configuration", (params) => { + const { items } = params as { readonly items: readonly { readonly section?: string }[] }; + const init = this.deps.initialization ?? {}; + return items.map((item) => { + if (item.section) { + const keys = item.section.split("."); + let value: unknown = init; + for (const key of keys) { + if (value && typeof value === "object" && key in value) { + value = (value as Record<string, unknown>)[key]; + } else { + return undefined; + } + } + return value; + } + return init; + }); + }); + + rpc.onRequest("workspace/workspaceFolders", () => { + return [{ uri: `file://${this.root}`, name: this.root }]; + }); + + rpc.onRequest("window/workDoneProgress/create", () => null); + rpc.onRequest("workspace/diagnostic/refresh", () => null); + + rpc.onRequest("client/registerCapability", (params) => { + const { registrations } = params as { + readonly registrations: readonly { + readonly id: string; + readonly method: string; + readonly registerOptions?: unknown; + }[]; + }; + for (const reg of registrations) { + if (reg.method === "textDocument/diagnostic") { + // Store diagnostic registration (future use) + } else if (reg.method === "workspace/didChangeWatchedFiles") { + const opts = reg.registerOptions as + | import("./watched-files.js").DidChangeWatchedFilesRegistrationOptions + | undefined; + if (opts) { + this.watchedFiles.applyRegister({ + id: reg.id, + method: reg.method, + registerOptions: opts, + }); + } + } + } + return null; + }); + + rpc.onRequest("client/unregisterCapability", (params) => { + const { unregistrations } = params as { + readonly unregistrations: readonly { + readonly id: string; + readonly method: string; + }[]; + }; + for (const unreg of unregistrations) { + this.watchedFiles.applyUnregister(unreg); + } + return null; + }); + } + + private async initialize(rpc: JsonRpcConnection): Promise<void> { + const timeout = 45_000; + + const initPromise = rpc.sendRequest("initialize", { + processId: this.process?.pid ?? null, + rootUri: `file://${this.root}`, + workspaceFolders: [{ uri: `file://${this.root}`, name: this.root }], + capabilities: CLIENT_CAPABILITIES, + }); + + const timeoutPromise = new Promise<never>((_, reject) => { + setTimeout(() => reject(new Error("Initialize timeout")), timeout); + }); + + await Promise.race([initPromise, timeoutPromise]); + + rpc.sendNotification("initialized", {}); + + if (this.deps.initialization) { + rpc.sendNotification("workspace/didChangeConfiguration", { + settings: this.deps.initialization, + }); + } + + this.startFileWatcher(); + } + + private startFileWatcher(): void { + const rootPrefix = this.root.endsWith("/") ? this.root : `${this.root}/`; + this.fileWatcherHandle = this.deps.fileWatcher(this.root, (event) => { + const changeType = + event.type === "create" + ? FileChangeType.Created + : event.type === "delete" + ? FileChangeType.Deleted + : FileChangeType.Changed; + + const relativePath = event.path.startsWith(rootPrefix) + ? event.path.slice(rootPrefix.length) + : event.path.replace(/^\/+/, ""); + + if (this.watchedFiles.matches(relativePath)) { + this.rpc?.sendNotification("workspace/didChangeWatchedFiles", { + changes: [{ uri: `file://${event.path}`, type: changeType }], + }); + } + }); + } + + async open(filePath: string): Promise<void> { + const rpc = this.rpc; + if (!rpc || this.state !== "connected") return; + + const version = (this.openDocuments.get(filePath) ?? 0) + 1; + this.openDocuments.set(filePath, version); + + try { + const text = await this.deps.fs.readText(filePath); + rpc.sendNotification("textDocument/didOpen", { + textDocument: { + uri: `file://${filePath}`, + languageId: "unknown", + version, + text, + }, + }); + } catch { + // file may not exist + } + } + + async waitForDiagnostics(filePath: string, timeoutMs = 10_000): Promise<string> { + await this.open(filePath); + return new Promise<string>((resolve) => { + const start = Date.now(); + const check = () => { + const formatted = this.diagnostics.format(`file://${filePath}`); + if (formatted) { + resolve(formatted); + return; + } + if (Date.now() - start >= timeoutMs) { + resolve(this.diagnostics.format(`file://${filePath}`) || ""); + return; + } + setTimeout(check, 100); + }; + check(); + }); + } + + getWatchedFilesRegistry(): WatchedFilesRegistry { + return this.watchedFiles; + } + + getDiagnosticsStore(): DiagnosticsStore { + return this.diagnostics; + } + + async request(method: string, params?: unknown): Promise<unknown> { + if (!this.rpc || this.state !== "connected") { + throw new Error("Client not connected"); + } + return this.rpc.sendRequest(method, params); + } + + shutdown(): void { + this.fileWatcherHandle?.close(); + this.fileWatcherHandle = null; + this.process?.kill(); + this.process = null; + this.rpc?.dispose(); + this.rpc = null; + this.state = "not-started"; + } +} diff --git a/packages/lsp/src/config.test.ts b/packages/lsp/src/config.test.ts new file mode 100644 index 0000000..1462490 --- /dev/null +++ b/packages/lsp/src/config.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { resolveServers } from "./config.js"; + +describe("config", () => { + it("built-in typescript resolves when tsconfig.json exists", async () => { + const servers = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: null, + exists: async (path) => path === "/project/tsconfig.json", + }); + + const ts = servers.find((s) => s.id === "typescript"); + expect(ts).toBeDefined(); + expect(ts?.command).toEqual(["typescript-language-server", "--stdio"]); + expect(ts?.extensions).toContain(".ts"); + expect(ts?.rootMarkers).toContain("tsconfig.json"); + }); + + it(".dispatch/lsp.json servers resolve", async () => { + const config = JSON.stringify({ + servers: { + mylsp: { + command: ["my-lsp", "--stdio"], + extensions: [".ml"], + rootMarkers: ["Makefile"], + }, + }, + }); + + const servers = await resolveServers({ + cwd: "/project", + dispatchLspJson: config, + opencodeJson: null, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("mylsp"); + expect(servers[0]?.command).toEqual(["my-lsp", "--stdio"]); + }); + + it("opencode.json lsp is used only as fallback", async () => { + const opencodeConfig = JSON.stringify({ + lsp: { + fallback: { + command: ["fallback-lsp"], + extensions: [".fb"], + }, + }, + }); + + const servers = await resolveServers({ + cwd: "/project", + dispatchLspJson: null, + opencodeJson: opencodeConfig, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("fallback"); + }); + + it(".dispatch/lsp.json wins over opencode.json", async () => { + const dispatchConfig = JSON.stringify({ + servers: { primary: { command: ["primary-lsp"], extensions: [".p"] } }, + }); + const opencodeConfig = JSON.stringify({ + lsp: { fallback: { command: ["fallback-lsp"], extensions: [".f"] } }, + }); + + const servers = await resolveServers({ + cwd: "/project", + dispatchLspJson: dispatchConfig, + opencodeJson: opencodeConfig, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.id).toBe("primary"); + }); + + it("luau-lsp sourcemap.autogenerate yields a rojo --watch sidecar", async () => { + const config = JSON.stringify({ + servers: { + luau: { + command: ["luau-lsp", "lsp"], + extensions: [".luau"], + initialization: { + "luau-lsp": { + sourcemap: { + autogenerate: true, + rojoProjectFile: "default.project.json", + }, + }, + }, + }, + }, + }); + + const servers = await resolveServers({ + cwd: "/project", + dispatchLspJson: config, + opencodeJson: null, + exists: async () => false, + }); + + expect(servers).toHaveLength(1); + expect(servers[0]?.sidecar).toBeDefined(); + expect(servers[0]?.sidecar?.command).toEqual([ + "rojo", + "sourcemap", + "default.project.json", + "--watch", + "-o", + "sourcemap.json", + ]); + }); +}); diff --git a/packages/lsp/src/config.ts b/packages/lsp/src/config.ts new file mode 100644 index 0000000..f0c1f3f --- /dev/null +++ b/packages/lsp/src/config.ts @@ -0,0 +1,148 @@ +/** + * PURE config resolution — resolve language server configurations. + * + * Sources, in precedence order: + * 1. cwd/.dispatch/lsp.json servers + * 2. fallback cwd/opencode.json lsp + * 3. the built-in registry + * + * Sidecar auto-detect: if a server's initialization has luau-lsp sourcemap + * with autogenerate=true and a rojoProjectFile, attach a rojo sidecar. + */ + +export interface ResolvedServer { + readonly id: string; + readonly name: string; + readonly command: readonly string[]; + readonly env?: Readonly<Record<string, string>> | undefined; + readonly extensions: readonly string[]; + readonly rootMarkers: readonly string[]; + readonly initialization?: Readonly<Record<string, unknown>> | undefined; + readonly sidecar?: { readonly command: readonly string[] } | undefined; +} + +export interface ServerConfig { + readonly id?: string | undefined; + readonly name?: string | undefined; + readonly command: readonly string[]; + readonly env?: Readonly<Record<string, string>> | undefined; + readonly extensions?: readonly string[] | undefined; + readonly rootMarkers?: readonly string[] | undefined; + readonly initialization?: Readonly<Record<string, unknown>> | undefined; + readonly watch?: readonly string[] | undefined; +} + +export interface LspJsonConfig { + readonly servers?: Readonly<Record<string, ServerConfig>> | undefined; +} + +export interface OpencodeJsonConfig { + readonly lsp?: Readonly<Record<string, ServerConfig>> | undefined; +} + +export interface ResolveServersDeps { + readonly cwd: string; + readonly dispatchLspJson: string | null; + readonly opencodeJson: string | null; + readonly exists: (path: string) => Promise<boolean>; +} + +const BUILT_IN_REGISTRY: Record<string, ResolvedServer> = { + typescript: { + id: "typescript", + name: "TypeScript Language Server", + command: ["typescript-language-server", "--stdio"], + extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], + rootMarkers: ["tsconfig.json", "package.json"], + }, +}; + +export async function resolveServers(deps: ResolveServersDeps): Promise<ResolvedServer[]> { + const result = new Map<string, ResolvedServer>(); + + if (deps.dispatchLspJson) { + try { + const config = JSON.parse(deps.dispatchLspJson) as LspJsonConfig; + if (config.servers) { + for (const [key, server] of Object.entries(config.servers)) { + const resolved = resolveServer(key, server); + result.set(resolved.id, resolved); + } + } + } catch { + // ignore parse errors + } + } + + if (result.size === 0 && deps.opencodeJson) { + try { + const config = JSON.parse(deps.opencodeJson) as OpencodeJsonConfig; + if (config.lsp) { + for (const [key, server] of Object.entries(config.lsp)) { + const resolved = resolveServer(key, server); + result.set(resolved.id, resolved); + } + } + } catch { + // ignore parse errors + } + } + + if (result.size === 0) { + for (const [id, server] of Object.entries(BUILT_IN_REGISTRY)) { + result.set(id, server); + } + } + + return [...result.values()]; +} + +function resolveServer(key: string, config: ServerConfig): ResolvedServer { + const id = config.id ?? key; + const name = config.name ?? id; + const extensions = config.extensions ?? []; + const rootMarkers = config.rootMarkers ?? []; + + let sidecar: { readonly command: readonly string[] } | undefined; + if (config.watch) { + sidecar = { command: config.watch }; + } else if (config.initialization) { + sidecar = detectSidecar(config.initialization); + } + + const result: ResolvedServer = { + id, + name, + command: config.command, + extensions, + rootMarkers, + }; + if (config.env) { + (result as { env?: Readonly<Record<string, string>> }).env = config.env; + } + if (config.initialization) { + (result as { initialization?: Readonly<Record<string, unknown>> }).initialization = + config.initialization; + } + if (sidecar) { + (result as { sidecar?: { readonly command: readonly string[] } }).sidecar = sidecar; + } + return result; +} + +function detectSidecar( + init: Readonly<Record<string, unknown>>, +): { readonly command: readonly string[] } | undefined { + const luauLsp = init["luau-lsp"]; + if (!luauLsp || typeof luauLsp !== "object") return undefined; + const luau = luauLsp as Record<string, unknown>; + const sourcemap = luau.sourcemap; + if (!sourcemap || typeof sourcemap !== "object") return undefined; + const sm = sourcemap as Record<string, unknown>; + if (sm.autogenerate !== true) return undefined; + const rojoProjectFile = sm.rojoProjectFile; + if (typeof rojoProjectFile !== "string") return undefined; + return { + command: ["rojo", "sourcemap", rojoProjectFile, "--watch", "-o", "sourcemap.json"], + }; +} diff --git a/packages/lsp/src/diagnostics.test.ts b/packages/lsp/src/diagnostics.test.ts new file mode 100644 index 0000000..9f2b6b4 --- /dev/null +++ b/packages/lsp/src/diagnostics.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { DiagnosticsStore } from "./diagnostics.js"; + +describe("diagnostics", () => { + it("formats diagnostics with severity, location, and message", () => { + const store = new DiagnosticsStore(); + store.setPushDiagnostics({ + uri: "file:///test.ts", + diagnostics: [ + { + range: { start: { line: 0, character: 5 }, end: { line: 0, character: 10 } }, + severity: 1, + source: "typescript", + message: "Cannot find name 'hello'.", + }, + ], + }); + + const formatted = store.format("file:///test.ts"); + expect(formatted).toContain("ERROR"); + expect(formatted).toContain("L1:6"); + expect(formatted).toContain("Cannot find name 'hello'."); + expect(formatted).toContain("[typescript]"); + }); + + it("merges push and pull diagnostics with deduplication", () => { + const store = new DiagnosticsStore(); + const diag = { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, + severity: 1, + message: "Error", + }; + + store.setPushDiagnostics({ + uri: "file:///test.ts", + diagnostics: [diag], + }); + store.setPullDiagnostics("file:///test.ts", { + kind: "full", + items: [diag], + }); + + const merged = store.getMerged("file:///test.ts"); + expect(merged).toHaveLength(1); + }); + + it("returns empty string when no diagnostics exist", () => { + const store = new DiagnosticsStore(); + expect(store.format("file:///nonexistent.ts")).toBe(""); + }); +}); diff --git a/packages/lsp/src/diagnostics.ts b/packages/lsp/src/diagnostics.ts new file mode 100644 index 0000000..ea18811 --- /dev/null +++ b/packages/lsp/src/diagnostics.ts @@ -0,0 +1,86 @@ +/** + * Diagnostics — merge push (textDocument/publishDiagnostics) + pull + * (textDocument/diagnostic) per file, dedupe, and format. + */ + +export interface Diagnostic { + readonly range: { + readonly start: { readonly line: number; readonly character: number }; + readonly end: { readonly line: number; readonly character: number }; + }; + readonly severity?: number; + readonly source?: string; + readonly message: string; + readonly code?: string | number; +} + +export interface PublishDiagnosticsParams { + readonly uri: string; + readonly diagnostics: readonly Diagnostic[]; +} + +export interface DocumentDiagnosticReport { + readonly kind: "full" | "unchanged"; + readonly items?: readonly Diagnostic[]; +} + +const severityNames: Record<number, string> = { + 1: "ERROR", + 2: "WARNING", + 3: "INFO", + 4: "HINT", +}; + +export class DiagnosticsStore { + private pushDiagnostics = new Map<string, readonly Diagnostic[]>(); + private pullDiagnostics = new Map<string, readonly Diagnostic[]>(); + + setPushDiagnostics(params: PublishDiagnosticsParams): void { + this.pushDiagnostics.set(params.uri, params.diagnostics); + } + + setPullDiagnostics(uri: string, report: DocumentDiagnosticReport): void { + if (report.kind === "full" && report.items) { + this.pullDiagnostics.set(uri, report.items); + } + } + + getMerged(uri: string): readonly Diagnostic[] { + const push = this.pushDiagnostics.get(uri) ?? []; + const pull = this.pullDiagnostics.get(uri) ?? []; + return dedupeDiagnostics([...push, ...pull]); + } + + format(uri: string): string { + const diags = this.getMerged(uri); + if (diags.length === 0) return ""; + const lines: string[] = []; + for (const d of diags) { + const sev = d.severity ? (severityNames[d.severity] ?? "UNKNOWN") : "UNKNOWN"; + const line = d.range.start.line + 1; + const col = d.range.start.character + 1; + const src = d.source ? ` [${d.source}]` : ""; + const code = d.code ? ` (${d.code})` : ""; + lines.push(`${sev}${code}${src} L${line}:${col}: ${d.message}`); + } + return lines.join("\n"); + } +} + +function diagnosticKey(d: Diagnostic): string { + const r = d.range; + return `${r.start.line}:${r.start.character}-${r.end.line}:${r.end.character}:${d.severity ?? 0}:${d.message}`; +} + +function dedupeDiagnostics(diags: readonly Diagnostic[]): readonly Diagnostic[] { + const seen = new Set<string>(); + const result: Diagnostic[] = []; + for (const d of diags) { + const key = diagnosticKey(d); + if (!seen.has(key)) { + seen.add(key); + result.push(d); + } + } + return result; +} diff --git a/packages/lsp/src/extension.ts b/packages/lsp/src/extension.ts new file mode 100644 index 0000000..486f66d --- /dev/null +++ b/packages/lsp/src/extension.ts @@ -0,0 +1,120 @@ +/** + * LSP extension — manifest + activate(host). + * + * Builds the manager with real adapters, registers the lsp tool and + * lspServiceHandle, and wires deactivate to manager.shutdownAll(). + */ + +import type { Extension, HostAPI, ServiceHandle } from "@dispatch/kernel"; +import { defineService } from "@dispatch/kernel"; +import type { SpawnedProcess } from "./client.js"; +import { LspManager } from "./manager.js"; +import { createLspTool } from "./tool.js"; +import type { LspServerStatus, LspService } from "./types.js"; + +export const lspServiceHandle: ServiceHandle<LspService> = defineService<LspService>("lsp"); + +function realSpawn( + command: string[], + opts: { readonly cwd: string; readonly env?: Readonly<Record<string, string>> | undefined }, +): SpawnedProcess { + const env: Record<string, string | undefined> = { ...process.env }; + if (opts.env) { + for (const [key, value] of Object.entries(opts.env)) { + env[key] = value; + } + } + const proc = Bun.spawn(command, { + cwd: opts.cwd, + env: env as Record<string, string>, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + return { + stdin: proc.stdin, + stdout: proc.stdout, + stderr: proc.stderr, + pid: proc.pid, + kill: () => proc.kill(), + }; +} + +function realFileWatcher( + root: string, + onEvent: (e: { readonly type: "create" | "change" | "delete"; readonly path: string }) => void, +): { readonly close: () => void } { + const { watch } = require("node:fs"); + const watcher = watch(root, { recursive: true }, (eventType: string, filename: string | null) => { + if (!filename) return; + const fullPath = root.endsWith("/") ? `${root}${filename}` : `${root}/${filename}`; + const type = eventType === "rename" ? "create" : "change"; + onEvent({ type, path: fullPath }); + }); + return { close: () => watcher.close() }; +} + +function realFs() { + return { + readText: async (path: string) => { + const file = Bun.file(path); + return file.text(); + }, + exists: async (path: string) => { + const file = Bun.file(path); + return file.exists(); + }, + }; +} + +export const extension: Extension = { + manifest: { + id: "lsp", + name: "Language Server Protocol", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { spawn: true, fs: true }, + contributes: { tools: ["lsp"], services: ["lsp"] }, + }, + activate(host: HostAPI) { + const logger = host.logger; + + const manager = new LspManager({ + spawn: realSpawn, + fileWatcher: realFileWatcher, + fs: realFs(), + logger: { + info: (msg, attrs) => + logger.info(msg, attrs as Record<string, string | number | boolean | null> | undefined), + warn: (msg, attrs) => + logger.warn(msg, attrs as Record<string, string | number | boolean | null> | undefined), + error: (msg, attrs) => logger.error(msg, attrs as Record<string, unknown> | undefined), + }, + }); + + const lspTool = createLspTool(manager); + host.defineTool(lspTool); + + const service: LspService = { + async status(cwd: string): Promise<readonly LspServerStatus[]> { + return manager.status(cwd); + }, + }; + host.provideService(lspServiceHandle, service); + + host.logger.info("LSP extension activated"); + + // Store manager for deactivate + (lspManagerStore as { manager: LspManager | null }).manager = manager; + }, + deactivate() { + const store = lspManagerStore as { manager: LspManager | null }; + store.manager?.shutdownAll(); + store.manager = null; + }, +}; + +// Module-scoped store for deactivate +const lspManagerStore: { manager: LspManager | null } = { manager: null }; diff --git a/packages/lsp/src/framing.test.ts b/packages/lsp/src/framing.test.ts new file mode 100644 index 0000000..7c51a16 --- /dev/null +++ b/packages/lsp/src/framing.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { encode, FrameDecoder } from "./framing.js"; + +describe("framing", () => { + it("encode/decode round-trips", () => { + const msg = JSON.stringify({ jsonrpc: "2.0", method: "test", params: { a: 1 } }); + const encoded = encode(msg); + const decoder = new FrameDecoder(); + const messages = decoder.decode(encoded); + expect(messages).toHaveLength(1); + expect(messages[0]).toBe(msg); + }); + + it("decoder reassembles a frame split across two chunks", () => { + const msg = JSON.stringify({ jsonrpc: "2.0", method: "test" }); + const encoded = encode(msg); + const mid = Math.floor(encoded.length / 2); + const chunk1 = encoded.slice(0, mid); + const chunk2 = encoded.slice(mid); + + const decoder = new FrameDecoder(); + const result1 = decoder.decode(chunk1); + expect(result1).toHaveLength(0); + + const result2 = decoder.decode(chunk2); + expect(result2).toHaveLength(1); + expect(result2[0]).toBe(msg); + }); + + it("decoder yields two messages from one chunk", () => { + const msg1 = JSON.stringify({ jsonrpc: "2.0", method: "a" }); + const msg2 = JSON.stringify({ jsonrpc: "2.0", method: "b" }); + const encoded1 = encode(msg1); + const encoded2 = encode(msg2); + + const combined = new Uint8Array(encoded1.length + encoded2.length); + combined.set(encoded1); + combined.set(encoded2, encoded1.length); + + const decoder = new FrameDecoder(); + const messages = decoder.decode(combined); + expect(messages).toHaveLength(2); + expect(messages[0]).toBe(msg1); + expect(messages[1]).toBe(msg2); + }); +}); diff --git a/packages/lsp/src/framing.ts b/packages/lsp/src/framing.ts new file mode 100644 index 0000000..3a8ab3a --- /dev/null +++ b/packages/lsp/src/framing.ts @@ -0,0 +1,67 @@ +/** + * LSP Content-Length framing codec. + * + * The LSP base protocol uses Content-Length headers to frame JSON messages. + * `encode` wraps a JSON message with headers; `FrameDecoder` reassembles + * complete messages from streaming byte chunks (handles partial frames and + * multiple frames per chunk). + */ + +const HEADER_SEP = "\r\n\r\n"; +const CONTENT_LENGTH_RE = /^Content-Length:\s*(\d+)/i; + +export function encode(msg: string): Uint8Array { + const body = new TextEncoder().encode(msg); + const header = `Content-Length: ${body.length}\r\n\r\n`; + const frame = new TextEncoder().encode(header); + const result = new Uint8Array(frame.length + body.length); + result.set(frame); + result.set(body, frame.length); + return result; +} + +export class FrameDecoder { + private buffer = ""; + private expectedLength: number | null = null; + private headerEnd = -1; + + /** + * Feed raw bytes into the decoder. Returns all complete JSON messages + * that can be extracted from the accumulated buffer. + */ + decode(chunk: Uint8Array): string[] { + this.buffer += new TextDecoder().decode(chunk); + const messages: string[] = []; + + while (true) { + if (this.expectedLength === null) { + const headerEnd = this.buffer.indexOf(HEADER_SEP); + if (headerEnd === -1) break; + + const headerPart = this.buffer.slice(0, headerEnd); + const match = CONTENT_LENGTH_RE.exec(headerPart); + if (!match?.[1]) { + this.buffer = this.buffer.slice(headerEnd + HEADER_SEP.length); + continue; + } + this.expectedLength = Number.parseInt(match[1], 10); + this.headerEnd = headerEnd; + } + + const bodyStart = this.headerEnd + HEADER_SEP.length; + const available = this.buffer.length - bodyStart; + + if (available >= this.expectedLength) { + const body = this.buffer.slice(bodyStart, bodyStart + this.expectedLength); + messages.push(body); + this.buffer = this.buffer.slice(bodyStart + this.expectedLength); + this.expectedLength = null; + this.headerEnd = -1; + } else { + break; + } + } + + return messages; + } +} diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts new file mode 100644 index 0000000..19f824b --- /dev/null +++ b/packages/lsp/src/index.ts @@ -0,0 +1,29 @@ +export type { + ClientDeps, + FileWatcher, + FileWatcherHandle, + FsAccess, + SpawnedProcess, + SpawnProcess, +} from "./client.js"; +export { LanguageServerClient } from "./client.js"; +export type { LspJsonConfig, OpencodeJsonConfig, ResolvedServer, ServerConfig } from "./config.js"; +export { resolveServers } from "./config.js"; +export type { + Diagnostic, + DocumentDiagnosticReport, + PublishDiagnosticsParams, +} from "./diagnostics.js"; +export { DiagnosticsStore } from "./diagnostics.js"; +export { extension, lspServiceHandle } from "./extension.js"; +export { encode, FrameDecoder } from "./framing.js"; +export { languageId } from "./language.js"; +export type { Logger, ManagerDeps } from "./manager.js"; +export { LspManager } from "./manager.js"; +export { findRoot } from "./root.js"; +export type { JsonRpcMessage, NotificationHandler, RequestHandler, WriteFn } from "./rpc.js"; +export { JsonRpcConnection } from "./rpc.js"; +export { createLspTool } from "./tool.js"; +export type { LspServerState, LspServerStatus, LspService } from "./types.js"; +export type { FileChangeTypeValue, FileSystemWatcher, Registration } from "./watched-files.js"; +export { FileChangeType, globMatch, WatchedFilesRegistry } from "./watched-files.js"; diff --git a/packages/lsp/src/language.test.ts b/packages/lsp/src/language.test.ts new file mode 100644 index 0000000..3ec4b47 --- /dev/null +++ b/packages/lsp/src/language.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { languageId } from "./language.js"; + +describe("language", () => { + it("maps .ts to typescript", () => { + expect(languageId("file.ts")).toBe("typescript"); + }); + + it("maps .tsx to typescriptreact", () => { + expect(languageId("file.tsx")).toBe("typescriptreact"); + }); + + it("maps .js to javascript", () => { + expect(languageId("file.js")).toBe("javascript"); + }); + + it("maps .luau to luau", () => { + expect(languageId("file.luau")).toBe("luau"); + }); + + it("returns unknown for unrecognized extensions", () => { + expect(languageId("file.xyz")).toBe("unknown"); + }); + + it("returns unknown for files without extensions", () => { + expect(languageId("Makefile")).toBe("unknown"); + }); +}); diff --git a/packages/lsp/src/language.ts b/packages/lsp/src/language.ts new file mode 100644 index 0000000..214294e --- /dev/null +++ b/packages/lsp/src/language.ts @@ -0,0 +1,36 @@ +/** + * Language ID mapping from file extensions. + */ + +const extensionMap: Record<string, string> = { + ".ts": "typescript", + ".tsx": "typescriptreact", + ".mts": "typescript", + ".cts": "typescript", + ".js": "javascript", + ".jsx": "javascriptreact", + ".mjs": "javascript", + ".cjs": "javascript", + ".json": "json", + ".lua": "lua", + ".luau": "luau", + ".py": "python", + ".rs": "rust", + ".go": "go", + ".md": "markdown", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".css": "css", + ".html": "html", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", +}; + +export function languageId(filePath: string): string { + const dotIdx = filePath.lastIndexOf("."); + if (dotIdx === -1) return "unknown"; + const ext = filePath.slice(dotIdx).toLowerCase(); + return extensionMap[ext] ?? "unknown"; +} diff --git a/packages/lsp/src/manager.test.ts b/packages/lsp/src/manager.test.ts new file mode 100644 index 0000000..3e8e60e --- /dev/null +++ b/packages/lsp/src/manager.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import type { FileWatcher, FsAccess, SpawnedProcess, SpawnProcess } from "./client.js"; +import { encode } from "./framing.js"; +import { LspManager } from "./manager.js"; + +function makeAutoHandshakeSpawn(): SpawnProcess { + return () => { + let messageHandler: ((data: Uint8Array) => void) | null = null; + + const proc: SpawnedProcess = { + stdin: { + write: (bytes: Uint8Array) => { + // Parse the message to handle initialize handshake + const decoded = new TextDecoder().decode(bytes); + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const json = decoded.slice(headerEnd + 4); + try { + const msg = JSON.parse(json); + if (msg.method === "initialize") { + // Send back initialize response + setTimeout(() => { + const response = JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { capabilities: {} }, + }); + messageHandler?.(encode(response)); + }, 5); + } + // Ignore other messages + } catch { + // ignore parse errors + } + }, + }, + stdout: { + on: (_event: string, cb: (data: Uint8Array) => void) => { + messageHandler = cb; + }, + }, + pid: 12345, + kill: () => {}, + }; + return proc; + }; +} + +function noopFileWatcher(): FileWatcher { + return () => ({ close: () => {} }); +} + +function fakeFs(files: Record<string, string> = {}): FsAccess { + return { + readText: async (path) => files[path] ?? "", + exists: async (path) => path in files, + }; +} + +describe("manager", () => { + it("status(cwd) lazy-spawns matching servers and reports connected", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp", "--stdio"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const statuses = await manager.status("/project"); + expect(statuses).toHaveLength(1); + expect(statuses[0]?.id).toBe("test"); + expect(statuses[0]?.state).toBe("connected"); + expect(statuses[0]?.root).toBe("/project"); + }, 10000); + + it("concurrent status for the same root spawns one process", async () => { + let spawnCount = 0; + const countingSpawn: SpawnProcess = () => { + spawnCount++; + return makeAutoHandshakeSpawn()([], { cwd: "/project" }); + }; + + const manager = new LspManager({ + spawn: countingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const [s1, s2] = await Promise.all([manager.status("/project"), manager.status("/project")]); + + expect(s1).toHaveLength(1); + expect(s2).toHaveLength(1); + expect(spawnCount).toBe(1); + }, 10000); + + it("a failing spawn reports state:error and is not retried", async () => { + let spawnCount = 0; + const failingSpawn: SpawnProcess = () => { + spawnCount++; + throw new Error("spawn failed"); + }; + + const manager = new LspManager({ + spawn: failingSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + const s1 = await manager.status("/project"); + expect(s1[0]?.state).toBe("error"); + expect(spawnCount).toBe(1); + + // Second call should not retry + const s2 = await manager.status("/project"); + expect(s2[0]?.state).toBe("error"); + expect(spawnCount).toBe(1); + }); + + it("shutdownAll kills all spawned processes (incl. sidecars)", async () => { + let killed = false; + const trackableSpawn: SpawnProcess = () => { + const proc = makeAutoHandshakeSpawn()([], { cwd: "/project" }); + return { + ...proc, + kill: () => { + killed = true; + }, + }; + }; + + const manager = new LspManager({ + spawn: trackableSpawn, + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/project/tsconfig.json": "{}", + "/project/.dispatch/lsp.json": JSON.stringify({ + servers: { + test: { + command: ["test-lsp"], + extensions: [".ts"], + rootMarkers: ["tsconfig.json"], + }, + }, + }), + }), + }); + + await manager.status("/project"); + manager.shutdownAll(); + expect(killed).toBe(true); + }, 10000); + + it("resolves config per cwd (distinct cwds, opencode.json fallback)", async () => { + const manager = new LspManager({ + spawn: makeAutoHandshakeSpawn(), + fileWatcher: noopFileWatcher(), + fs: fakeFs({ + "/proj-a/.dispatch/lsp.json": JSON.stringify({ + servers: { a: { command: ["a-lsp"], extensions: [".a"], rootMarkers: [] } }, + }), + "/proj-b/opencode.json": JSON.stringify({ + lsp: { b: { command: ["b-lsp"], extensions: [".b"] } }, + }), + }), + }); + + const a = await manager.status("/proj-a"); + const b = await manager.status("/proj-b"); + expect(a.map((s) => s.id)).toEqual(["a"]); + expect(b.map((s) => s.id)).toEqual(["b"]); + }, 10000); +}); diff --git a/packages/lsp/src/manager.ts b/packages/lsp/src/manager.ts new file mode 100644 index 0000000..7080c65 --- /dev/null +++ b/packages/lsp/src/manager.ts @@ -0,0 +1,215 @@ +/** + * Manager — lazy-spawn one client per (serverID, root); dedup concurrent + * spawns; track a broken set (no retry storm); status(cwd); shutdownAll(). + */ + +import { join } from "node:path"; +import { + type ClientDeps, + type FileWatcher, + type FsAccess, + LanguageServerClient, + type SpawnProcess, +} from "./client.js"; +import { type ResolvedServer, resolveServers } from "./config.js"; +import { findRoot } from "./root.js"; +import type { LspServerState, LspServerStatus } from "./types.js"; + +export type Logger = { + readonly info: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void; + readonly warn: (msg: string, attrs?: Record<string, string | number | boolean | null>) => void; + readonly error: (msg: string, attrs?: Record<string, unknown>) => void; +}; + +export interface ManagerDeps { + readonly spawn: SpawnProcess; + readonly fileWatcher: FileWatcher; + readonly fs: FsAccess; + readonly logger?: Logger | undefined; +} + +type ClientEntry = { + readonly client: LanguageServerClient; + readonly server: ResolvedServer; + readonly promise: Promise<void>; +}; + +export class LspManager { + private clients = new Map<string, ClientEntry>(); + private broken = new Set<string>(); + private spawning = new Map<string, Promise<void>>(); + private deps: ManagerDeps; + + constructor(deps: ManagerDeps) { + this.deps = deps; + } + + async status(cwd: string): Promise<readonly LspServerStatus[]> { + // Config is resolved PER cwd: a different conversation cwd (e.g. a Roblox + // project) gets its own .dispatch/lsp.json or opencode.json, not a global one. + const dispatchLspJson = await this.readOrNull(join(cwd, ".dispatch", "lsp.json")); + const opencodeJson = await this.readOrNull(join(cwd, "opencode.json")); + const servers = await resolveServers({ + cwd, + dispatchLspJson, + opencodeJson, + exists: this.deps.fs.exists, + }); + + const results: LspServerStatus[] = []; + + for (const server of servers) { + const root = await findRoot(cwd, cwd, server.rootMarkers, this.deps.fs.exists); + const key = `${server.id}:${root}`; + + if (this.broken.has(key)) { + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: "error", + error: "Previously failed to start", + }; + results.push(status); + continue; + } + + const existing = this.clients.get(key); + if (existing) { + const state = existing.client.getState(); + const stateError = existing.client.getStateError(); + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: mapState(state), + }; + if (stateError !== undefined) { + (status as { error?: string }).error = stateError; + } + results.push(status); + continue; + } + + try { + await this.spawnClient(server, root, key); + const entry = this.clients.get(key); + if (entry) { + const state = entry.client.getState(); + const stateError = entry.client.getStateError(); + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: mapState(state), + }; + if (stateError !== undefined) { + (status as { error?: string }).error = stateError; + } + results.push(status); + } + } catch (err: unknown) { + this.broken.add(key); + const status: LspServerStatus = { + id: server.id, + name: server.name, + root, + extensions: server.extensions, + state: "error", + error: err instanceof Error ? err.message : String(err), + }; + results.push(status); + } + } + + return results; + } + + getClient(serverId: string, root: string): LanguageServerClient | undefined { + const key = `${serverId}:${root}`; + return this.clients.get(key)?.client; + } + + /** Read a config file's contents, or null if it is absent/unreadable. */ + private async readOrNull(path: string): Promise<string | null> { + if (!(await this.deps.fs.exists(path))) return null; + try { + return await this.deps.fs.readText(path); + } catch { + return null; + } + } + + private async spawnClient(server: ResolvedServer, root: string, key: string): Promise<void> { + const existingSpawn = this.spawning.get(key); + if (existingSpawn) return existingSpawn; + + const spawnPromise = this.doSpawn(server, root, key); + this.spawning.set(key, spawnPromise); + + try { + await spawnPromise; + } finally { + this.spawning.delete(key); + } + } + + private async doSpawn(server: ResolvedServer, root: string, key: string): Promise<void> { + const clientDeps: ClientDeps = { + spawn: this.deps.spawn, + fileWatcher: this.deps.fileWatcher, + fs: this.deps.fs, + command: server.command, + root, + serverId: server.id, + }; + if (server.env) { + (clientDeps as { env?: Readonly<Record<string, string>> }).env = server.env; + } + if (server.initialization) { + (clientDeps as { initialization?: Readonly<Record<string, unknown>> }).initialization = + server.initialization; + } + + const client = new LanguageServerClient(clientDeps); + const entry: ClientEntry = { + client, + server, + promise: client.start(), + }; + + this.clients.set(key, entry); + await entry.promise; + + if (client.getState() === "error") { + this.broken.add(key); + this.deps.logger?.warn("LSP server failed to start", { + serverId: server.id, + root, + error: client.getStateError() ?? "unknown", + }); + } else { + this.deps.logger?.info("LSP server connected", { + serverId: server.id, + root, + }); + } + } + + shutdownAll(): void { + for (const [key, entry] of this.clients) { + entry.client.shutdown(); + this.deps.logger?.info("LSP server shutdown", { key }); + } + this.clients.clear(); + this.broken.clear(); + this.spawning.clear(); + } +} + +function mapState(state: LspServerState): LspServerState { + return state; +} diff --git a/packages/lsp/src/root.test.ts b/packages/lsp/src/root.test.ts new file mode 100644 index 0000000..ffe2e31 --- /dev/null +++ b/packages/lsp/src/root.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { findRoot } from "./root.js"; + +describe("root", () => { + it("findRoot returns nearest marker ancestor bounded by cwd", async () => { + const existingFiles = new Set([ + "/project/src/components/tsconfig.json", + "/project/tsconfig.json", + ]); + + const result = await findRoot( + "/project/src/components/widgets", + "/project", + ["tsconfig.json"], + async (path) => existingFiles.has(path), + ); + + expect(result).toBe("/project/src/components"); + }); + + it("falls back to cwd", async () => { + const result = await findRoot( + "/project/src/deep/nested", + "/project", + ["tsconfig.json"], + async () => false, + ); + + expect(result).toBe("/project"); + }); + + it("finds marker at start directory", async () => { + const existingFiles = new Set(["/project/tsconfig.json"]); + + const result = await findRoot("/project", "/project", ["tsconfig.json"], async (path) => + existingFiles.has(path), + ); + + expect(result).toBe("/project"); + }); + + it("respects cwd boundary", async () => { + const existingFiles = new Set(["/tsconfig.json"]); + + const result = await findRoot("/project/src", "/project", ["tsconfig.json"], async (path) => + existingFiles.has(path), + ); + + expect(result).toBe("/project"); + }); +}); diff --git a/packages/lsp/src/root.ts b/packages/lsp/src/root.ts new file mode 100644 index 0000000..fc7814e --- /dev/null +++ b/packages/lsp/src/root.ts @@ -0,0 +1,44 @@ +/** + * Root finder — nearest ancestor containing a marker file, bounded at cwd. + */ + +export async function findRoot( + startDir: string, + cwd: string, + markers: readonly string[], + exists: (path: string) => Promise<boolean>, +): Promise<string> { + const normalizedStart = normalizePath(startDir); + const normalizedCwd = normalizePath(cwd); + + let current = normalizedStart; + while (true) { + for (const marker of markers) { + const markerPath = current === "/" ? `/${marker}` : `${current}/${marker}`; + if (await exists(markerPath)) { + return current; + } + } + if (current === normalizedCwd || current === "/") { + return normalizedCwd; + } + const parent = getParent(current); + if (parent === current) return normalizedCwd; + current = parent; + } +} + +function normalizePath(p: string): string { + let normalized = p.replace(/\\/g, "/"); + if (normalized.length > 1 && normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + return normalized || "/"; +} + +function getParent(p: string): string { + if (p === "/") return "/"; + const lastSlash = p.lastIndexOf("/"); + if (lastSlash <= 0) return "/"; + return p.slice(0, lastSlash) || "/"; +} diff --git a/packages/lsp/src/rpc.test.ts b/packages/lsp/src/rpc.test.ts new file mode 100644 index 0000000..a03870f --- /dev/null +++ b/packages/lsp/src/rpc.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { JsonRpcConnection } from "./rpc.js"; + +function makeConnection(): { conn: JsonRpcConnection; messages: string[] } { + const messages: string[] = []; + const conn = new JsonRpcConnection((bytes) => { + const decoded = new TextDecoder().decode(bytes); + // Extract JSON from the LSP-framed message + const headerEnd = decoded.indexOf("\r\n\r\n"); + if (headerEnd !== -1) { + messages.push(decoded.slice(headerEnd + 4)); + } + }); + return { conn, messages }; +} + +function frameResponse(id: number, result: unknown): string { + return JSON.stringify({ jsonrpc: "2.0", id, result }); +} + +describe("rpc", () => { + it("sendRequest resolves by matching id", async () => { + const { conn, messages } = makeConnection(); + + const promise = conn.sendRequest("test/method", { key: "value" }); + expect(messages).toHaveLength(1); + + const rawSent = messages[0]; + if (rawSent === undefined) throw new Error("expected a sent message"); + const sent = JSON.parse(rawSent); + expect(sent.method).toBe("test/method"); + expect(sent.params).toEqual({ key: "value" }); + expect(sent.id).toBe(1); + + conn.handleMessage(frameResponse(1, { ok: true })); + const result = await promise; + expect(result).toEqual({ ok: true }); + }); + + it("onNotification dispatches by method", () => { + const { conn } = makeConnection(); + let received: unknown = null; + conn.onNotification("test/notify", (params) => { + received = params; + }); + + conn.handleMessage( + JSON.stringify({ jsonrpc: "2.0", method: "test/notify", params: { data: 42 } }), + ); + expect(received).toEqual({ data: 42 }); + }); + + it("onRequest replies to a server-to-client request", async () => { + const { conn, messages } = makeConnection(); + + conn.onRequest("workspace/configuration", (params) => { + const { items } = params as { readonly items: readonly { readonly section?: string }[] }; + return items.map(() => ({ setting: true })); + }); + + await conn.handleMessage( + JSON.stringify({ + jsonrpc: "2.0", + id: 100, + method: "workspace/configuration", + params: { items: [{ section: "test" }] }, + }), + ); + + // The response should be sent back + expect(messages).toHaveLength(1); + const rawResponse = messages[0]; + if (rawResponse === undefined) throw new Error("expected a response message"); + const response = JSON.parse(rawResponse); + expect(response.id).toBe(100); + expect(response.result).toEqual([{ setting: true }]); + }); +}); diff --git a/packages/lsp/src/rpc.ts b/packages/lsp/src/rpc.ts new file mode 100644 index 0000000..45adf42 --- /dev/null +++ b/packages/lsp/src/rpc.ts @@ -0,0 +1,132 @@ +/** + * JSON-RPC connection over an injected write function. + * + * Provides sendRequest (correlated by id), sendNotification, onRequest, + * and onNotification. The caller feeds decoded JSON messages via `handleMessage`. + */ + +import { encode } from "./framing.js"; + +export type WriteFn = (bytes: Uint8Array) => void; + +export interface PendingRequest { + readonly resolve: (value: unknown) => void; + readonly reject: (reason: unknown) => void; +} + +export type RequestHandler = (params: unknown) => unknown | Promise<unknown>; +export type NotificationHandler = (params: unknown) => void; + +export interface JsonRpcMessage { + readonly jsonrpc: "2.0"; + readonly id?: number | string | undefined; + readonly method?: string | undefined; + readonly params?: unknown; + readonly result?: unknown; + readonly error?: + | { readonly code: number; readonly message: string; readonly data?: unknown } + | undefined; +} + +export class JsonRpcConnection { + private nextId = 1; + private pending = new Map<number | string, PendingRequest>(); + private requestHandlers = new Map<string, RequestHandler>(); + private notificationHandlers = new Map<string, NotificationHandler>(); + private write: WriteFn; + + constructor(write: WriteFn) { + this.write = write; + } + + sendRequest(method: string, params?: unknown): Promise<unknown> { + const id = this.nextId++; + const msg: JsonRpcMessage = { jsonrpc: "2.0", id, method, params }; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.sendMessage(msg); + }); + } + + sendNotification(method: string, params?: unknown): void { + const msg: JsonRpcMessage = { jsonrpc: "2.0", method, params }; + this.sendMessage(msg); + } + + onRequest(method: string, handler: RequestHandler): void { + this.requestHandlers.set(method, handler); + } + + onNotification(method: string, handler: NotificationHandler): void { + this.notificationHandlers.set(method, handler); + } + + async handleMessage(json: string): Promise<void> { + const msg = JSON.parse(json) as JsonRpcMessage; + const { id, method } = msg; + + if (id !== undefined && method !== undefined) { + await this.handleIncomingRequest(id, method, msg.params); + } else if (method !== undefined) { + this.handleIncomingNotification(method, msg.params); + } else if (id !== undefined) { + this.handleResponse(id, msg); + } + } + + private sendMessage(msg: JsonRpcMessage): void { + this.write(encode(JSON.stringify(msg))); + } + + private handleResponse(id: number | string, msg: JsonRpcMessage): void { + const entry = this.pending.get(id); + if (!entry) return; + this.pending.delete(id); + if (msg.error) { + entry.reject(new Error(msg.error.message)); + } else { + entry.resolve(msg.result); + } + } + + private async handleIncomingRequest( + id: number | string, + method: string, + params: unknown, + ): Promise<void> { + const handler = this.requestHandlers.get(method); + if (!handler) { + this.sendMessage({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }); + return; + } + try { + const result = await handler(params); + this.sendMessage({ jsonrpc: "2.0", id, result }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.sendMessage({ + jsonrpc: "2.0", + id, + error: { code: -32603, message }, + }); + } + } + + private handleIncomingNotification(method: string, params: unknown): void { + const handler = this.notificationHandlers.get(method); + if (handler) { + handler(params); + } + } + + dispose(): void { + for (const entry of this.pending.values()) { + entry.reject(new Error("Connection closed")); + } + this.pending.clear(); + } +} diff --git a/packages/lsp/src/tool.test.ts b/packages/lsp/src/tool.test.ts new file mode 100644 index 0000000..03787ae --- /dev/null +++ b/packages/lsp/src/tool.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import type { LspManager } from "./manager.js"; +import { createLspTool } from "./tool.js"; + +function stubManager(overrides?: Partial<LspManager>): LspManager { + return { + status: async () => [], + getClient: () => undefined, + shutdownAll: () => {}, + ...overrides, + } as unknown as LspManager; +} + +describe("tool", () => { + it("diagnostics formats errors", async () => { + const tool = createLspTool(stubManager()); + const result = await tool.execute( + { operation: "diagnostics", path: "test.ts" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); + expect(result.isError).toBe(true); + expect(result.content).toContain("No language server configured"); + }); + + it("position ops convert 1-based to 0-based", async () => { + let receivedPosition: { line: number; character: number } | null = null; + + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async (method: string, params: unknown) => { + if (method === "textDocument/hover") { + receivedPosition = (params as { position: { line: number; character: number } }).position; + return { contents: { value: "hover result" } }; + } + return null; + }, + waitForDiagnostics: async () => "", + }; + + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); + + const result = await tool.execute( + { operation: "hover", path: "test.ts", line: 5, character: 10 }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); + + expect(receivedPosition).toEqual({ line: 4, character: 9 }); + expect(result.content).toBe("hover result"); + }); + + it("path resolved against ctx.cwd", async () => { + let receivedUri: string | null = null; + + const mockClient = { + getState: () => "connected" as const, + getStateError: () => undefined, + request: async (method: string, params: unknown) => { + if (method === "textDocument/hover") { + receivedUri = (params as { textDocument: { uri: string } }).textDocument.uri; + return { contents: { value: "ok" } }; + } + return null; + }, + waitForDiagnostics: async () => "", + }; + + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + getClient: () => mockClient as never, + }), + ); + + await tool.execute( + { operation: "hover", path: "src/test.ts", line: 1, character: 1 }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); + + expect(receivedUri).toBe("file:///project/src/test.ts"); + }); + + it("position op without line/character returns isError", async () => { + const tool = createLspTool( + stubManager({ + status: async () => [ + { + id: "ts", + name: "TypeScript", + root: "/project", + extensions: [".ts"], + state: "connected", + }, + ], + }), + ); + + const result = await tool.execute( + { operation: "hover", path: "test.ts" }, + { + toolCallId: "test", + onOutput: () => {}, + signal: AbortSignal.timeout(5000), + log: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => ({}) as never, + span: () => ({}) as never, + }, + cwd: "/project", + }, + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("requires both"); + }); +}); diff --git a/packages/lsp/src/tool.ts b/packages/lsp/src/tool.ts new file mode 100644 index 0000000..bc4b41f --- /dev/null +++ b/packages/lsp/src/tool.ts @@ -0,0 +1,225 @@ +/** + * The lsp tool — model-facing tool contract. + * + * Operations: diagnostics, hover, definition, references, documentSymbol. + */ + +import { resolve } from "node:path"; +import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; +import type { LspManager } from "./manager.js"; + +type Operation = "diagnostics" | "hover" | "definition" | "references" | "documentSymbol"; + +const POSITION_OPS: ReadonlySet<string> = new Set(["hover", "definition", "references"]); + +interface ValidatedArgs { + readonly operation: Operation; + readonly path: string; + readonly line?: number | undefined; + readonly character?: number | undefined; +} + +function validateArgs(args: unknown): { readonly error: string } | ValidatedArgs { + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object." }; + } + const obj = args as Record<string, unknown>; + + const rawOp = obj.operation; + if (typeof rawOp !== "string") { + return { error: 'Error: Missing "operation" parameter (must be a string).' }; + } + const validOps: ReadonlySet<string> = new Set([ + "diagnostics", + "hover", + "definition", + "references", + "documentSymbol", + ]); + if (!validOps.has(rawOp)) { + return { + error: `Error: Invalid operation "${rawOp}". Must be one of: diagnostics, hover, definition, references, documentSymbol.`, + }; + } + const operation = rawOp as Operation; + + const rawPath = obj.path; + if (typeof rawPath !== "string" || rawPath.trim().length === 0) { + return { error: 'Error: Missing or empty "path" parameter (must be a non-empty string).' }; + } + + let line: number | undefined; + let character: number | undefined; + + if (obj.line !== undefined) { + const n = Number(obj.line); + if (!Number.isFinite(n) || n < 1) { + return { error: 'Error: Invalid "line" parameter (must be a positive number, 1-based).' }; + } + line = Math.floor(n); + } + + if (obj.character !== undefined) { + const n = Number(obj.character); + if (!Number.isFinite(n) || n < 1) { + return { + error: 'Error: Invalid "character" parameter (must be a positive number, 1-based).', + }; + } + character = Math.floor(n); + } + + const result: ValidatedArgs = { operation, path: rawPath }; + if (line !== undefined) { + (result as { line?: number }).line = line; + } + if (character !== undefined) { + (result as { character?: number }).character = character; + } + return result; +} + +function resolveFilePath(filePath: string, cwd: string): string { + const resolved = resolve(cwd, filePath); + const normalizedCwd = resolve(cwd); + if (!resolved.startsWith(normalizedCwd)) { + return normalizedCwd; + } + return resolved; +} + +/** Convert validated 1-based line/character to an LSP 0-based position. */ +function toPosition( + line: number | undefined, + character: number | undefined, +): { readonly line: number; readonly character: number } { + if (line === undefined || character === undefined) { + throw new Error("Position operations require both line and character."); + } + return { line: line - 1, character: character - 1 }; +} + +export function createLspTool(manager: LspManager): ToolContract { + return { + name: "lsp", + description: + "Query language servers for diagnostics, hover information, symbol definitions, references, and document symbols.", + parameters: { + type: "object", + properties: { + operation: { + type: "string", + enum: ["diagnostics", "hover", "definition", "references", "documentSymbol"], + description: "The LSP operation to perform.", + }, + path: { + type: "string", + description: "File path relative to the workspace.", + }, + line: { + type: "number", + description: "Line number (1-based). Required for hover, definition, references.", + }, + character: { + type: "number", + description: "Character position (1-based). Required for hover, definition, references.", + }, + }, + required: ["operation", "path"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> { + const validated = validateArgs(args); + if ("error" in validated) { + return { content: validated.error, isError: true }; + } + + const { operation, path: filePath, line, character } = validated; + const cwd = ctx.cwd ?? process.cwd(); + const absolutePath = resolveFilePath(filePath, cwd); + + if (POSITION_OPS.has(operation)) { + if (line === undefined || character === undefined) { + return { + content: `Error: "${operation}" requires both "line" and "character" parameters (1-based).`, + isError: true, + }; + } + } + + try { + const statuses = await manager.status(cwd); + if (statuses.length === 0) { + return { content: "No language server configured for this workspace.", isError: true }; + } + + const connected = statuses.find((s) => s.state === "connected"); + if (!connected) { + const first = statuses[0]; + const detail = first + ? `"${first.name}" is not connected (state: ${first.state})` + : "is not connected"; + return { + content: `Language server ${detail}.`, + isError: true, + }; + } + + // Find the client for this server + const client = manager.getClient(connected.id, connected.root); + if (!client) { + return { content: "Language server client not available.", isError: true }; + } + + switch (operation) { + case "diagnostics": { + const diags = await client.waitForDiagnostics(absolutePath); + return { content: diags || "No diagnostics found." }; + } + case "hover": { + const result = await client.request("textDocument/hover", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + }); + if (!result) return { content: "No hover information available." }; + const hover = result as { readonly contents?: { readonly value?: string } | string }; + const content = + typeof hover.contents === "string" + ? hover.contents + : (hover.contents?.value ?? "No hover information available."); + return { content }; + } + case "definition": { + const result = await client.request("textDocument/definition", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + }); + if (!result) return { content: "No definition found." }; + return { content: JSON.stringify(result) }; + } + case "references": { + const result = await client.request("textDocument/references", { + textDocument: { uri: `file://${absolutePath}` }, + position: toPosition(line, character), + context: { includeDeclaration: true }, + }); + if (!result) return { content: "No references found." }; + return { content: JSON.stringify(result) }; + } + case "documentSymbol": { + const result = await client.request("textDocument/documentSymbol", { + textDocument: { uri: `file://${absolutePath}` }, + }); + if (!result) return { content: "No symbols found." }; + return { content: JSON.stringify(result) }; + } + } + } catch (err: unknown) { + return { + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + }, + }; +} diff --git a/packages/lsp/src/types.ts b/packages/lsp/src/types.ts new file mode 100644 index 0000000..f89f84d --- /dev/null +++ b/packages/lsp/src/types.ts @@ -0,0 +1,23 @@ +/** + * Shared types for the LSP extension. + */ + +export type LspServerState = "connected" | "starting" | "error" | "not-started"; + +export interface LspServerStatus { + readonly id: string; + readonly name: string; + readonly root: string; + readonly extensions: readonly string[]; + readonly state: LspServerState; + readonly error?: string | undefined; +} + +export interface LspService { + /** + * Resolve the language servers configured for `cwd`, ensure each is spawned + + * initialized (lazy connect), and report live state. Never throws for a single + * server's failure — reflect it as state:"error" with a short `error`. + */ + status(cwd: string): Promise<readonly LspServerStatus[]>; +} diff --git a/packages/lsp/src/watched-files.test.ts b/packages/lsp/src/watched-files.test.ts new file mode 100644 index 0000000..4e598e1 --- /dev/null +++ b/packages/lsp/src/watched-files.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { FileChangeType, globMatch, WatchedFilesRegistry } from "./watched-files.js"; + +describe("watched-files", () => { + it("register stores workspace/didChangeWatchedFiles watchers", () => { + const registry = new WatchedFilesRegistry(); + + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }, { globPattern: "sourcemap.json" }], + }, + }); + + const watchers = registry.getAllWatchers(); + expect(watchers).toHaveLength(2); + expect(watchers[0]?.globPattern).toBe("**/*.luau"); + expect(watchers[1]?.globPattern).toBe("sourcemap.json"); + }); + + it("a changed path matching a registered glob is forwarded as a didChangeWatchedFiles notification with the correct FileChangeType", () => { + const registry = new WatchedFilesRegistry(); + + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }); + + expect(registry.matches("src/main.luau")).toBe(true); + expect(registry.matches("src/nested/deep/file.luau")).toBe(true); + expect(registry.matches("src/main.ts")).toBe(false); + + expect(FileChangeType.Created).toBe(1); + expect(FileChangeType.Changed).toBe(2); + expect(FileChangeType.Deleted).toBe(3); + }); + + it("unregisterCapability stops forwarding", () => { + const registry = new WatchedFilesRegistry(); + + registry.applyRegister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/*.luau" }], + }, + }); + + expect(registry.matches("src/main.luau")).toBe(true); + + registry.applyUnregister({ + id: "reg-1", + method: "workspace/didChangeWatchedFiles", + }); + + expect(registry.matches("src/main.luau")).toBe(false); + expect(registry.getAllWatchers()).toHaveLength(0); + }); + + it("glob matching covers double-star-star.luau, sourcemap.json, double-star/sourcemap.json", () => { + // **/*.luau + expect(globMatch("**/*.luau", "src/main.luau")).toBe(true); + expect(globMatch("**/*.luau", "deep/nested/file.luau")).toBe(true); + expect(globMatch("**/*.luau", "file.luau")).toBe(true); + expect(globMatch("**/*.luau", "src/main.ts")).toBe(false); + + // sourcemap.json (literal) + expect(globMatch("sourcemap.json", "sourcemap.json")).toBe(true); + expect(globMatch("sourcemap.json", "other.json")).toBe(false); + + // **/sourcemap.json + expect(globMatch("**/sourcemap.json", "sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "build/sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "deep/nested/sourcemap.json")).toBe(true); + expect(globMatch("**/sourcemap.json", "other.json")).toBe(false); + }); +}); diff --git a/packages/lsp/src/watched-files.ts b/packages/lsp/src/watched-files.ts new file mode 100644 index 0000000..e23df89 --- /dev/null +++ b/packages/lsp/src/watched-files.ts @@ -0,0 +1,110 @@ +/** + * Watched-files registration state machine + glob matcher. + * + * Manages `workspace/didChangeWatchedFiles` registrations from the server. + * `applyRegister` stores watchers, `applyUnregister` removes them, and + * `matches(path)` tests a file path against all registered glob patterns. + * + * Glob matching supports double-star (any path segments), star (within a segment), + * and literals. + */ + +export interface FileSystemWatcher { + readonly globPattern: string; + readonly kind?: number; +} + +export interface DidChangeWatchedFilesRegistrationOptions { + readonly watchers: readonly FileSystemWatcher[]; +} + +export interface Registration { + readonly id: string; + readonly method: string; + readonly registerOptions?: DidChangeWatchedFilesRegistrationOptions; +} + +export const FileChangeType = { + Created: 1, + Changed: 2, + Deleted: 3, +} as const; + +export type FileChangeTypeValue = (typeof FileChangeType)[keyof typeof FileChangeType]; + +export class WatchedFilesRegistry { + private watchers = new Map<string, readonly FileSystemWatcher[]>(); + + applyRegister(registration: Registration): void { + if (registration.method !== "workspace/didChangeWatchedFiles") return; + const opts = registration.registerOptions; + if (!opts?.watchers) return; + this.watchers.set(registration.id, opts.watchers); + } + + applyUnregister(unregistration: { readonly id: string; readonly method: string }): void { + if (unregistration.method !== "workspace/didChangeWatchedFiles") return; + this.watchers.delete(unregistration.id); + } + + matches(filePath: string): boolean { + for (const watchers of this.watchers.values()) { + for (const w of watchers) { + if (globMatch(w.globPattern, filePath)) return true; + } + } + return false; + } + + getAllWatchers(): readonly FileSystemWatcher[] { + const result: FileSystemWatcher[] = []; + for (const watchers of this.watchers.values()) { + for (const w of watchers) { + result.push(w); + } + } + return result; + } +} + +export function globMatch(pattern: string, filePath: string): boolean { + const normalizedPath = filePath.replace(/^\/+/, "").replace(/\\/g, "/"); + const normalizedPattern = pattern.replace(/^\/+/, "").replace(/\\/g, "/"); + const regex = globToRegex(normalizedPattern); + return regex.test(normalizedPath); +} + +function globToRegex(glob: string): RegExp { + let regex = "^"; + let i = 0; + while (i < glob.length) { + const ch = glob[i] ?? ""; + if (ch === "*" && glob[i + 1] === "*") { + if (glob[i + 2] === "/") { + regex += "(?:.+/)?"; + i += 3; + } else { + regex += ".*"; + i += 2; + } + } else if (ch === "*") { + regex += "[^/]*"; + i++; + } else if (ch === "?") { + regex += "[^/]"; + i++; + } else if (ch === ".") { + regex += "\\."; + i++; + } else { + regex += escapeRegex(ch); + i++; + } + } + regex += "$"; + return new RegExp(regex); +} + +function escapeRegex(ch: string): string { + return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/lsp/tsconfig.json b/packages/lsp/tsconfig.json new file mode 100644 index 0000000..ff99a43 --- /dev/null +++ b/packages/lsp/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../kernel" }] +} |
