From 9d11b62ff0b10bf152520e3f3e9cc56e9aeb865b Mon Sep 17 00:00:00 2001 From: James Long Date: Sun, 17 May 2026 16:14:20 -0400 Subject: [PATCH] convert lsp client to effect --- packages/opencode/src/lsp/client.ts | 15 ++++++--- packages/opencode/src/lsp/lsp.ts | 30 +++++++++++------ .../server/routes/instance/httpapi/server.ts | 3 ++ packages/opencode/test/lsp/client.test.ts | 32 ++++++++++++------- packages/opencode/test/lsp/index.test.ts | 13 ++++++-- 5 files changed, 66 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 205cba6f29..c09f6e10ea 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -11,6 +11,7 @@ import { Effect, Schema } from "effect" import type * as LSPServer from "./server" import { withTimeout } from "../util/timeout" import { Filesystem } from "@/util/filesystem" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceRef } from "@/effect/instance-ref" import { makeRuntime } from "@/effect/run-service" import type { InstanceContext } from "@/project/instance-context" @@ -30,7 +31,7 @@ const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2 const log = Log.create({ service: "lsp.client" }) const busRuntime = makeRuntime(Bus.Service, Bus.layer) -export type Info = NonNullable>> +export type Info = NonNullable>> export type Diagnostic = VSCodeDiagnostic @@ -138,13 +139,18 @@ function shouldSeedDiagnosticsOnFirstPush(serverID: string) { return serverID === "typescript" } -export async function create(input: { +export const create = Effect.fn("LSPClient.create")(function* (input: { serverID: string server: LSPServer.Handle root: string directory: string instance: InstanceContext }) { + const appFs = yield* AppFileSystem.Service + // Bridge: read file content through AppFileSystem (so the simulated backend + // can satisfy file reads from the in-memory FS instead of real disk). + const readText = (p: string) => Effect.runPromise(appFs.readFileString(p) as Effect.Effect) + return yield* Effect.promise(async () => { const logger = log.clone().tag("serverID", input.serverID) logger.info("starting client") const instance = input.instance @@ -596,7 +602,7 @@ export async function create(input: { request.path = Filesystem.normalizePath( path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), ) - const text = await Filesystem.readText(request.path) + const text = await readText(request.path) const extension = path.extname(request.path) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" @@ -702,6 +708,7 @@ export async function create(input: { logger.info("initialized") return result -} + }) +}) export * as LSPClient from "./client" diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index a3b9779e79..ac00ddd4f9 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -13,6 +13,9 @@ import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" import { NonNegativeInt } from "@opencode-ai/core/schema" import { RuntimeFlags } from "@/effect/runtime-flags" +import { InstanceRef } from "@/effect/instance-ref" +import { makeRuntime } from "@/effect/run-service" +import { AppFileSystem } from "@opencode-ai/core/filesystem" const log = Log.create({ service: "lsp" }) @@ -150,6 +153,7 @@ export const makeLayer = (supported: LSPServer.Info[]) => Effect.gen(function* () { const config = yield* Config.Service const flags = yield* RuntimeFlags.Service + const appFs = yield* AppFileSystem.Service const state = yield* InstanceState.make( Effect.fn("LSP.state")(function* (ctx) { @@ -238,13 +242,15 @@ export const makeLayer = (supported: LSPServer.Info[]) => 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) => { + const client = await Effect.runPromise( + LSPClient.create({ + serverID: server.id, + server: handle, + root, + directory: ctx.directory, + instance: ctx, + }).pipe(Effect.provideService(AppFileSystem.Service, appFs)), + ).catch(async (err) => { s.broken.add(key) await Process.stop(handle.process) log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) @@ -297,7 +303,9 @@ export const makeLayer = (supported: LSPServer.Info[]) => if (!client) continue result.push(client) - await Bus.publish(ctx, Event.Updated, {}) + void busRuntime.runPromise((bus) => + bus.publish(Event.Updated, {}).pipe(Effect.provideService(InstanceRef, ctx)), + ) } return result @@ -508,7 +516,11 @@ export const makeLayer = (supported: LSPServer.Info[]) => export const layer = makeLayer(builtinServers) -export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), +) export * as Diagnostic from "./diagnostic" diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 25000b5531..42ced001b0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -335,6 +335,9 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType[0]) => + Effect.runPromise(LSPClient.create(input).pipe(Effect.provide(AppFileSystem.defaultLayer))) + describe("LSPClient interop", () => { beforeEach(async () => { await Log.init({ print: true }) @@ -27,7 +35,7 @@ describe("LSPClient interop", () => { const client = await withTestInstance({ directory: process.cwd(), fn: (ctx) => - LSPClient.create({ + createClient({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), @@ -51,7 +59,7 @@ describe("LSPClient interop", () => { const client = await withTestInstance({ directory: process.cwd(), fn: (ctx) => - LSPClient.create({ + createClient({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), @@ -75,7 +83,7 @@ describe("LSPClient interop", () => { const client = await withTestInstance({ directory: process.cwd(), fn: (ctx) => - LSPClient.create({ + createClient({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), @@ -99,7 +107,7 @@ describe("LSPClient interop", () => { const client = await withTestInstance({ directory: process.cwd(), fn: (ctx) => - LSPClient.create({ + createClient({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: process.cwd(), @@ -127,7 +135,7 @@ describe("LSPClient interop", () => { const client = await withTestInstance({ directory: process.cwd(), fn: (ctx) => - LSPClient.create({ + createClient({ serverID: "fake", server: { ...(handle as unknown as LSPServer.Handle), @@ -157,7 +165,7 @@ describe("LSPClient interop", () => { await withTestInstance({ directory: tmp.path, fn: async (ctx) => { - const client = await LSPClient.create({ + const client = await createClient({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, @@ -201,7 +209,7 @@ describe("LSPClient interop", () => { await withTestInstance({ directory: tmp.path, fn: async (ctx) => { - const client = await LSPClient.create({ + const client = await createClient({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, @@ -248,7 +256,7 @@ describe("LSPClient interop", () => { await withTestInstance({ directory: tmp.path, fn: async (ctx) => { - const client = await LSPClient.create({ + const client = await createClient({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, @@ -296,7 +304,7 @@ describe("LSPClient interop", () => { await withTestInstance({ directory: tmp.path, fn: async (ctx) => { - const client = await LSPClient.create({ + const client = await createClient({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, @@ -345,7 +353,7 @@ describe("LSPClient interop", () => { await withTestInstance({ directory: tmp.path, fn: async (ctx) => { - const client = await LSPClient.create({ + const client = await createClient({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, @@ -399,7 +407,7 @@ describe("LSPClient interop", () => { await withTestInstance({ directory: tmp.path, fn: async (ctx) => { - const client = await LSPClient.create({ + const client = await createClient({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, @@ -464,7 +472,7 @@ describe("LSPClient interop", () => { await withTestInstance({ directory: tmp.path, fn: async (ctx) => { - const client = await LSPClient.create({ + const client = await createClient({ serverID: "fake", server: handle as unknown as LSPServer.Handle, root: tmp.path, diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index 78543c4583..43b92186e4 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -6,6 +6,7 @@ import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { LSP } from "@/lsp/lsp" import * as LSPServer from "@/lsp/server" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" @@ -13,14 +14,22 @@ import { awaitWithTimeout, testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) const experimentalTyIt = testEffect( Layer.mergeAll( - LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalLspTy: true }))), + LSP.layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalLspTy: true })), + Layer.provide(AppFileSystem.defaultLayer), + ), CrossSpawnSpawner.defaultLayer, ), ) const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") const disabledDownloadIt = testEffect( Layer.mergeAll( - LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ disableLspDownload: true }))), + LSP.layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(RuntimeFlags.layer({ disableLspDownload: true })), + Layer.provide(AppFileSystem.defaultLayer), + ), CrossSpawnSpawner.defaultLayer, ), )