Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 42 additions & 15 deletions packages/opencode/src/lsp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand All @@ -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[] = []
Expand Down Expand Up @@ -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))) {
Expand Down Expand Up @@ -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)
Expand Down
123 changes: 123 additions & 0 deletions packages/opencode/test/lsp/server.test.ts
Original file line number Diff line number Diff line change
@@ -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()
}
})
})
Loading