소스 검색

convert lsp client to effect

James Long 3 달 전
부모
커밋
9d11b62ff0

+ 11 - 4
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<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"

+ 21 - 9
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<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"
 

+ 3 - 0
packages/opencode/src/server/routes/instance/httpapi/server.ts

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

+ 20 - 12
packages/opencode/test/lsp/client.test.ts

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

+ 11 - 2
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,
   ),
 )