convert lsp client to effect

This commit is contained in:
James Long 2026-05-17 16:14:20 -04:00
commit 9d11b62ff0
5 changed files with 66 additions and 27 deletions

View file

@ -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<Awaited<ReturnType<typeof create>>>
export type Info = NonNullable<Effect.Success<ReturnType<typeof create>>>
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<string, Error>)
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"

View file

@ -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<State>(
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"

View file

@ -335,6 +335,9 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typ
files: {
".git/HEAD": "ref: refs/heads/main\n",
".git/config": '[core]\n\trepositoryformatversion = 0\n\tbare = false\n[branch "main"]\n',
// Enable built-in LSP servers (e.g. the simulated typescript stub) so
// tools like `edit` / `write` get diagnostics in the simulated chain.
"opencode.json": JSON.stringify({ $schema: "https://opencode.ai/config.json", lsp: true }, null, 2) + "\n",
},
}),
// SimulationFileSystem.layer no longer provides FileSystem.FileSystem; satisfy

View file

@ -5,6 +5,8 @@ import { tmpdir, withTestInstance } from "../fixture/fixture"
import { LSPClient } from "@/lsp/client"
import * as LSPServer from "@/lsp/server"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
function spawnFakeServer() {
const { spawn } = require("child_process")
@ -16,6 +18,12 @@ function spawnFakeServer() {
}
}
// LSPClient.create is an Effect that yields AppFileSystem so the production
// real-fs layer or a simulated one can satisfy file reads. Tests use the
// default real-disk layer.
const createClient = (input: Parameters<typeof LSPClient.create>[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,

View file

@ -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,
),
)