diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 1cf93177e41d..1b63ca2744c7 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -29,7 +29,7 @@ export const requiresExtensionsForCustomServers = Schema.makeFilter< boolean | Record> >((data) => { if (typeof data === "boolean") return undefined - const serverIds = new Set(Object.values(LSPServer).map((server) => server.id)) + const serverIds = new Set(Object.values(LSPServer.Builtins).map((server) => server.id)) const ok = Object.entries(data).every(([id, config]) => { if ("disabled" in config && config.disabled) return true if (serverIds.has(id)) return true diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 59a64ca1ed2e..6c1f3a661125 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -4,13 +4,13 @@ import path from "path" import { pathToFileURL, fileURLToPath } from "url" import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types" +import { Effect } from "effect" import { Log } from "../util" import { Process } from "../util" import { LANGUAGE_EXTENSIONS } from "./language" import z from "zod" import type * as LSPServer from "./server" import { NamedError } from "@opencode-ai/shared/util/error" -import { withTimeout } from "../util/timeout" import { Instance } from "../project/instance" import { Filesystem } from "../util" @@ -18,7 +18,19 @@ const DIAGNOSTICS_DEBOUNCE_MS = 150 const log = Log.create({ service: "lsp.client" }) -export type Info = NonNullable>> +type Connection = ReturnType + +export interface Info { + readonly root: string + readonly serverID: string + readonly connection: Connection + readonly notify: { + readonly open: (input: { path: string }) => Effect.Effect + } + readonly diagnostics: Map + readonly waitForDiagnostics: (input: { path: string }) => Effect.Effect + readonly shutdown: () => Effect.Effect +} export type Diagnostic = VSCodeDiagnostic @@ -39,7 +51,11 @@ export const Event = { ), } -export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { +export const create = Effect.fn("LSPClient.create")(function* (input: { + serverID: string + server: LSPServer.Handle + root: string +}) { const l = log.clone().tag("serverID", input.serverID) l.info("starting client") @@ -64,10 +80,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle l.info("window/workDoneProgress/create", params) return null }) - connection.onRequest("workspace/configuration", async () => { - // Return server initialization options - return [input.server.initialization ?? {}] - }) + connection.onRequest("workspace/configuration", async () => [input.server.initialization ?? {}]) connection.onRequest("client/registerCapability", async () => {}) connection.onRequest("client/unregisterCapability", async () => {}) connection.onRequest("workspace/workspaceFolders", async () => [ @@ -79,7 +92,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle connection.listen() l.info("sending initialize") - await withTimeout( + yield* Effect.tryPromise(() => connection.sendRequest("initialize", { rootUri: pathToFileURL(input.root).href, processId: input.server.process.pid, @@ -113,138 +126,137 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }, }, }), - 45_000, - ).catch((err) => { - l.error("initialize error", { error: err }) - throw new InitializeError( - { serverID: input.serverID }, - { - cause: err, - }, - ) - }) + ).pipe( + Effect.timeout(45_000), + Effect.mapError((cause) => new InitializeError({ serverID: input.serverID }, { cause })), + Effect.tapError((error) => Effect.sync(() => l.error("initialize error", { error }))), + ) - await connection.sendNotification("initialized", {}) + yield* Effect.tryPromise(() => connection.sendNotification("initialized", {})) if (input.server.initialization) { - await connection.sendNotification("workspace/didChangeConfiguration", { - settings: input.server.initialization, - }) + yield* Effect.tryPromise(() => + connection.sendNotification("workspace/didChangeConfiguration", { + settings: input.server.initialization, + }), + ) } - const files: { - [path: string]: number - } = {} + const files: Record = {} - const result = { - root: input.root, - get serverID() { - return input.serverID - }, - get connection() { - return connection - }, - notify: { - async open(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) - const text = await Filesystem.readText(input.path) - const extension = path.extname(input.path) - const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - - const version = files[input.path] - if (version !== undefined) { - log.info("workspace/didChangeWatchedFiles", input) - await connection.sendNotification("workspace/didChangeWatchedFiles", { - changes: [ - { - uri: pathToFileURL(input.path).href, - type: 2, // Changed - }, - ], - }) - - const next = version + 1 - files[input.path] = next - log.info("textDocument/didChange", { - path: input.path, - version: next, - }) - await connection.sendNotification("textDocument/didChange", { - textDocument: { - uri: pathToFileURL(input.path).href, - version: next, - }, - contentChanges: [{ text }], - }) - return - } + const open = Effect.fn("LSPClient.notify.open")(function* (next: { path: string }) { + next.path = path.isAbsolute(next.path) ? next.path : path.resolve(Instance.directory, next.path) + const text = yield* Effect.promise(() => Filesystem.readText(next.path)).pipe(Effect.orDie) + const extension = path.extname(next.path) + const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - log.info("workspace/didChangeWatchedFiles", input) - await connection.sendNotification("workspace/didChangeWatchedFiles", { + const version = files[next.path] + if (version !== undefined) { + log.info("workspace/didChangeWatchedFiles", next) + yield* Effect.tryPromise(() => + connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { - uri: pathToFileURL(input.path).href, - type: 1, // Created + uri: pathToFileURL(next.path).href, + type: 2, }, ], - }) + }), + ).pipe(Effect.orDie) - log.info("textDocument/didOpen", input) - diagnostics.delete(input.path) - await connection.sendNotification("textDocument/didOpen", { + const nextVersion = version + 1 + files[next.path] = nextVersion + log.info("textDocument/didChange", { + path: next.path, + version: nextVersion, + }) + yield* Effect.tryPromise(() => + connection.sendNotification("textDocument/didChange", { textDocument: { - uri: pathToFileURL(input.path).href, - languageId, - version: 0, - text, + uri: pathToFileURL(next.path).href, + version: nextVersion, }, - }) - files[input.path] = 0 - return - }, - }, - get diagnostics() { - return diagnostics - }, - async waitForDiagnostics(input: { path: string }) { - const normalizedPath = Filesystem.normalizePath( - path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), - ) - log.info("waiting for diagnostics", { path: normalizedPath }) - let unsub: () => void - let debounceTimer: ReturnType | undefined - return await withTimeout( - new Promise((resolve) => { - unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) { - // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax) - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => { - log.info("got diagnostics", { path: normalizedPath }) - unsub?.() - resolve() - }, DIAGNOSTICS_DEBOUNCE_MS) - } - }) + contentChanges: [{ text }], }), - 3000, - ) - .catch(() => {}) - .finally(() => { - if (debounceTimer) clearTimeout(debounceTimer) - unsub?.() - }) - }, - async shutdown() { - l.info("shutting down") - connection.end() - connection.dispose() - await Process.stop(input.server.process) - l.info("shutdown") - }, - } + ).pipe(Effect.orDie) + return + } + + log.info("workspace/didChangeWatchedFiles", next) + yield* Effect.tryPromise(() => + connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [ + { + uri: pathToFileURL(next.path).href, + type: 1, + }, + ], + }), + ).pipe(Effect.orDie) + + log.info("textDocument/didOpen", next) + diagnostics.delete(next.path) + yield* Effect.tryPromise(() => + connection.sendNotification("textDocument/didOpen", { + textDocument: { + uri: pathToFileURL(next.path).href, + languageId, + version: 0, + text, + }, + }), + ).pipe(Effect.orDie) + files[next.path] = 0 + }) + + const waitForDiagnostics = Effect.fn("LSPClient.waitForDiagnostics")(function* (next: { path: string }) { + const normalizedPath = Filesystem.normalizePath( + path.isAbsolute(next.path) ? next.path : path.resolve(Instance.directory, next.path), + ) + log.info("waiting for diagnostics", { path: normalizedPath }) + yield* Effect.callback((resume) => { + let debounceTimer: ReturnType | undefined + const unsub = Bus.subscribe(Event.Diagnostics, (event) => { + if (event.properties.path !== normalizedPath || event.properties.serverID !== input.serverID) return + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + log.info("got diagnostics", { path: normalizedPath }) + resume(Effect.void) + }, DIAGNOSTICS_DEBOUNCE_MS) + }) + + return Effect.sync(() => { + if (debounceTimer) clearTimeout(debounceTimer) + unsub() + }) + }).pipe(Effect.timeoutOption(3000), Effect.asVoid) + }) + + const shutdown = Effect.fn("LSPClient.shutdown")(function* () { + l.info("shutting down") + connection.end() + connection.dispose() + yield* Effect.promise(() => Process.stop(input.server.process)).pipe(Effect.orDie) + l.info("shutdown") + }) l.info("initialized") - return result -} + return { + root: input.root, + get serverID() { + return input.serverID + }, + get connection() { + return connection + }, + notify: { + open, + }, + get diagnostics() { + return diagnostics + }, + waitForDiagnostics, + shutdown, + } satisfies Info +}) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 43c830987010..34d7a412b13d 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -11,7 +11,7 @@ import { Instance } from "../project/instance" import { Flag } from "@/flag/flag" import { Process } from "../util" import { spawn as lspspawn } from "./launch" -import { Effect, Layer, Context } from "effect" +import { Effect, Fiber, Layer, Context, Scope } from "effect" import { InstanceState } from "@/effect" const log = Log.create({ service: "lsp" }) @@ -134,7 +134,7 @@ interface State { clients: LSPClient.Info[] servers: Record broken: Set - spawning: Map> + spawning: Map> } export interface Interface { @@ -160,6 +160,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service + const scope = yield* Scope.Scope const state = yield* InstanceState.make( Effect.fn("LSP.state")(function* () { @@ -170,7 +171,7 @@ export const layer = Layer.effect( if (!cfg.lsp) { log.info("all LSPs are disabled") } else { - for (const server of Object.values(LSPServer)) { + for (const server of Object.values(LSPServer.Builtins)) { servers[server.id] = server } @@ -187,15 +188,16 @@ export const layer = Layer.effect( servers[name] = { ...existing, id: name, - root: existing?.root ?? (async () => Instance.directory), + root: existing?.root ?? (() => Effect.succeed(Instance.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, - }), + spawn: (root) => + Effect.sync(() => ({ + process: lspspawn(item.command[0], item.command.slice(1), { + cwd: root, + env: { ...process.env, ...item.env }, + }), + initialization: item.initialization, + })), } } } @@ -215,110 +217,121 @@ export const layer = Layer.effect( } yield* Effect.addFinalizer(() => - Effect.promise(async () => { - await Promise.all(s.clients.map((client) => client.shutdown())) - }), + Effect.forEach(s.clients, (client) => client.shutdown(), { concurrency: "unbounded", discard: true }), ) return s }), ) - const getClients = Effect.fnUntraced(function* (file: string) { - if (!Instance.containsPath(file)) 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) - .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 - }) - - if (!handle) return undefined - log.info("spawned lsp server", { serverID: server.id, root }) - - const client = await LSPClient.create({ - serverID: server.id, - server: handle, - root, - }).catch(async (err) => { + const request = Effect.fnUntraced(function* ( + client: LSPClient.Info, + method: string, + params: unknown, + fallback: A, + ) { + return yield* (Effect.tryPromise(() => client.connection.sendRequest(method, params)).pipe( + Effect.catch(() => Effect.succeed(fallback)), + )) + }) + + const scheduleClient = Effect.fnUntraced(function* (s: State, server: LSPServer.Info, root: string, key: string) { + const handle = yield* (server.spawn(root).pipe( + Effect.catch((error: unknown) => + Effect.sync(() => { s.broken.add(key) - await Process.stop(handle.process) - log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) - return undefined - }) + log.error(`Failed to spawn LSP server ${server.id}`, { error }) + }).pipe(Effect.as(undefined)), + ), + )) + if (!handle) { + s.broken.add(key) + return undefined + } - if (!client) return undefined + log.info("spawned lsp server", { serverID: server.id, root }) - const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (existing) { - await Process.stop(handle.process) - return existing - } + const client = yield* LSPClient.create({ + serverID: server.id, + server: handle, + root, + }).pipe( + Effect.catch((error: unknown) => + Effect.gen(function* () { + s.broken.add(key) + yield* (Effect.promise(() => Process.stop(handle.process)).pipe(Effect.catch(() => Effect.void))) + log.error(`Failed to initialize LSP client ${server.id}`, { error }) + return undefined + }), + ), + ) + if (!client) return undefined - s.clients.push(client) - return client - } + const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (existing) { + yield* (Effect.promise(() => Process.stop(handle.process)).pipe(Effect.catch(() => Effect.void))) + return existing + } - for (const server of Object.values(s.servers)) { - if (server.extensions.length && !server.extensions.includes(extension)) continue + s.clients.push(client) + return client + }) - const root = await server.root(file) - if (!root) continue - if (s.broken.has(root + server.id)) continue + const awaitSpawn = Effect.fnUntraced(function* (s: State, server: LSPServer.Info, root: string, key: string) { + const inflight = s.spawning.get(key) + if (inflight) return yield* inflight - const match = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (match) { - result.push(match) - continue - } + const task = yield* Effect.cached(scheduleClient(s, server, root, key)) + s.spawning.set(key, task) + return yield* task.pipe( + Effect.ensuring( + Effect.sync(() => { + if (s.spawning.get(key) === task) s.spawning.delete(key) + }), + ), + ) + }) - const inflight = s.spawning.get(root + server.id) - if (inflight) { - const client = await inflight - if (!client) continue - result.push(client) - continue - } + const getClients = Effect.fnUntraced(function* (file: string) { + if (!Instance.containsPath(file)) return [] as LSPClient.Info[] + const s = yield* InstanceState.get(state) + const extension = path.parse(file).ext || file + const result: LSPClient.Info[] = [] - const task = schedule(server, root, root + server.id) - s.spawning.set(root + server.id, task) + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue - task.finally(() => { - if (s.spawning.get(root + server.id) === task) { - s.spawning.delete(root + server.id) - } - }) + const root = yield* server.root(file) + if (!root) continue - const client = await task - if (!client) continue + const key = root + server.id + if (s.broken.has(key)) continue - result.push(client) - Bus.publish(Event.Updated, {}) + const match = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (match) { + result.push(match) + continue } - return result - }) + const hadInflight = s.spawning.has(key) + const client = yield* awaitSpawn(s, server, root, key) + if (!client) continue + + result.push(client) + if (!hadInflight) Bus.publish(Event.Updated, {}) + } + + return result }) - const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Promise) { + const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Effect.Effect) { const clients = yield* getClients(file) - return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x)))) + return yield* Effect.forEach(clients, fn, { concurrency: "unbounded" }) }) - const runAll = Effect.fnUntraced(function* (fn: (client: LSPClient.Info) => Promise) { + const runAll = Effect.fnUntraced(function* (fn: (client: LSPClient.Info) => Effect.Effect) { const s = yield* InstanceState.get(state) - return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x)))) + return yield* Effect.forEach(s.clients, fn, { concurrency: "unbounded" }) }) const init = Effect.fn("LSP.init")(function* () { @@ -341,38 +354,43 @@ export const layer = Layer.effect( const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { 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) - if (!root) continue - if (s.broken.has(root + server.id)) continue - return true - } - return false - }) + 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 = yield* server.root(file) + if (!root) continue + if (s.broken.has(root + server.id)) continue + return true + } + return false }) const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { log.info("touching file", { file: input }) const clients = yield* getClients(input) - yield* Effect.promise(() => - Promise.all( - clients.map(async (client) => { - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() - await client.notify.open({ path: input }) - return wait + yield* Effect.forEach( + clients, + (client) => + Effect.gen(function* () { + const waiting = waitForDiagnostics + ? yield* client.waitForDiagnostics({ path: input }).pipe(Effect.forkIn(scope)) + : undefined + yield* client.notify.open({ path: input }) + if (waiting) yield* Fiber.join(waiting) }), - ).catch((err) => { - log.error("failed to touch file", { err, file: input }) - }), + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.catch((err: unknown) => + Effect.sync(() => { + 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) + const all = yield* runAll((client) => Effect.succeed(client.diagnostics)) for (const result of all) { for (const [p, diags] of result.entries()) { const arr = results[p] || [] @@ -385,78 +403,65 @@ export const layer = Layer.effect( const hover = Effect.fn("LSP.hover")(function* (input: LocInput) { return yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/hover", { + request(client, "textDocument/hover", { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, - }) - .catch(() => null), + }, null), ) }) const definition = Effect.fn("LSP.definition")(function* (input: LocInput) { const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/definition", { + request(client, "textDocument/definition", { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, - }) - .catch(() => null), + }, 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", { + request(client, "textDocument/references", { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, context: { includeDeclaration: true }, - }) - .catch(() => []), + }, [] as any[]), ) 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", { + request(client, "textDocument/implementation", { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, - }) - .catch(() => null), + }, 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(() => []), - ) + const results = yield* run(file, (client) => request(client, "textDocument/documentSymbol", { textDocument: { uri } }, [] as any[])) 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[]), + request(client, "workspace/symbol", { query }, [] as Symbol[]).pipe( + Effect.map((result) => result.filter((x) => kinds.includes(x.kind)).slice(0, 10)), + ), ) return results.flat() }) const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/prepareCallHierarchy", { + request(client, "textDocument/prepareCallHierarchy", { textDocument: { uri: pathToFileURL(input.file).href }, position: { line: input.line, character: input.character }, - }) - .catch(() => []), + }, [] as any[]), ) return results.flat().filter(Boolean) }) @@ -465,16 +470,16 @@ export const layer = Layer.effect( input: LocInput, direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls", ) { - const results = yield* run(input.file, async (client) => { - const items = await client.connection - .sendRequest("textDocument/prepareCallHierarchy", { + const results = yield* run(input.file, (client) => + Effect.gen(function* () { + const items = yield* request(client, "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(() => []) - }) + }, [] as unknown[]) + if (!items.length) return [] + return yield* request(client, direction, { item: items[0] }, [] as unknown[]) + }), + ) return results.flat().filter(Boolean) }) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 760e8eaba0e4..fd2ecdf3fc77 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -14,13 +14,9 @@ import { which } from "../util/which" import { Module } from "@opencode-ai/shared/util/module" import { spawn } from "./launch" import { Npm } from "../npm" +import { Effect } from "effect" const log = Log.create({ service: "lsp.server" }) -const pathExists = async (p: string) => - fs - .stat(p) - .then(() => true) - .catch(() => false) const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true }) const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true }) @@ -29,9 +25,10 @@ export interface Handle { initialization?: Record } -type RootFunction = (file: string) => Promise +type RawRootFunction = (file: string) => Promise +type RootFunction = (file: string) => Effect.Effect -const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { +const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RawRootFunction => { return async (file) => { if (excludePatterns) { const excludedFiles = Filesystem.up({ @@ -55,15 +52,36 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo } } +export interface RawInfo { + id: string + extensions: string[] + global?: boolean + root: RawRootFunction + spawn(root: string): Promise +} + export interface Info { id: string extensions: string[] global?: boolean root: RootFunction - spawn(root: string): Promise + spawn(root: string): Effect.Effect } -export const Deno: Info = { +const effectify = (info: RawInfo): Info => ({ + ...info, + root: (file) => Effect.promise(() => info.root(file)), + spawn: (root) => Effect.promise(() => info.spawn(root)), +}) + +const effectifyAll = >(infos: T): { [K in keyof T]: Info } => + Object.fromEntries(Object.entries(infos).map(([key, value]) => [key, effectify(value)])) as { [K in keyof T]: Info } + +// Temporary migration bridge: `Builtins` exposes Effect-shaped `root` / `spawn` +// while the per-server definitions still use their older Promise bodies. +// Follow-up: convert the individual server definitions in place and delete this wrapper. + +export const Deno: RawInfo = { id: "deno", root: async (file) => { const files = Filesystem.up({ @@ -91,7 +109,7 @@ export const Deno: Info = { }, } -export const Typescript: Info = { +export const Typescript: RawInfo = { id: "typescript", root: NearestRoot( ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"], @@ -121,7 +139,7 @@ export const Typescript: Info = { }, } -export const Vue: Info = { +export const Vue: RawInfo = { id: "vue", extensions: [".vue"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), @@ -150,7 +168,7 @@ export const Vue: Info = { }, } -export const ESLint: Info = { +export const ESLint: RawInfo = { id: "eslint", root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], @@ -207,7 +225,7 @@ export const ESLint: Info = { }, } -export const Oxlint: Info = { +export const Oxlint: RawInfo = { id: "oxlint", root: NearestRoot([ ".oxlintrc.json", @@ -280,7 +298,7 @@ export const Oxlint: Info = { }, } -export const Biome: Info = { +export const Biome: RawInfo = { id: "biome", root: NearestRoot([ "biome.json", @@ -342,7 +360,7 @@ export const Biome: Info = { }, } -export const Gopls: Info = { +export const Gopls: RawInfo = { id: "gopls", root: async (file) => { const work = await NearestRoot(["go.work"])(file) @@ -381,7 +399,7 @@ export const Gopls: Info = { }, } -export const Rubocop: Info = { +export const Rubocop: RawInfo = { id: "ruby-lsp", root: NearestRoot(["Gemfile"]), extensions: [".rb", ".rake", ".gemspec", ".ru"], @@ -419,7 +437,7 @@ export const Rubocop: Info = { }, } -export const Ty: Info = { +export const Ty: RawInfo = { id: "ty", extensions: [".py", ".pyi"], root: NearestRoot([ @@ -481,7 +499,7 @@ export const Ty: Info = { }, } -export const Pyright: Info = { +export const Pyright: RawInfo = { id: "pyright", extensions: [".py", ".pyi"], root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]), @@ -525,7 +543,7 @@ export const Pyright: Info = { }, } -export const ElixirLS: Info = { +export const ElixirLS: RawInfo = { id: "elixir-ls", extensions: [".ex", ".exs"], root: NearestRoot(["mix.exs", "mix.lock"]), @@ -588,7 +606,7 @@ export const ElixirLS: Info = { }, } -export const Zls: Info = { +export const Zls: RawInfo = { id: "zls", extensions: [".zig", ".zon"], root: NearestRoot(["build.zig"]), @@ -700,7 +718,7 @@ export const Zls: Info = { }, } -export const CSharp: Info = { +export const CSharp: RawInfo = { id: "csharp", root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), extensions: [".cs"], @@ -737,7 +755,7 @@ export const CSharp: Info = { }, } -export const FSharp: Info = { +export const FSharp: RawInfo = { id: "fsharp", root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), extensions: [".fs", ".fsi", ".fsx", ".fsscript"], @@ -774,7 +792,7 @@ export const FSharp: Info = { }, } -export const SourceKit: Info = { +export const SourceKit: RawInfo = { id: "sourcekit-lsp", extensions: [".swift", ".objc", "objcpp"], root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]), @@ -808,7 +826,7 @@ export const SourceKit: Info = { }, } -export const RustAnalyzer: Info = { +export const RustAnalyzer: RawInfo = { id: "rust", root: async (root) => { const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root) @@ -854,7 +872,7 @@ export const RustAnalyzer: Info = { }, } -export const Clangd: Info = { +export const Clangd: RawInfo = { id: "clangd", root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]), extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], @@ -1000,7 +1018,7 @@ export const Clangd: Info = { }, } -export const Svelte: Info = { +export const Svelte: RawInfo = { id: "svelte", extensions: [".svelte"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), @@ -1027,7 +1045,7 @@ export const Svelte: Info = { }, } -export const Astro: Info = { +export const Astro: RawInfo = { id: "astro", extensions: [".astro"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), @@ -1065,7 +1083,7 @@ export const Astro: Info = { }, } -export const JDTLS: Info = { +export const JDTLS: RawInfo = { id: "jdtls", root: async (file) => { // Without exclusions, NearestRoot defaults to instance directory so we can't @@ -1108,7 +1126,7 @@ export const JDTLS: Info = { } const distPath = path.join(Global.Path.bin, "jdtls") const launcherDir = path.join(distPath, "plugins") - const installed = await pathExists(launcherDir) + const installed = await Filesystem.exists(launcherDir) if (!installed) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return log.info("Downloading JDTLS LSP server.") @@ -1140,7 +1158,7 @@ export const JDTLS: Info = { .find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item)) ?.trim() ?? "" const launcherJar = path.join(launcherDir, jarFileName) - if (!(await pathExists(launcherJar))) { + if (!(await Filesystem.exists(launcherJar))) { log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) return } @@ -1186,7 +1204,7 @@ export const JDTLS: Info = { }, } -export const KotlinLS: Info = { +export const KotlinLS: RawInfo = { id: "kotlin-ls", extensions: [".kt", ".kts"], root: async (file) => { @@ -1285,7 +1303,7 @@ export const KotlinLS: Info = { }, } -export const YamlLS: Info = { +export const YamlLS: RawInfo = { id: "yaml-ls", extensions: [".yaml", ".yml"], root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), @@ -1311,7 +1329,7 @@ export const YamlLS: Info = { }, } -export const LuaLS: Info = { +export const LuaLS: RawInfo = { id: "lua-ls", root: NearestRoot([ ".luarc.json", @@ -1452,7 +1470,7 @@ export const LuaLS: Info = { }, } -export const PHPIntelephense: Info = { +export const PHPIntelephense: RawInfo = { id: "php intelephense", extensions: [".php"], root: NearestRoot(["composer.json", "composer.lock", ".php-version"]), @@ -1483,7 +1501,7 @@ export const PHPIntelephense: Info = { }, } -export const Prisma: Info = { +export const Prisma: RawInfo = { id: "prisma", extensions: [".prisma"], root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]), @@ -1501,7 +1519,7 @@ export const Prisma: Info = { }, } -export const Dart: Info = { +export const Dart: RawInfo = { id: "dart", extensions: [".dart"], root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]), @@ -1519,7 +1537,7 @@ export const Dart: Info = { }, } -export const Ocaml: Info = { +export const Ocaml: RawInfo = { id: "ocaml-lsp", extensions: [".ml", ".mli"], root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]), @@ -1536,7 +1554,7 @@ export const Ocaml: Info = { } }, } -export const BashLS: Info = { +export const BashLS: RawInfo = { id: "bash", extensions: [".sh", ".bash", ".zsh", ".ksh"], root: async () => Instance.directory, @@ -1562,7 +1580,7 @@ export const BashLS: Info = { }, } -export const TerraformLS: Info = { +export const TerraformLS: RawInfo = { id: "terraform", extensions: [".tf", ".tfvars"], root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), @@ -1643,7 +1661,7 @@ export const TerraformLS: Info = { }, } -export const TexLab: Info = { +export const TexLab: RawInfo = { id: "texlab", extensions: [".tex", ".bib"], root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), @@ -1731,7 +1749,7 @@ export const TexLab: Info = { }, } -export const DockerfileLS: Info = { +export const DockerfileLS: RawInfo = { id: "dockerfile", extensions: [".dockerfile", "Dockerfile"], root: async () => Instance.directory, @@ -1757,7 +1775,7 @@ export const DockerfileLS: Info = { }, } -export const Gleam: Info = { +export const Gleam: RawInfo = { id: "gleam", extensions: [".gleam"], root: NearestRoot(["gleam.toml"]), @@ -1775,7 +1793,7 @@ export const Gleam: Info = { }, } -export const Clojure: Info = { +export const Clojure: RawInfo = { id: "clojure-lsp", extensions: [".clj", ".cljs", ".cljc", ".edn"], root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]), @@ -1796,7 +1814,7 @@ export const Clojure: Info = { }, } -export const Nixd: Info = { +export const Nixd: RawInfo = { id: "nixd", extensions: [".nix"], root: async (file) => { @@ -1827,7 +1845,7 @@ export const Nixd: Info = { }, } -export const Tinymist: Info = { +export const Tinymist: RawInfo = { id: "tinymist", extensions: [".typ", ".typc"], root: NearestRoot(["typst.toml"]), @@ -1919,7 +1937,7 @@ export const Tinymist: Info = { }, } -export const HLS: Info = { +export const HLS: RawInfo = { id: "haskell-language-server", extensions: [".hs", ".lhs"], root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]), @@ -1937,7 +1955,7 @@ export const HLS: Info = { }, } -export const JuliaLS: Info = { +export const JuliaLS: RawInfo = { id: "julials", extensions: [".jl"], root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]), @@ -1954,3 +1972,43 @@ export const JuliaLS: Info = { } }, } + +export const Builtins = effectifyAll({ + Deno, + Typescript, + Vue, + ESLint, + Oxlint, + Biome, + Gopls, + Rubocop, + Ty, + Pyright, + ElixirLS, + Zls, + CSharp, + FSharp, + SourceKit, + RustAnalyzer, + Clangd, + Svelte, + Astro, + JDTLS, + KotlinLS, + YamlLS, + LuaLS, + PHPIntelephense, + Prisma, + Dart, + Ocaml, + BashLS, + TerraformLS, + TexLab, + DockerfileLS, + Gleam, + Clojure, + Nixd, + Tinymist, + HLS, + JuliaLS, +}) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index f124fddf9581..d1facbf5a156 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test, beforeEach } from "bun:test" import path from "path" +import { Effect } from "effect" +import { Bus } from "../../src/bus" import { LSPClient } from "../../src/lsp" import { LSPServer } from "../../src/lsp" -import { Instance } from "../../src/project/instance" import { Log } from "../../src/util" +import { provideInstance } from "../fixture/fixture" // Minimal fake LSP server that speaks JSON-RPC over stdio function spawnFakeServer() { @@ -16,23 +18,27 @@ function spawnFakeServer() { } } +async function createClient() { + const handle = spawnFakeServer() as any + const cwd = process.cwd() + const client = await Effect.runPromise( + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: cwd, + }).pipe(provideInstance(cwd)), + ) + + return { client, cwd } +} + describe("LSPClient interop", () => { beforeEach(async () => { await Log.init({ print: true }) }) test("handles workspace/workspaceFolders request", async () => { - const handle = spawnFakeServer() as any - - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - }) + const { client } = await createClient() await client.connection.sendNotification("test/trigger", { method: "workspace/workspaceFolders", @@ -42,21 +48,11 @@ describe("LSPClient interop", () => { expect(client.connection).toBeDefined() - await client.shutdown() + await Effect.runPromise(client.shutdown()) }) test("handles client/registerCapability request", async () => { - const handle = spawnFakeServer() as any - - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - }) + const { client } = await createClient() await client.connection.sendNotification("test/trigger", { method: "client/registerCapability", @@ -66,21 +62,11 @@ describe("LSPClient interop", () => { expect(client.connection).toBeDefined() - await client.shutdown() + await Effect.runPromise(client.shutdown()) }) test("handles client/unregisterCapability request", async () => { - const handle = spawnFakeServer() as any - - const client = await Instance.provide({ - directory: process.cwd(), - fn: () => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - }), - }) + const { client } = await createClient() await client.connection.sendNotification("test/trigger", { method: "client/unregisterCapability", @@ -90,6 +76,32 @@ describe("LSPClient interop", () => { expect(client.connection).toBeDefined() - await client.shutdown() + await Effect.runPromise(client.shutdown()) + }) + + test("waitForDiagnostics() resolves when a matching diagnostic event is published", async () => { + const { client, cwd } = await createClient() + const file = path.join(cwd, "fixture.ts") + + const waiting = Effect.runPromise(client.waitForDiagnostics({ path: file }).pipe(provideInstance(cwd))) + + await Effect.runPromise(Effect.sleep(20)) + await Effect.runPromise(Effect.promise(() => Bus.publish(LSPClient.Event.Diagnostics, { path: file, serverID: "fake" })).pipe(provideInstance(cwd))) + await waiting + + await Effect.runPromise(client.shutdown()) + }) + + test("waitForDiagnostics() times out without throwing when no event arrives", async () => { + const { client, cwd } = await createClient() + const started = Date.now() + + await Effect.runPromise(client.waitForDiagnostics({ path: path.join(cwd, "never.ts") }).pipe(provideInstance(cwd))) + + const elapsed = Date.now() - started + expect(elapsed).toBeGreaterThanOrEqual(2900) + expect(elapsed).toBeLessThan(5000) + + await Effect.runPromise(client.shutdown()) }) }) diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index 13f21c93cc7e..ca10f208fc0d 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import path from "path" -import { Effect, Layer } from "effect" +import { Effect, Fiber, Layer, Scope } from "effect" import { LSP } from "../../src/lsp" import { LSPServer } from "../../src/lsp" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" @@ -153,6 +153,35 @@ describe("LSP service lifecycle", () => { ), ), ) + + it.live("touchFile() dedupes concurrent spawn attempts for the same file", () => + provideTmpdirInstance( + (dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const gate = Promise.withResolvers() + const scope = yield* Scope.Scope + const file = path.join(dir, "src", "inside.ts") + + spawnSpy.mockImplementation(async () => { + await gate.promise + return undefined + }) + + const fiber = yield* Effect.all([lsp.touchFile(file, false), lsp.touchFile(file, false)], { + concurrency: "unbounded", + }).pipe(Effect.forkIn(scope)) + + yield* Effect.sleep(20) + expect(spawnSpy).toHaveBeenCalledTimes(1) + + gate.resolve() + yield* Fiber.join(fiber) + }), + ), + { config: { lsp: true } }, + ), + ) }) describe("LSP.Diagnostic", () => {