This commit is contained in:
James Long
2026-05-17 15:04:04 -04:00
parent 99c758e343
commit d33517c6b5
3 changed files with 527 additions and 327 deletions
+334 -326
View File
@@ -111,6 +111,11 @@ const filterExperimentalServers = (servers: Record<string, LSPServer.Info>, 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<Service, Interface>()("@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<State>(
Effect.fn("LSP.state")(function* (ctx) {
const cfg = yield* config.get()
const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* (ctx) {
const cfg = yield* config.get()
const servers: Record<string, LSPServer.Info> = {}
const servers: Record<string, LSPServer.Info> = {}
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* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
const clients = yield* getClients(file)
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
})
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
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* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
const clients = yield* getClients(file)
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
})
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
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<string, LSPClient.Diagnostic[]> = {}
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<Symbol[]>("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<unknown[] | null>("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<string, LSPClient.Diagnostic[]> = {}
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<Symbol[]>("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<unknown[] | null>("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))
@@ -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<typ
FileWatcher.layer,
Format.layer,
Snapshot.layer,
LSP.layer,
LSP.makeLayer(SimulationLsp.supportedServers),
MCP.layer,
Skill.layer,
Instruction.layer,
@@ -0,0 +1,191 @@
import type { ChildProcessWithoutNullStreams } from "child_process"
import { EventEmitter } from "events"
import { PassThrough, type Readable, type Writable } from "stream"
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
type MessageConnection,
} from "vscode-jsonrpc/node"
import type * as LSPServer from "@/lsp/server"
// ─── Fake process ────────────────────────────────────────────────────────────
//
// `LSPClient.create` wraps the server handle's stdout/stdin with
// `StreamMessageReader` / `StreamMessageWriter` from `vscode-jsonrpc/node`,
// which expects real Node `Readable` / `Writable` streams. We satisfy that
// contract with a pair of `PassThrough` streams wired in opposite directions:
//
// client.write(stdin) ──► [inbound] ──► server reads
// client.read(stdout) ◄── [outbound] ◄── server writes
//
// Everything lives in-process; nothing is ever spawned.
interface FakeProcessHandles {
readonly process: ChildProcessWithoutNullStreams
readonly serverInput: Readable
readonly serverOutput: Writable
}
let fakePid = 50_000
function createFakeProcess(): FakeProcessHandles {
const inbound = new PassThrough() // client.stdin → server reads
const outbound = new PassThrough() // server writes → client.stdout
const stderr = new PassThrough() // never written
const proc = new EventEmitter() as unknown as ChildProcessWithoutNullStreams & {
exitCode: number | null
signalCode: NodeJS.Signals | null
pid: number
stdin: Writable
stdout: Readable
stderr: Readable
}
proc.pid = ++fakePid
proc.exitCode = null
proc.signalCode = null
proc.stdin = inbound
proc.stdout = outbound
proc.stderr = stderr
// `Process.stop` calls `proc.kill()`. Tear the streams down and emit `exit`
// so any consumer that awaits `process.exited` resolves.
Object.defineProperty(proc, "kill", {
value: (_signal?: NodeJS.Signals | number) => {
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"