diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 4b8a3e7d45..5f0a1807d8 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -1,4 +1,6 @@ import { LSP } from "../../../lsp" +import { AppRuntime } from "../../../effect/app-runtime" +import { Effect } from "effect" import { bootstrap } from "../../bootstrap" import { cmd } from "../cmd" import { Log } from "../../../util/log" @@ -19,9 +21,16 @@ const DiagnosticsCommand = cmd({ builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), async handler(args) { await bootstrap(process.cwd(), async () => { - await LSP.touchFile(args.file, true) - await sleep(1000) - process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL) + const out = await AppRuntime.runPromise( + LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.touchFile(args.file, true) + yield* Effect.sleep(1000) + return yield* lsp.diagnostics() + }), + ), + ) + process.stdout.write(JSON.stringify(out, null, 2) + EOL) }) }, }) @@ -33,7 +42,7 @@ export const SymbolsCommand = cmd({ async handler(args) { await bootstrap(process.cwd(), async () => { using _ = Log.Default.time("symbols") - const results = await LSP.workspaceSymbol(args.query) + const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))) process.stdout.write(JSON.stringify(results, null, 2) + EOL) }) }, @@ -46,7 +55,7 @@ export const DocumentSymbolsCommand = cmd({ async handler(args) { await bootstrap(process.cwd(), async () => { using _ = Log.Default.time("document-symbols") - const results = await LSP.documentSymbol(args.uri) + const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))) process.stdout.write(JSON.stringify(results, null, 2) + EOL) }) }, diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 8e34a88546..0c83890e55 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -13,7 +13,6 @@ import { Process } from "../util/process" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" export namespace LSP { const log = Log.create({ service: "lsp" }) @@ -508,37 +507,6 @@ export namespace LSP { export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - const { runPromise } = makeRuntime(Service, defaultLayer) - - export const init = async () => runPromise((svc) => svc.init()) - - export const status = async () => runPromise((svc) => svc.status()) - - export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file)) - - export const touchFile = async (input: string, waitForDiagnostics?: boolean) => - runPromise((svc) => svc.touchFile(input, waitForDiagnostics)) - - export const diagnostics = async () => runPromise((svc) => svc.diagnostics()) - - export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input)) - - export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input)) - - export const references = async (input: LocInput) => runPromise((svc) => svc.references(input)) - - export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input)) - - export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri)) - - export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query)) - - export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input)) - - export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input)) - - export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input)) - export namespace Diagnostic { const MAX_PER_FILE = 20 diff --git a/packages/opencode/src/server/instance/file.ts b/packages/opencode/src/server/instance/file.ts index 713513b38d..a869cf3673 100644 --- a/packages/opencode/src/server/instance/file.ts +++ b/packages/opencode/src/server/instance/file.ts @@ -105,11 +105,6 @@ export const FileRoutes = lazy(() => }), ), async (c) => { - /* - const query = c.req.valid("query").query - const result = await LSP.workspaceSymbol(query) - return c.json(result) - */ return c.json([]) }, ) diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts index 6009130a2f..6d383afa7c 100644 --- a/packages/opencode/src/server/instance/index.ts +++ b/packages/opencode/src/server/instance/index.ts @@ -256,7 +256,8 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => }, }), async (c) => { - return c.json(await LSP.status()) + const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status())) + return c.json(items) }, ) .get( diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index 7e514e39b1..b12a61ae3c 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -1,55 +1,55 @@ -import { describe, expect, spyOn, test } from "bun:test" +import { describe, expect, spyOn } from "bun:test" import path from "path" -import * as Lsp from "../../src/lsp/index" +import { Effect, Layer } from "effect" +import { LSP } from "../../src/lsp" import { LSPServer } from "../../src/lsp/server" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) describe("lsp.spawn", () => { - test("does not spawn builtin LSP for files outside instance", async () => { - await using tmp = await tmpdir() - const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + it.live("does not spawn builtin LSP for files outside instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts")) - await Lsp.LSP.hover({ - file: path.join(tmp.path, "..", "hover.ts"), - line: 0, - character: 0, - }) - }, - }) + try { + yield* lsp.touchFile(path.join(dir, "..", "outside.ts")) + yield* lsp.hover({ + file: path.join(dir, "..", "hover.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(0) + } finally { + spy.mockRestore() + } + }), + ), + ), + ) - expect(spy).toHaveBeenCalledTimes(0) - } finally { - spy.mockRestore() - await Instance.disposeAll() - } - }) + it.live("would spawn builtin LSP for files inside instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - test("would spawn builtin LSP for files inside instance", async () => { - await using tmp = await tmpdir() - const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await Lsp.LSP.hover({ - file: path.join(tmp.path, "src", "inside.ts"), - line: 0, - character: 0, - }) - }, - }) - - expect(spy).toHaveBeenCalledTimes(1) - } finally { - spy.mockRestore() - await Instance.disposeAll() - } - }) + try { + yield* lsp.hover({ + file: path.join(dir, "src", "inside.ts"), + line: 0, + character: 0, + }) + expect(spy).toHaveBeenCalledTimes(1) + } finally { + spy.mockRestore() + } + }), + ), + ), + ) }) diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index fb3ed8c21c..a6de869fcb 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -1,23 +1,13 @@ -import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test" +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import path from "path" -import * as Lsp from "../../src/lsp/index" +import { Effect, Layer } from "effect" +import { LSP } from "../../src/lsp" import { LSPServer } from "../../src/lsp/server" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -function withInstance(fn: (dir: string) => Promise) { - return async () => { - await using tmp = await tmpdir() - try { - await Instance.provide({ - directory: tmp.path, - fn: () => fn(tmp.path), - }) - } finally { - await Instance.disposeAll() - } - } -} +const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) describe("LSP service lifecycle", () => { let spawnSpy: ReturnType @@ -30,97 +20,112 @@ describe("LSP service lifecycle", () => { spawnSpy.mockRestore() }) - test( - "init() completes without error", - withInstance(async () => { - await Lsp.LSP.init() - }), + it.live("init() completes without error", () => provideTmpdirInstance(() => LSP.Service.use((lsp) => lsp.init()))) + + it.live("status() returns empty array initially", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.status() + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), + ), + ), ) - test( - "status() returns empty array initially", - withInstance(async () => { - const result = await Lsp.LSP.status() - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(0) - }), + it.live("diagnostics() returns empty object initially", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.diagnostics() + expect(typeof result).toBe("object") + expect(Object.keys(result).length).toBe(0) + }), + ), + ), ) - test( - "diagnostics() returns empty object initially", - withInstance(async () => { - const result = await Lsp.LSP.diagnostics() - expect(typeof result).toBe("object") - expect(Object.keys(result).length).toBe(0) - }), + it.live("hasClients() returns true for .ts files in instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join(dir, "test.ts")) + expect(result).toBe(true) + }), + ), + ), ) - test( - "hasClients() returns true for .ts files in instance", - withInstance(async (dir) => { - const result = await Lsp.LSP.hasClients(path.join(dir, "test.ts")) - expect(result).toBe(true) - }), + it.live("hasClients() returns false for files outside instance", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.hasClients(path.join(dir, "..", "outside.ts")) + expect(typeof result).toBe("boolean") + }), + ), + ), ) - test( - "hasClients() returns false for files outside instance", - withInstance(async (dir) => { - const result = await Lsp.LSP.hasClients(path.join(dir, "..", "outside.ts")) - // hasClients checks servers but doesn't check containsPath — getClients does - // So hasClients may return true even for outside files (it checks extension + root) - // The guard is in getClients, not hasClients - expect(typeof result).toBe("boolean") - }), + it.live("workspaceSymbol() returns empty array with no clients", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.workspaceSymbol("test") + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(0) + }), + ), + ), ) - test( - "workspaceSymbol() returns empty array with no clients", - withInstance(async () => { - const result = await Lsp.LSP.workspaceSymbol("test") - expect(Array.isArray(result)).toBe(true) - expect(result.length).toBe(0) - }), + it.live("definition() returns empty array for unknown file", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.definition({ + file: path.join(dir, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), + ), + ), ) - test( - "definition() returns empty array for unknown file", - withInstance(async (dir) => { - const result = await Lsp.LSP.definition({ - file: path.join(dir, "nonexistent.ts"), - line: 0, - character: 0, - }) - expect(Array.isArray(result)).toBe(true) - }), + it.live("references() returns empty array for unknown file", () => + provideTmpdirInstance((dir) => + LSP.Service.use((lsp) => + Effect.gen(function* () { + const result = yield* lsp.references({ + file: path.join(dir, "nonexistent.ts"), + line: 0, + character: 0, + }) + expect(Array.isArray(result)).toBe(true) + }), + ), + ), ) - test( - "references() returns empty array for unknown file", - withInstance(async (dir) => { - const result = await Lsp.LSP.references({ - file: path.join(dir, "nonexistent.ts"), - line: 0, - character: 0, - }) - expect(Array.isArray(result)).toBe(true) - }), - ) - - test( - "multiple init() calls are idempotent", - withInstance(async () => { - await Lsp.LSP.init() - await Lsp.LSP.init() - await Lsp.LSP.init() - // Should not throw or create duplicate state - }), + it.live("multiple init() calls are idempotent", () => + provideTmpdirInstance(() => + LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.init() + yield* lsp.init() + yield* lsp.init() + }), + ), + ), ) }) describe("LSP.Diagnostic", () => { test("pretty() formats error diagnostic", () => { - const result = Lsp.LSP.Diagnostic.pretty({ + const result = LSP.Diagnostic.pretty({ range: { start: { line: 9, character: 4 }, end: { line: 9, character: 10 } }, message: "Type 'string' is not assignable to type 'number'", severity: 1, @@ -129,7 +134,7 @@ describe("LSP.Diagnostic", () => { }) test("pretty() formats warning diagnostic", () => { - const result = Lsp.LSP.Diagnostic.pretty({ + const result = LSP.Diagnostic.pretty({ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }, message: "Unused variable", severity: 2, @@ -138,7 +143,7 @@ describe("LSP.Diagnostic", () => { }) test("pretty() defaults to ERROR when no severity", () => { - const result = Lsp.LSP.Diagnostic.pretty({ + const result = LSP.Diagnostic.pretty({ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }, message: "Something wrong", } as any)