From d33517c6b5eed69067326410deca8732e2d8954d Mon Sep 17 00:00:00 2001 From: James Long Date: Sun, 17 May 2026 14:15:05 -0400 Subject: [PATCH] fake lsp --- packages/opencode/src/lsp/lsp.ts | 660 +++++++++--------- .../server/routes/instance/httpapi/server.ts | 3 +- .../opencode/src/testing/simulation/lsp.ts | 191 +++++ 3 files changed, 527 insertions(+), 327 deletions(-) create mode 100644 packages/opencode/src/testing/simulation/lsp.ts diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 3117b834c..a3b9779e7 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -111,6 +111,11 @@ const filterExperimentalServers = (servers: Record, flag } } +const builtinServers: LSPServer.Info[] = Object.values(LSPServer).filter( + (value): value is LSPServer.Info => + typeof value === "object" && value !== null && typeof (value as LSPServer.Info).id === "string", +) + type LocInput = { file: string; line: number; character: number } interface State { @@ -139,366 +144,369 @@ export interface Interface { export class Service extends Context.Service()("@opencode/LSP") {} -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - const flags = yield* RuntimeFlags.Service +export const makeLayer = (supported: LSPServer.Info[]) => + Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const flags = yield* RuntimeFlags.Service - const state = yield* InstanceState.make( - Effect.fn("LSP.state")(function* (ctx) { - const cfg = yield* config.get() + const state = yield* InstanceState.make( + Effect.fn("LSP.state")(function* (ctx) { + const cfg = yield* config.get() - const servers: Record = {} + const servers: Record = {} - if (!cfg.lsp) { - log.info("all LSPs are disabled") - } else { - for (const server of Object.values(LSPServer)) { - servers[server.id] = server - } + if (!cfg.lsp) { + log.info("all LSPs are disabled") + } else { + for (const server of supported) { + servers[server.id] = server + } - filterExperimentalServers(servers, flags) + filterExperimentalServers(servers, flags) - if (cfg.lsp !== true) { - for (const [name, item] of Object.entries(cfg.lsp)) { - const existing = servers[name] - if (item.disabled) { - log.info(`LSP server ${name} is disabled`) - delete servers[name] - continue - } - servers[name] = { - ...existing, - id: name, - root: existing?.root ?? (async (_file, ctx) => ctx.directory), - extensions: item.extensions ?? existing?.extensions ?? [], - spawn: async (root) => ({ - process: lspspawn(item.command[0], item.command.slice(1), { - cwd: root, - env: { ...process.env, ...item.env }, + if (cfg.lsp !== true) { + for (const [name, item] of Object.entries(cfg.lsp)) { + const existing = servers[name] + if (item.disabled) { + log.info(`LSP server ${name} is disabled`) + delete servers[name] + continue + } + servers[name] = { + ...existing, + id: name, + root: existing?.root ?? (async (_file, ctx) => ctx.directory), + extensions: item.extensions ?? existing?.extensions ?? [], + spawn: async (root) => ({ + process: lspspawn(item.command[0], item.command.slice(1), { + cwd: root, + env: { ...process.env, ...item.env }, + }), + initialization: item.initialization, }), - initialization: item.initialization, - }), + } } } + + log.info("enabled LSP servers", { + serverIds: Object.values(servers) + .map((server) => server.id) + .join(", "), + }) } - log.info("enabled LSP servers", { - serverIds: Object.values(servers) - .map((server) => server.id) - .join(", "), - }) - } + const s: State = { + clients: [], + servers, + broken: new Set(), + spawning: new Map(), + } - const s: State = { - clients: [], - servers, - broken: new Set(), - spawning: new Map(), - } + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await Promise.all(s.clients.map((client) => client.shutdown())) + }), + ) - yield* Effect.addFinalizer(() => - Effect.promise(async () => { - await Promise.all(s.clients.map((client) => client.shutdown())) - }), - ) + return s + }), + ) - return s - }), - ) + const getClients = Effect.fnUntraced(function* (file: string) { + const ctx = yield* InstanceState.context + if (!containsPath(file, ctx)) return [] as LSPClient.Info[] + const s = yield* InstanceState.get(state) + return yield* Effect.promise(async () => { + const extension = path.parse(file).ext || file + const result: LSPClient.Info[] = [] - const getClients = Effect.fnUntraced(function* (file: string) { - const ctx = yield* InstanceState.context - if (!containsPath(file, ctx)) return [] as LSPClient.Info[] - const s = yield* InstanceState.get(state) - return yield* Effect.promise(async () => { - const extension = path.parse(file).ext || file - const result: LSPClient.Info[] = [] + async function schedule(server: LSPServer.Info, root: string, key: string) { + const handle = await server + .spawn(root, ctx, flags) + .then((value) => { + if (!value) s.broken.add(key) + return value + }) + .catch((err) => { + s.broken.add(key) + log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) + return undefined + }) - async function schedule(server: LSPServer.Info, root: string, key: string) { - const handle = await server - .spawn(root, ctx, flags) - .then((value) => { - if (!value) s.broken.add(key) - return value - }) - .catch((err) => { + if (!handle) return undefined + log.info("spawned lsp server", { serverID: server.id, root }) + + const client = await LSPClient.create({ + serverID: server.id, + server: handle, + root, + directory: ctx.directory, + instance: ctx, + }).catch(async (err) => { s.broken.add(key) - log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) + await Process.stop(handle.process) + log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) return undefined }) - if (!handle) return undefined - log.info("spawned lsp server", { serverID: server.id, root }) + if (!client) return undefined - const client = await LSPClient.create({ - serverID: server.id, - server: handle, - root, - directory: ctx.directory, - instance: ctx, - }).catch(async (err) => { - s.broken.add(key) - await Process.stop(handle.process) - log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) - return undefined - }) - - if (!client) return undefined - - const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (existing) { - await Process.stop(handle.process) - return existing - } - - s.clients.push(client) - return client - } - - for (const server of Object.values(s.servers)) { - if (server.extensions.length && !server.extensions.includes(extension)) continue - - const root = await server.root(file, ctx) - if (!root) continue - if (s.broken.has(root + server.id)) continue - - const match = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (match) { - result.push(match) - continue - } - - const inflight = s.spawning.get(root + server.id) - if (inflight) { - const client = await inflight - if (!client) continue - result.push(client) - continue - } - - const task = schedule(server, root, root + server.id) - s.spawning.set(root + server.id, task) - - task.finally(() => { - if (s.spawning.get(root + server.id) === task) { - s.spawning.delete(root + server.id) + const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (existing) { + await Process.stop(handle.process) + return existing } + + s.clients.push(client) + return client + } + + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue + + const root = await server.root(file, ctx) + if (!root) continue + if (s.broken.has(root + server.id)) continue + + const match = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (match) { + result.push(match) + continue + } + + const inflight = s.spawning.get(root + server.id) + if (inflight) { + const client = await inflight + if (!client) continue + result.push(client) + continue + } + + const task = schedule(server, root, root + server.id) + s.spawning.set(root + server.id, task) + + task.finally(() => { + if (s.spawning.get(root + server.id) === task) { + s.spawning.delete(root + server.id) + } + }) + + const client = await task + if (!client) continue + + result.push(client) + await Bus.publish(ctx, Event.Updated, {}) + } + + return result + }) + }) + + const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Promise) { + const clients = yield* getClients(file) + return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x)))) + }) + + const runAll = Effect.fnUntraced(function* (fn: (client: LSPClient.Info) => Promise) { + const s = yield* InstanceState.get(state) + return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x)))) + }) + + const init = Effect.fn("LSP.init")(function* () { + yield* InstanceState.get(state) + }) + + const status = Effect.fn("LSP.status")(function* () { + const ctx = yield* InstanceState.context + const s = yield* InstanceState.get(state) + const result: Status[] = [] + for (const client of s.clients) { + result.push({ + id: client.serverID, + name: s.servers[client.serverID].id, + root: path.relative(ctx.directory, client.root), + status: "connected", }) - - const client = await task - if (!client) continue - - result.push(client) - await Bus.publish(ctx, Event.Updated, {}) } - return result }) - }) - const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Promise) { - const clients = yield* getClients(file) - return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x)))) - }) - - const runAll = Effect.fnUntraced(function* (fn: (client: LSPClient.Info) => Promise) { - const s = yield* InstanceState.get(state) - return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x)))) - }) - - const init = Effect.fn("LSP.init")(function* () { - yield* InstanceState.get(state) - }) - - const status = Effect.fn("LSP.status")(function* () { - const ctx = yield* InstanceState.context - const s = yield* InstanceState.get(state) - const result: Status[] = [] - for (const client of s.clients) { - result.push({ - id: client.serverID, - name: s.servers[client.serverID].id, - root: path.relative(ctx.directory, client.root), - status: "connected", + const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { + const ctx = yield* InstanceState.context + const s = yield* InstanceState.get(state) + return yield* Effect.promise(async () => { + const extension = path.parse(file).ext || file + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue + const root = await server.root(file, ctx) + if (!root) continue + if (s.broken.has(root + server.id)) continue + return true + } + return false }) - } - return result - }) - - const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { - const ctx = yield* InstanceState.context - const s = yield* InstanceState.get(state) - return yield* Effect.promise(async () => { - const extension = path.parse(file).ext || file - for (const server of Object.values(s.servers)) { - if (server.extensions.length && !server.extensions.includes(extension)) continue - const root = await server.root(file, ctx) - if (!root) continue - if (s.broken.has(root + server.id)) continue - return true - } - return false }) - }) - const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") { - log.info("touching file", { file: input }) - const clients = yield* getClients(input) - yield* Effect.promise(() => - Promise.all( - clients.map(async (client) => { - const after = Date.now() - const version = await client.notify.open({ path: input }) - if (!diagnostics) return - return client.waitForDiagnostics({ - path: input, - version, - mode: diagnostics, - after, - }) + const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") { + log.info("touching file", { file: input }) + const clients = yield* getClients(input) + yield* Effect.promise(() => + Promise.all( + clients.map(async (client) => { + const after = Date.now() + const version = await client.notify.open({ path: input }) + if (!diagnostics) return + return client.waitForDiagnostics({ + path: input, + version, + mode: diagnostics, + after, + }) + }), + ).catch((err) => { + log.error("failed to touch file", { err, file: input }) }), - ).catch((err) => { - log.error("failed to touch file", { err, file: input }) - }), - ) - }) - - const diagnostics = Effect.fn("LSP.diagnostics")(function* () { - const results: Record = {} - const all = yield* runAll(async (client) => client.diagnostics) - for (const result of all) { - for (const [p, diags] of result.entries()) { - const arr = results[p] || [] - arr.push(...diags) - results[p] = arr - } - } - return results - }) - - const hover = Effect.fn("LSP.hover")(function* (input: LocInput) { - return yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/hover", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ) - }) - - const definition = Effect.fn("LSP.definition")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/definition", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ) - return results.flat().filter(Boolean) - }) - - const references = Effect.fn("LSP.references")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/references", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - context: { includeDeclaration: true }, - }) - .catch(() => []), - ) - return results.flat().filter(Boolean) - }) - - const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/implementation", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ) - return results.flat().filter(Boolean) - }) - - const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) { - const file = fileURLToPath(uri) - const results = yield* run(file, (client) => - client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []), - ) - return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean) - }) - - const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { - const results = yield* runAll((client) => - client.connection - .sendRequest("workspace/symbol", { query }) - .then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10)) - .catch(() => [] as Symbol[]), - ) - return results.flat() - }) - - const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => []), - ) - return results.flat().filter(Boolean) - }) - - const callHierarchyRequest = Effect.fnUntraced(function* ( - input: LocInput, - direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls", - ) { - const results = yield* run(input.file, async (client) => { - const items = await client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => [] as unknown[]) - if (!items?.length) return [] - return client.connection.sendRequest(direction, { item: items[0] }).catch(() => []) + ) }) - return results.flat().filter(Boolean) - }) - const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) { - return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls") - }) + const diagnostics = Effect.fn("LSP.diagnostics")(function* () { + const results: Record = {} + const all = yield* runAll(async (client) => client.diagnostics) + for (const result of all) { + for (const [p, diags] of result.entries()) { + const arr = results[p] || [] + arr.push(...diags) + results[p] = arr + } + } + return results + }) - const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) { - return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls") - }) + const hover = Effect.fn("LSP.hover")(function* (input: LocInput) { + return yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/hover", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + }) - return Service.of({ - init, - status, - hasClients, - touchFile, - diagnostics, - hover, - definition, - references, - implementation, - documentSymbol, - workspaceSymbol, - prepareCallHierarchy, - incomingCalls, - outgoingCalls, - }) - }), -) + const definition = Effect.fn("LSP.definition")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/definition", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + return results.flat().filter(Boolean) + }) + + const references = Effect.fn("LSP.references")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/references", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + context: { includeDeclaration: true }, + }) + .catch(() => []), + ) + return results.flat().filter(Boolean) + }) + + const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/implementation", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + return results.flat().filter(Boolean) + }) + + const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) { + const file = fileURLToPath(uri) + const results = yield* run(file, (client) => + client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []), + ) + return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean) + }) + + const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { + const results = yield* runAll((client) => + client.connection + .sendRequest("workspace/symbol", { query }) + .then((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10)) + .catch(() => [] as Symbol[]), + ) + return results.flat() + }) + + const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/prepareCallHierarchy", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => []), + ) + return results.flat().filter(Boolean) + }) + + const callHierarchyRequest = Effect.fnUntraced(function* ( + input: LocInput, + direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls", + ) { + const results = yield* run(input.file, async (client) => { + const items = await client.connection + .sendRequest("textDocument/prepareCallHierarchy", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => [] as unknown[]) + if (!items?.length) return [] + return client.connection.sendRequest(direction, { item: items[0] }).catch(() => []) + }) + return results.flat().filter(Boolean) + }) + + const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) { + return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls") + }) + + const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) { + return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls") + }) + + return Service.of({ + init, + status, + hasClients, + touchFile, + diagnostics, + hover, + definition, + references, + implementation, + documentSymbol, + workspaceSymbol, + prepareCallHierarchy, + incomingCalls, + outgoingCalls, + }) + }), + ) + +export const layer = makeLayer(builtinServers) export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index e275f13d2..576987e29 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -80,6 +80,7 @@ import { SimulationNetworkRoutes } from "@/testing/simulation/network-routes" import { SimulationProvider } from "@/testing/simulation/provider" import { SimulationSpawner } from "@/testing/simulation/spawner" import { SimulationGit } from "@/testing/simulation/git" +import { SimulationLsp } from "@/testing/simulation/lsp" import { Simulation } from "@/testing/simulation/service" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/shared/ui" @@ -483,7 +484,7 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType { + if (proc.exitCode !== null) return true + proc.exitCode = 0 + proc.signalCode = null + // Closing the streams unblocks the JSON-RPC reader/writer cleanly. + inbound.end() + outbound.end() + stderr.end() + proc.emit("exit", 0, null) + proc.emit("close", 0, null) + return true + }, + }) + + return { process: proc, serverInput: inbound, serverOutput: outbound } +} + +// ─── JSON-RPC server implementation ────────────────────────────────────────── +// +// We use the same `vscode-jsonrpc` library on the server side so the wire +// framing is bit-for-bit identical to a real LSP server. The server is +// a stub: it responds with valid but empty results to every request the +// client makes, and accepts every notification silently. + +const SERVER_CAPABILITIES = { + textDocumentSync: { + openClose: true, + change: 2, // Incremental + }, + hoverProvider: true, + definitionProvider: true, + referencesProvider: true, + implementationProvider: true, + documentSymbolProvider: true, + workspaceSymbolProvider: true, + callHierarchyProvider: true, + diagnosticProvider: { + interFileDependencies: false, + workspaceDiagnostics: false, + }, +} as const + +function startFakeServer(connection: MessageConnection) { + connection.onRequest("initialize", async () => ({ + capabilities: SERVER_CAPABILITIES, + serverInfo: { name: "simulated-lsp", version: "0.0.0" }, + })) + + // Notifications — silently accept. + for (const method of [ + "initialized", + "textDocument/didOpen", + "textDocument/didChange", + "textDocument/didClose", + "textDocument/didSave", + "workspace/didChangeWatchedFiles", + "workspace/didChangeConfiguration", + "$/setTrace", + "$/cancelRequest", + ]) { + connection.onNotification(method, () => {}) + } + + // Diagnostics — empty pull response so the client's pull path resolves + // immediately with no findings. We never push diagnostics. + connection.onRequest("textDocument/diagnostic", async () => ({ + kind: "full", + items: [], + })) + connection.onRequest("workspace/diagnostic", async () => ({ + items: [], + })) + + // Code intelligence — null/[] results are valid per the LSP spec. + connection.onRequest("textDocument/hover", async () => null) + connection.onRequest("textDocument/definition", async () => []) + connection.onRequest("textDocument/references", async () => []) + connection.onRequest("textDocument/implementation", async () => []) + connection.onRequest("textDocument/documentSymbol", async () => []) + connection.onRequest("workspace/symbol", async () => []) + connection.onRequest("textDocument/prepareCallHierarchy", async () => []) + connection.onRequest("callHierarchy/incomingCalls", async () => []) + connection.onRequest("callHierarchy/outgoingCalls", async () => []) + + // Shutdown / exit — the client doesn't call `shutdown` (it just kills the + // process), but answer if it ever does. + connection.onRequest("shutdown", async () => null) + connection.onNotification("exit", () => {}) + + // Catch-all so unknown methods don't blow up the connection. + connection.onRequest((method) => { + // Unknown method: return null. Returning a typed error would also be + // valid, but null keeps consumers happy without spurious failures. + void method + return null + }) + connection.onNotification(() => {}) + + connection.listen() +} + +// ─── Public LSPServer.Info implementation ──────────────────────────────────── + +export const SimulatedTypescript: LSPServer.Info = { + id: "typescript", + // Match the file extensions of the real typescript server so the simulated + // backend reports clients for the same files. The simulated root is just + // the instance directory — we don't probe the filesystem here because the + // simulated FS is in-memory and the only "project" is `/opencode`. + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], + root: async (_file, ctx) => ctx.directory, + async spawn(_root, _ctx) { + const { process, serverInput, serverOutput } = createFakeProcess() + + const connection = createMessageConnection( + new StreamMessageReader(serverInput), + new StreamMessageWriter(serverOutput), + ) + startFakeServer(connection) + + // When the client kills the process, the streams close and the server + // connection's reader will emit `end`. Dispose explicitly to release + // listeners. + process.once("exit", () => { + connection.dispose() + }) + + return { + process, + initialization: {}, + } + }, +} + +export const supportedServers: LSPServer.Info[] = [SimulatedTypescript] + +export * as SimulationLsp from "./lsp"