From 68c0adbb2c5ee88bf77a40396a85975f0863333e Mon Sep 17 00:00:00 2001 From: Samir Alibabic Date: Fri, 15 May 2026 10:26:05 +0200 Subject: [PATCH] fix(opencode): resolve LSP dependencies from workspace root --- packages/opencode/src/lsp/server.ts | 57 +++++++--- packages/opencode/test/lsp/server.test.ts | 123 ++++++++++++++++++++++ 2 files changed, 165 insertions(+), 15 deletions(-) create mode 100644 packages/opencode/test/lsp/server.test.ts diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 7bda06f0df15..9133ac527828 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -23,6 +23,27 @@ const pathExists = async (p: string) => .catch(() => false) const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true }) const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true }) +const JsRootMarkers = [ + "package.json", + "tsconfig.json", + "jsconfig.json", + "package-lock.json", + "bun.lockb", + "bun.lock", + "pnpm-lock.yaml", + "yarn.lock", +] + +const resolveModule = (specifier: string, root: string, ctx: InstanceContext) => + Module.resolve(specifier, root) ?? (root === ctx.directory ? undefined : Module.resolve(specifier, ctx.directory)) + +const logMissing = (id: string, dependency: string, root: string, ctx: InstanceContext) => + log.info(`${id} dependency not found`, { + dependency, + root, + directory: ctx.directory, + worktree: ctx.worktree, + }) export interface Handle { process: ChildProcessWithoutNullStreams @@ -93,17 +114,20 @@ export const Deno: Info = { export const Typescript: Info = { id: "typescript", - root: NearestRoot( - ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"], - ["deno.json", "deno.jsonc"], - ), + root: NearestRoot(JsRootMarkers, ["deno.json", "deno.jsonc"]), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], async spawn(root, ctx) { - const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory) - log.info("typescript server", { tsserver }) - if (!tsserver) return + const tsserver = resolveModule("typescript/lib/tsserver.js", root, ctx) + log.info("typescript server", { root, tsserver }) + if (!tsserver) { + logMissing("typescript", "typescript/lib/tsserver.js", root, ctx) + return + } const bin = await Npm.which("typescript-language-server") - if (!bin) return + if (!bin) { + logMissing("typescript", "typescript-language-server", root, ctx) + return + } const proc = spawn(bin, ["--stdio"], { cwd: root, env: { @@ -124,7 +148,7 @@ export const Typescript: Info = { export const Vue: Info = { id: "vue", extensions: [".vue"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + root: NearestRoot(JsRootMarkers), async spawn(root, _ctx, flags) { let binary = which("vue-language-server") const args: string[] = [] @@ -152,11 +176,14 @@ export const Vue: Info = { export const ESLint: Info = { id: "eslint", - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + root: NearestRoot(JsRootMarkers), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], async spawn(root, ctx, flags) { - const eslint = Module.resolve("eslint", ctx.directory) - if (!eslint) return + const eslint = resolveModule("eslint", root, ctx) + if (!eslint) { + logMissing("eslint", "eslint", root, ctx) + return + } log.info("spawning eslint server") const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") if (!(await Filesystem.exists(serverPath))) { @@ -1138,11 +1165,11 @@ export const Svelte: Info = { export const Astro: Info = { id: "astro", extensions: [".astro"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + root: NearestRoot(JsRootMarkers), async spawn(root, ctx, flags) { - const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory) + const tsserver = resolveModule("typescript/lib/tsserver.js", root, ctx) if (!tsserver) { - log.info("typescript not found, required for Astro language server") + logMissing("astro", "typescript/lib/tsserver.js", root, ctx) return } const tsdk = path.dirname(tsserver) diff --git a/packages/opencode/test/lsp/server.test.ts b/packages/opencode/test/lsp/server.test.ts new file mode 100644 index 000000000000..d3a200543f8b --- /dev/null +++ b/packages/opencode/test/lsp/server.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, spyOn, test } from "bun:test" +import { Effect } from "effect" +import fs from "fs/promises" +import path from "path" +import { Npm } from "@opencode-ai/core/npm" +import { RuntimeFlags } from "@/effect/runtime-flags" +import type { InstanceContext } from "@/project/instance" +import { ProjectID } from "@/project/schema" +import * as LSPServer from "@/lsp/server" +import { tmpdir } from "../fixture/fixture" + +const createContext = (directory: string): InstanceContext => ({ + directory, + worktree: directory, + project: { + id: ProjectID.global, + worktree: directory, + time: { + created: 0, + updated: 0, + }, + sandboxes: [], + }, +}) + +const flags = () => + Effect.runPromise( + Effect.gen(function* () { + return yield* RuntimeFlags.Service + }).pipe(Effect.provide(RuntimeFlags.layer())), + ) + +const writePackage = async (dir: string, marker: string) => { + await fs.mkdir(dir, { recursive: true }) + await Bun.write(path.join(dir, marker), marker.endsWith(".json") ? "{}" : "") +} + +const writeTypescript = async (dir: string) => { + await writePackage(dir, "package.json") + await fs.mkdir(path.join(dir, "node_modules", "typescript", "lib"), { recursive: true }) + await Bun.write(path.join(dir, "node_modules", "typescript", "lib", "tsserver.js"), "") +} + +describe("LSP server definitions", () => { + test("TypeScript root detects child package.json", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "apps", "api") + await writePackage(root, "package.json") + + const result = await LSPServer.Typescript.root(path.join(root, "src", "server.ts"), createContext(tmp.path)) + + expect(result).toBe(root) + }) + + test("TypeScript root detects child tsconfig.json", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "apps", "api") + await writePackage(root, "tsconfig.json") + + const result = await LSPServer.Typescript.root(path.join(root, "src", "server.ts"), createContext(tmp.path)) + + expect(result).toBe(root) + }) + + test("TypeScript root skips Deno projects", async () => { + await using tmp = await tmpdir() + await writePackage(tmp.path, "package.json") + await writePackage(tmp.path, "deno.json") + + const result = await LSPServer.Typescript.root(path.join(tmp.path, "src", "main.ts"), createContext(tmp.path)) + + expect(result).toBeUndefined() + }) + + test("ESLint root detects child package.json", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "apps", "web") + await writePackage(root, "package.json") + + const result = await LSPServer.ESLint.root(path.join(root, "src", "App.tsx"), createContext(tmp.path)) + + expect(result).toBe(root) + }) + + test("Astro root detects child package.json", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "apps", "site") + await writePackage(root, "package.json") + + const result = await LSPServer.Astro.root(path.join(root, "src", "index.astro"), createContext(tmp.path)) + + expect(result).toBe(root) + }) + + test("TypeScript spawn resolves TypeScript from workspace root first", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "apps", "api") + await writeTypescript(root) + const npmWhich = spyOn(Npm, "which").mockResolvedValue(undefined) + + try { + await LSPServer.Typescript.spawn(root, createContext(tmp.path), await flags()) + expect(npmWhich).toHaveBeenCalledWith("typescript-language-server") + } finally { + npmWhich.mockRestore() + } + }) + + test("TypeScript spawn falls back to launch directory TypeScript", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "apps", "api") + await fs.mkdir(root, { recursive: true }) + await writeTypescript(tmp.path) + const npmWhich = spyOn(Npm, "which").mockResolvedValue(undefined) + + try { + await LSPServer.Typescript.spawn(root, createContext(tmp.path), await flags()) + expect(npmWhich).toHaveBeenCalledWith("typescript-language-server") + } finally { + npmWhich.mockRestore() + } + }) +})