Răsfoiți Sursa

refactor(lsp): effectify client and server boundaries

Kit Langton 23 ore în urmă
părinte
comite
00120c32a8

+ 1 - 1
packages/opencode/src/config/lsp.ts

@@ -29,7 +29,7 @@ export const requiresExtensionsForCustomServers = Schema.makeFilter<
   boolean | Record<string, Schema.Schema.Type<typeof Entry>>
 >((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

+ 173 - 140
packages/opencode/src/lsp/client.ts

@@ -4,6 +4,7 @@ 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"
@@ -18,7 +19,19 @@ const DIAGNOSTICS_DEBOUNCE_MS = 150
 
 const log = Log.create({ service: "lsp.client" })
 
-export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
+type Connection = ReturnType<typeof createMessageConnection>
+
+export interface Info {
+  readonly root: string
+  readonly serverID: string
+  readonly connection: Connection
+  readonly notify: {
+    readonly open: (input: { path: string }) => Effect.Effect<void>
+  }
+  readonly diagnostics: Map<string, Diagnostic[]>
+  readonly waitForDiagnostics: (input: { path: string }) => Effect.Effect<void>
+  readonly shutdown: () => Effect.Effect<void>
+}
 
 export type Diagnostic = VSCodeDiagnostic
 
@@ -39,7 +52,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 +81,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,145 +93,144 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
   connection.listen()
 
   l.info("sending initialize")
-  await withTimeout(
-    connection.sendRequest("initialize", {
-      rootUri: pathToFileURL(input.root).href,
-      processId: input.server.process.pid,
-      workspaceFolders: [
-        {
-          name: "workspace",
-          uri: pathToFileURL(input.root).href,
-        },
-      ],
-      initializationOptions: {
-        ...input.server.initialization,
-      },
-      capabilities: {
-        window: {
-          workDoneProgress: true,
-        },
-        workspace: {
-          configuration: true,
-          didChangeWatchedFiles: {
-            dynamicRegistration: true,
-          },
-        },
-        textDocument: {
-          synchronization: {
-            didOpen: true,
-            didChange: true,
+  yield* Effect.tryPromise({
+    try: () =>
+      withTimeout(
+        connection.sendRequest("initialize", {
+          rootUri: pathToFileURL(input.root).href,
+          processId: input.server.process.pid,
+          workspaceFolders: [
+            {
+              name: "workspace",
+              uri: pathToFileURL(input.root).href,
+            },
+          ],
+          initializationOptions: {
+            ...input.server.initialization,
           },
-          publishDiagnostics: {
-            versionSupport: true,
+          capabilities: {
+            window: {
+              workDoneProgress: true,
+            },
+            workspace: {
+              configuration: true,
+              didChangeWatchedFiles: {
+                dynamicRegistration: true,
+              },
+            },
+            textDocument: {
+              synchronization: {
+                didOpen: true,
+                didChange: true,
+              },
+              publishDiagnostics: {
+                versionSupport: true,
+              },
+            },
           },
+        }),
+        45_000,
+      ),
+    catch: (error) => {
+      l.error("initialize error", { error })
+      return new InitializeError(
+        { serverID: input.serverID },
+        {
+          cause: error,
         },
-      },
-    }),
-    45_000,
-  ).catch((err) => {
-    l.error("initialize error", { error: err })
-    throw new InitializeError(
-      { serverID: input.serverID },
-      {
-        cause: err,
-      },
-    )
+      )
+    },
   })
 
-  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 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 files: Record<string, number> = {}
 
-          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<typeof setTimeout> | undefined
-      return await withTimeout(
+          contentChanges: [{ text }],
+        }),
+      ).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 })
+    let unsub: (() => void) | undefined
+    let debounceTimer: ReturnType<typeof setTimeout> | undefined
+    yield* Effect.promise(() =>
+      withTimeout(
         new Promise<void>((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 (event.properties.path === normalizedPath && event.properties.serverID === input.serverID) {
               if (debounceTimer) clearTimeout(debounceTimer)
               debounceTimer = setTimeout(() => {
                 log.info("got diagnostics", { path: normalizedPath })
@@ -228,23 +241,43 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
           })
         }),
         3000,
-      )
-        .catch(() => {})
-        .finally(() => {
+      ),
+    ).pipe(
+      Effect.catch(() => Effect.void),
+      Effect.ensuring(
+        Effect.sync(() => {
           if (debounceTimer) clearTimeout(debounceTimer)
           unsub?.()
-        })
-    },
-    async shutdown() {
-      l.info("shutting down")
-      connection.end()
-      connection.dispose()
-      await Process.stop(input.server.process)
-      l.info("shutdown")
-    },
-  }
+        }),
+      ),
+    )
+  })
+
+  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
+})

+ 142 - 141
packages/opencode/src/lsp/lsp.ts

@@ -134,7 +134,7 @@ interface State {
   clients: LSPClient.Info[]
   servers: Record<string, LSPServer.Info>
   broken: Set<string>
-  spawning: Map<string, Promise<LSPClient.Info | undefined>>
+  spawning: Map<string, Effect.Effect<LSPClient.Info | undefined>>
 }
 
 export interface Interface {
@@ -170,7 +170,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 +187,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 +216,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* <A>(
+      client: LSPClient.Info,
+      method: string,
+      params: unknown,
+      fallback: A,
+    ) {
+      return yield* (Effect.tryPromise(() => client.connection.sendRequest<A>(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* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
+    const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Effect.Effect<T>) {
       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* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
+    const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Effect.Effect<T>) {
       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 +353,40 @@ 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(() =>
+      yield* Effect.tryPromise(() =>
         Promise.all(
           clients.map(async (client) => {
-            const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
-            await client.notify.open({ path: input })
+            const wait = waitForDiagnostics ? Effect.runPromise(client.waitForDiagnostics({ path: input })) : Promise.resolve()
+            await Effect.runPromise(client.notify.open({ path: input }))
             return wait
           }),
-        ).catch((err) => {
-          log.error("failed to touch file", { err, file: input })
-        }),
+        ),
+      ).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<string, LSPClient.Diagnostic[]> = {}
-      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 +399,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<Symbol[]>("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 +466,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<unknown[] | null>("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)
     })
 

+ 103 - 40
packages/opencode/src/lsp/server.ts

@@ -14,6 +14,7 @@ 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) =>
@@ -29,9 +30,10 @@ export interface Handle {
   initialization?: Record<string, any>
 }
 
-type RootFunction = (file: string) => Promise<string | undefined>
+type RawRootFunction = (file: string) => Promise<string | undefined>
+type RootFunction = (file: string) => Effect.Effect<string | undefined>
 
-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 +57,36 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo
   }
 }
 
+export interface RawInfo {
+  id: string
+  extensions: string[]
+  global?: boolean
+  root: RawRootFunction
+  spawn(root: string): Promise<Handle | undefined>
+}
+
 export interface Info {
   id: string
   extensions: string[]
   global?: boolean
   root: RootFunction
-  spawn(root: string): Promise<Handle | undefined>
+  spawn(root: string): Effect.Effect<Handle | undefined>
 }
 
-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 = <T extends Record<string, RawInfo>>(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 +114,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 +144,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 +173,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 +230,7 @@ export const ESLint: Info = {
   },
 }
 
-export const Oxlint: Info = {
+export const Oxlint: RawInfo = {
   id: "oxlint",
   root: NearestRoot([
     ".oxlintrc.json",
@@ -280,7 +303,7 @@ export const Oxlint: Info = {
   },
 }
 
-export const Biome: Info = {
+export const Biome: RawInfo = {
   id: "biome",
   root: NearestRoot([
     "biome.json",
@@ -342,7 +365,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 +404,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 +442,7 @@ export const Rubocop: Info = {
   },
 }
 
-export const Ty: Info = {
+export const Ty: RawInfo = {
   id: "ty",
   extensions: [".py", ".pyi"],
   root: NearestRoot([
@@ -481,7 +504,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 +548,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 +611,7 @@ export const ElixirLS: Info = {
   },
 }
 
-export const Zls: Info = {
+export const Zls: RawInfo = {
   id: "zls",
   extensions: [".zig", ".zon"],
   root: NearestRoot(["build.zig"]),
@@ -700,7 +723,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 +760,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 +797,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 +831,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 +877,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 +1023,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 +1050,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 +1088,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
@@ -1186,7 +1209,7 @@ export const JDTLS: Info = {
   },
 }
 
-export const KotlinLS: Info = {
+export const KotlinLS: RawInfo = {
   id: "kotlin-ls",
   extensions: [".kt", ".kts"],
   root: async (file) => {
@@ -1285,7 +1308,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 +1334,7 @@ export const YamlLS: Info = {
   },
 }
 
-export const LuaLS: Info = {
+export const LuaLS: RawInfo = {
   id: "lua-ls",
   root: NearestRoot([
     ".luarc.json",
@@ -1452,7 +1475,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 +1506,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 +1524,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 +1542,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 +1559,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 +1585,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 +1666,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 +1754,7 @@ export const TexLab: Info = {
   },
 }
 
-export const DockerfileLS: Info = {
+export const DockerfileLS: RawInfo = {
   id: "dockerfile",
   extensions: [".dockerfile", "Dockerfile"],
   root: async () => Instance.directory,
@@ -1757,7 +1780,7 @@ export const DockerfileLS: Info = {
   },
 }
 
-export const Gleam: Info = {
+export const Gleam: RawInfo = {
   id: "gleam",
   extensions: [".gleam"],
   root: NearestRoot(["gleam.toml"]),
@@ -1775,7 +1798,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 +1819,7 @@ export const Clojure: Info = {
   },
 }
 
-export const Nixd: Info = {
+export const Nixd: RawInfo = {
   id: "nixd",
   extensions: [".nix"],
   root: async (file) => {
@@ -1827,7 +1850,7 @@ export const Nixd: Info = {
   },
 }
 
-export const Tinymist: Info = {
+export const Tinymist: RawInfo = {
   id: "tinymist",
   extensions: [".typ", ".typc"],
   root: NearestRoot(["typst.toml"]),
@@ -1919,7 +1942,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 +1960,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 +1977,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,
+})

+ 25 - 18
packages/opencode/test/lsp/client.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test, beforeEach } from "bun:test"
 import path from "path"
+import { Effect } from "effect"
 import { LSPClient } from "../../src/lsp"
 import { LSPServer } from "../../src/lsp"
 import { Instance } from "../../src/project/instance"
@@ -27,11 +28,13 @@ describe("LSPClient interop", () => {
     const client = await Instance.provide({
       directory: process.cwd(),
       fn: () =>
-        LSPClient.create({
-          serverID: "fake",
-          server: handle as unknown as LSPServer.Handle,
-          root: process.cwd(),
-        }),
+        Effect.runPromise(
+          LSPClient.create({
+            serverID: "fake",
+            server: handle as unknown as LSPServer.Handle,
+            root: process.cwd(),
+          }),
+        ),
     })
 
     await client.connection.sendNotification("test/trigger", {
@@ -42,7 +45,7 @@ describe("LSPClient interop", () => {
 
     expect(client.connection).toBeDefined()
 
-    await client.shutdown()
+    await Effect.runPromise(client.shutdown())
   })
 
   test("handles client/registerCapability request", async () => {
@@ -51,11 +54,13 @@ describe("LSPClient interop", () => {
     const client = await Instance.provide({
       directory: process.cwd(),
       fn: () =>
-        LSPClient.create({
-          serverID: "fake",
-          server: handle as unknown as LSPServer.Handle,
-          root: process.cwd(),
-        }),
+        Effect.runPromise(
+          LSPClient.create({
+            serverID: "fake",
+            server: handle as unknown as LSPServer.Handle,
+            root: process.cwd(),
+          }),
+        ),
     })
 
     await client.connection.sendNotification("test/trigger", {
@@ -66,7 +71,7 @@ describe("LSPClient interop", () => {
 
     expect(client.connection).toBeDefined()
 
-    await client.shutdown()
+    await Effect.runPromise(client.shutdown())
   })
 
   test("handles client/unregisterCapability request", async () => {
@@ -75,11 +80,13 @@ describe("LSPClient interop", () => {
     const client = await Instance.provide({
       directory: process.cwd(),
       fn: () =>
-        LSPClient.create({
-          serverID: "fake",
-          server: handle as unknown as LSPServer.Handle,
-          root: process.cwd(),
-        }),
+        Effect.runPromise(
+          LSPClient.create({
+            serverID: "fake",
+            server: handle as unknown as LSPServer.Handle,
+            root: process.cwd(),
+          }),
+        ),
     })
 
     await client.connection.sendNotification("test/trigger", {
@@ -90,6 +97,6 @@ describe("LSPClient interop", () => {
 
     expect(client.connection).toBeDefined()
 
-    await client.shutdown()
+    await Effect.runPromise(client.shutdown())
   })
 })