From 509bc11f81430575c58887960a02e63fa0107c03 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 15 Apr 2026 23:30:52 -0400 Subject: [PATCH] feat: unwrap lsp namespaces to flat exports + barrel (#22748) --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/lsp/client.ts | 408 +- packages/opencode/src/lsp/index.ts | 540 +-- packages/opencode/src/lsp/lsp.ts | 535 +++ packages/opencode/src/lsp/server.ts | 3618 +++++++++--------- packages/opencode/test/lsp/client.test.ts | 4 +- packages/opencode/test/lsp/index.test.ts | 2 +- packages/opencode/test/lsp/lifecycle.test.ts | 2 +- 8 files changed, 2554 insertions(+), 2557 deletions(-) create mode 100644 packages/opencode/src/lsp/lsp.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 04801098b4..d8cfd5e48f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -19,7 +19,7 @@ import { printParseErrorCode, } from "jsonc-parser" import { Instance, type InstanceContext } from "../project/instance" -import { LSPServer } from "../lsp/server" +import { LSPServer } from "../lsp" import { Installation } from "@/installation" import { ConfigMarkdown } from "." import { existsSync } from "fs" diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 27301e79a7..fed2bf5c99 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -8,7 +8,7 @@ import { Log } from "../util" import { Process } from "../util" import { LANGUAGE_EXTENSIONS } from "./language" import z from "zod" -import type { LSPServer } from "./server" +import type { LSPServer } from "." import { NamedError } from "@opencode-ai/shared/util/error" import { withTimeout } from "../util/timeout" import { Instance } from "../project/instance" @@ -16,237 +16,235 @@ import { Filesystem } from "../util" const DIAGNOSTICS_DEBOUNCE_MS = 150 -export namespace LSPClient { - const log = Log.create({ service: "lsp.client" }) +const log = Log.create({ service: "lsp.client" }) - export type Info = NonNullable>> +export type Info = NonNullable>> - export type Diagnostic = VSCodeDiagnostic +export type Diagnostic = VSCodeDiagnostic - export const InitializeError = NamedError.create( - "LSPInitializeError", +export const InitializeError = NamedError.create( + "LSPInitializeError", + z.object({ + serverID: z.string(), + }), +) + +export const Event = { + Diagnostics: BusEvent.define( + "lsp.client.diagnostics", z.object({ serverID: z.string(), + path: z.string(), }), + ), +} + +export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { + const l = log.clone().tag("serverID", input.serverID) + l.info("starting client") + + const connection = createMessageConnection( + new StreamMessageReader(input.server.process.stdout as any), + new StreamMessageWriter(input.server.process.stdin as any), ) - export const Event = { - Diagnostics: BusEvent.define( - "lsp.client.diagnostics", - z.object({ - serverID: z.string(), - path: z.string(), - }), - ), + const diagnostics = new Map() + connection.onNotification("textDocument/publishDiagnostics", (params) => { + const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) + l.info("textDocument/publishDiagnostics", { + path: filePath, + count: params.diagnostics.length, + }) + const exists = diagnostics.has(filePath) + diagnostics.set(filePath, params.diagnostics) + if (!exists && input.serverID === "typescript") return + Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + }) + connection.onRequest("window/workDoneProgress/create", (params) => { + l.info("window/workDoneProgress/create", params) + return null + }) + connection.onRequest("workspace/configuration", async () => { + // Return server initialization options + return [input.server.initialization ?? {}] + }) + connection.onRequest("client/registerCapability", async () => {}) + connection.onRequest("client/unregisterCapability", async () => {}) + connection.onRequest("workspace/workspaceFolders", async () => [ + { + name: "workspace", + uri: pathToFileURL(input.root).href, + }, + ]) + connection.listen() + + l.info("sending initialize") + await withTimeout( + connection.sendRequest("initialize", { + rootUri: pathToFileURL(input.root).href, + processId: input.server.process.pid, + workspaceFolders: [ + { + name: "workspace", + uri: pathToFileURL(input.root).href, + }, + ], + initializationOptions: { + ...input.server.initialization, + }, + capabilities: { + window: { + workDoneProgress: true, + }, + workspace: { + configuration: true, + didChangeWatchedFiles: { + dynamicRegistration: true, + }, + }, + textDocument: { + synchronization: { + didOpen: true, + didChange: true, + }, + publishDiagnostics: { + versionSupport: true, + }, + }, + }, + }), + 45_000, + ).catch((err) => { + l.error("initialize error", { error: err }) + throw new InitializeError( + { serverID: input.serverID }, + { + cause: err, + }, + ) + }) + + await connection.sendNotification("initialized", {}) + + if (input.server.initialization) { + await connection.sendNotification("workspace/didChangeConfiguration", { + settings: input.server.initialization, + }) } - export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { - const l = log.clone().tag("serverID", input.serverID) - l.info("starting client") + const files: { + [path: string]: number + } = {} - const connection = createMessageConnection( - new StreamMessageReader(input.server.process.stdout as any), - new StreamMessageWriter(input.server.process.stdin as any), - ) - - const diagnostics = new Map() - connection.onNotification("textDocument/publishDiagnostics", (params) => { - const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) - l.info("textDocument/publishDiagnostics", { - path: filePath, - count: params.diagnostics.length, - }) - const exists = diagnostics.has(filePath) - diagnostics.set(filePath, params.diagnostics) - if (!exists && input.serverID === "typescript") return - void Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) - }) - connection.onRequest("window/workDoneProgress/create", (params) => { - l.info("window/workDoneProgress/create", params) - return null - }) - connection.onRequest("workspace/configuration", async () => { - // Return server initialization options - return [input.server.initialization ?? {}] - }) - connection.onRequest("client/registerCapability", async () => {}) - connection.onRequest("client/unregisterCapability", async () => {}) - connection.onRequest("workspace/workspaceFolders", async () => [ - { - name: "workspace", - uri: pathToFileURL(input.root).href, - }, - ]) - connection.listen() - - l.info("sending initialize") - await withTimeout( - connection.sendRequest("initialize", { - rootUri: pathToFileURL(input.root).href, - processId: input.server.process.pid, - workspaceFolders: [ - { - name: "workspace", - uri: pathToFileURL(input.root).href, - }, - ], - initializationOptions: { - ...input.server.initialization, - }, - capabilities: { - window: { - workDoneProgress: true, - }, - workspace: { - configuration: true, - didChangeWatchedFiles: { - dynamicRegistration: true, - }, - }, - textDocument: { - synchronization: { - didOpen: true, - didChange: true, - }, - publishDiagnostics: { - versionSupport: true, - }, - }, - }, - }), - 45_000, - ).catch((err) => { - l.error("initialize error", { error: err }) - throw new InitializeError( - { serverID: input.serverID }, - { - cause: err, - }, - ) - }) - - await connection.sendNotification("initialized", {}) - - if (input.server.initialization) { - await connection.sendNotification("workspace/didChangeConfiguration", { - settings: input.server.initialization, - }) - } - - const files: { - [path: string]: number - } = {} - - const result = { - root: input.root, - get serverID() { - return input.serverID - }, - get connection() { - return connection - }, - notify: { - async open(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) - const text = await Filesystem.readText(input.path) - const extension = path.extname(input.path) - const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - - const version = files[input.path] - if (version !== undefined) { - log.info("workspace/didChangeWatchedFiles", input) - await connection.sendNotification("workspace/didChangeWatchedFiles", { - changes: [ - { - uri: pathToFileURL(input.path).href, - type: 2, // Changed - }, - ], - }) - - const next = version + 1 - files[input.path] = next - log.info("textDocument/didChange", { - path: input.path, - version: next, - }) - await connection.sendNotification("textDocument/didChange", { - textDocument: { - uri: pathToFileURL(input.path).href, - version: next, - }, - contentChanges: [{ text }], - }) - return - } + const result = { + root: input.root, + get serverID() { + return input.serverID + }, + get connection() { + return connection + }, + notify: { + async open(input: { path: string }) { + input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) + const text = await Filesystem.readText(input.path) + const extension = path.extname(input.path) + const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" + const version = files[input.path] + if (version !== undefined) { log.info("workspace/didChangeWatchedFiles", input) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { uri: pathToFileURL(input.path).href, - type: 1, // Created + type: 2, // Changed }, ], }) - log.info("textDocument/didOpen", input) - diagnostics.delete(input.path) - await connection.sendNotification("textDocument/didOpen", { + const next = version + 1 + files[input.path] = next + log.info("textDocument/didChange", { + path: input.path, + version: next, + }) + await connection.sendNotification("textDocument/didChange", { textDocument: { uri: pathToFileURL(input.path).href, - languageId, - version: 0, - text, + version: next, }, + contentChanges: [{ text }], }) - files[input.path] = 0 return - }, + } + + log.info("workspace/didChangeWatchedFiles", input) + await connection.sendNotification("workspace/didChangeWatchedFiles", { + changes: [ + { + uri: pathToFileURL(input.path).href, + type: 1, // Created + }, + ], + }) + + log.info("textDocument/didOpen", input) + diagnostics.delete(input.path) + await connection.sendNotification("textDocument/didOpen", { + textDocument: { + uri: pathToFileURL(input.path).href, + languageId, + version: 0, + text, + }, + }) + files[input.path] = 0 + return }, - get diagnostics() { - return diagnostics - }, - async waitForDiagnostics(input: { path: string }) { - const normalizedPath = Filesystem.normalizePath( - path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), - ) - log.info("waiting for diagnostics", { path: normalizedPath }) - let unsub: () => void - let debounceTimer: ReturnType | undefined - return await withTimeout( - new Promise((resolve) => { - unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) { - // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax) - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => { - log.info("got diagnostics", { path: normalizedPath }) - unsub?.() - resolve() - }, DIAGNOSTICS_DEBOUNCE_MS) - } - }) - }), - 3000, - ) - .catch(() => {}) - .finally(() => { - if (debounceTimer) clearTimeout(debounceTimer) - unsub?.() + }, + get diagnostics() { + return diagnostics + }, + async waitForDiagnostics(input: { path: string }) { + const normalizedPath = Filesystem.normalizePath( + path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path), + ) + log.info("waiting for diagnostics", { path: normalizedPath }) + let unsub: () => void + let debounceTimer: ReturnType | undefined + return await withTimeout( + new Promise((resolve) => { + unsub = Bus.subscribe(Event.Diagnostics, (event) => { + if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) { + // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax) + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + log.info("got diagnostics", { path: normalizedPath }) + unsub?.() + resolve() + }, DIAGNOSTICS_DEBOUNCE_MS) + } }) - }, - async shutdown() { - l.info("shutting down") - connection.end() - connection.dispose() - await Process.stop(input.server.process) - l.info("shutdown") - }, - } - - l.info("initialized") - - return result + }), + 3000, + ) + .catch(() => {}) + .finally(() => { + if (debounceTimer) clearTimeout(debounceTimer) + unsub?.() + }) + }, + async shutdown() { + l.info("shutting down") + connection.end() + connection.dispose() + await Process.stop(input.server.process) + l.info("shutdown") + }, } + + l.info("initialized") + + return result } diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 5146c40abe..9fc06fa21b 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -1,537 +1,3 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { Log } from "../util" -import { LSPClient } from "./client" -import path from "path" -import { pathToFileURL, fileURLToPath } from "url" -import { LSPServer } from "./server" -import z from "zod" -import { Config } from "../config" -import { Instance } from "../project/instance" -import { Flag } from "@/flag/flag" -import { Process } from "../util" -import { spawn as lspspawn } from "./launch" -import { Effect, Layer, Context } from "effect" -import { InstanceState } from "@/effect" - -export namespace LSP { - const log = Log.create({ service: "lsp" }) - - export const Event = { - Updated: BusEvent.define("lsp.updated", z.object({})), - } - - export const Range = z - .object({ - start: z.object({ - line: z.number(), - character: z.number(), - }), - end: z.object({ - line: z.number(), - character: z.number(), - }), - }) - .meta({ - ref: "Range", - }) - export type Range = z.infer - - export const Symbol = z - .object({ - name: z.string(), - kind: z.number(), - location: z.object({ - uri: z.string(), - range: Range, - }), - }) - .meta({ - ref: "Symbol", - }) - export type Symbol = z.infer - - export const DocumentSymbol = z - .object({ - name: z.string(), - detail: z.string().optional(), - kind: z.number(), - range: Range, - selectionRange: Range, - }) - .meta({ - ref: "DocumentSymbol", - }) - export type DocumentSymbol = z.infer - - export const Status = z - .object({ - id: z.string(), - name: z.string(), - root: z.string(), - status: z.union([z.literal("connected"), z.literal("error")]), - }) - .meta({ - ref: "LSPStatus", - }) - export type Status = z.infer - - enum SymbolKind { - File = 1, - Module = 2, - Namespace = 3, - Package = 4, - Class = 5, - Method = 6, - Property = 7, - Field = 8, - Constructor = 9, - Enum = 10, - Interface = 11, - Function = 12, - Variable = 13, - Constant = 14, - String = 15, - Number = 16, - Boolean = 17, - Array = 18, - Object = 19, - Key = 20, - Null = 21, - EnumMember = 22, - Struct = 23, - Event = 24, - Operator = 25, - TypeParameter = 26, - } - - const kinds = [ - SymbolKind.Class, - SymbolKind.Function, - SymbolKind.Method, - SymbolKind.Interface, - SymbolKind.Variable, - SymbolKind.Constant, - SymbolKind.Struct, - SymbolKind.Enum, - ] - - const filterExperimentalServers = (servers: Record) => { - if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) { - if (servers["pyright"]) { - log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled") - delete servers["pyright"] - } - } else { - if (servers["ty"]) { - delete servers["ty"] - } - } - } - - type LocInput = { file: string; line: number; character: number } - - interface State { - clients: LSPClient.Info[] - servers: Record - broken: Set - spawning: Map> - } - - export interface Interface { - readonly init: () => Effect.Effect - readonly status: () => Effect.Effect - readonly hasClients: (file: string) => Effect.Effect - readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect - readonly diagnostics: () => Effect.Effect> - readonly hover: (input: LocInput) => Effect.Effect - readonly definition: (input: LocInput) => Effect.Effect - readonly references: (input: LocInput) => Effect.Effect - readonly implementation: (input: LocInput) => Effect.Effect - readonly documentSymbol: (uri: string) => Effect.Effect<(LSP.DocumentSymbol | LSP.Symbol)[]> - readonly workspaceSymbol: (query: string) => Effect.Effect - readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect - readonly incomingCalls: (input: LocInput) => Effect.Effect - readonly outgoingCalls: (input: LocInput) => Effect.Effect - } - - export class Service extends Context.Service()("@opencode/LSP") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - - const state = yield* InstanceState.make( - Effect.fn("LSP.state")(function* () { - const cfg = yield* config.get() - - const servers: Record = {} - - if (cfg.lsp === false) { - log.info("all LSPs are disabled") - } else { - for (const server of Object.values(LSPServer)) { - servers[server.id] = server - } - - filterExperimentalServers(servers) - - for (const [name, item] of Object.entries(cfg.lsp ?? {})) { - const existing = servers[name] - if (item.disabled) { - log.info(`LSP server ${name} is disabled`) - delete servers[name] - continue - } - servers[name] = { - ...existing, - id: name, - root: existing?.root ?? (async () => Instance.directory), - extensions: item.extensions ?? existing?.extensions ?? [], - spawn: async (root) => ({ - process: lspspawn(item.command[0], item.command.slice(1), { - cwd: root, - env: { ...process.env, ...item.env }, - }), - initialization: item.initialization, - }), - } - } - - log.info("enabled LSP servers", { - serverIds: Object.values(servers) - .map((server) => server.id) - .join(", "), - }) - } - - const s: State = { - clients: [], - servers, - broken: new Set(), - spawning: new Map(), - } - - yield* Effect.addFinalizer(() => - Effect.promise(async () => { - await Promise.all(s.clients.map((client) => client.shutdown())) - }), - ) - - return s - }), - ) - - const getClients = Effect.fnUntraced(function* (file: string) { - if (!Instance.containsPath(file)) return [] as LSPClient.Info[] - const s = yield* InstanceState.get(state) - return yield* Effect.promise(async () => { - const extension = path.parse(file).ext || file - const result: LSPClient.Info[] = [] - - async function schedule(server: LSPServer.Info, root: string, key: string) { - const handle = await server - .spawn(root) - .then((value) => { - if (!value) s.broken.add(key) - return value - }) - .catch((err) => { - s.broken.add(key) - log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) - return undefined - }) - - if (!handle) return undefined - log.info("spawned lsp server", { serverID: server.id, root }) - - const client = await LSPClient.create({ - serverID: server.id, - server: handle, - root, - }).catch(async (err) => { - s.broken.add(key) - await Process.stop(handle.process) - log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) - return undefined - }) - - if (!client) return undefined - - const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (existing) { - await Process.stop(handle.process) - return existing - } - - s.clients.push(client) - return client - } - - for (const server of Object.values(s.servers)) { - if (server.extensions.length && !server.extensions.includes(extension)) continue - - const root = await server.root(file) - if (!root) continue - if (s.broken.has(root + server.id)) continue - - const match = s.clients.find((x) => x.root === root && x.serverID === server.id) - if (match) { - result.push(match) - continue - } - - const inflight = s.spawning.get(root + server.id) - if (inflight) { - const client = await inflight - if (!client) continue - result.push(client) - continue - } - - const task = schedule(server, root, root + server.id) - s.spawning.set(root + server.id, task) - - void task.finally(() => { - if (s.spawning.get(root + server.id) === task) { - s.spawning.delete(root + server.id) - } - }) - - const client = await task - if (!client) continue - - result.push(client) - void Bus.publish(Event.Updated, {}) - } - - return result - }) - }) - - const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Promise) { - const clients = yield* getClients(file) - return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x)))) - }) - - const runAll = Effect.fnUntraced(function* (fn: (client: LSPClient.Info) => Promise) { - const s = yield* InstanceState.get(state) - return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x)))) - }) - - const init = Effect.fn("LSP.init")(function* () { - yield* InstanceState.get(state) - }) - - const status = Effect.fn("LSP.status")(function* () { - const s = yield* InstanceState.get(state) - const result: Status[] = [] - for (const client of s.clients) { - result.push({ - id: client.serverID, - name: s.servers[client.serverID].id, - root: path.relative(Instance.directory, client.root), - status: "connected", - }) - } - return result - }) - - const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { - const s = yield* InstanceState.get(state) - return yield* Effect.promise(async () => { - const extension = path.parse(file).ext || file - for (const server of Object.values(s.servers)) { - if (server.extensions.length && !server.extensions.includes(extension)) continue - const root = await server.root(file) - if (!root) continue - if (s.broken.has(root + server.id)) continue - return true - } - return false - }) - }) - - const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { - log.info("touching file", { file: input }) - const clients = yield* getClients(input) - yield* Effect.promise(() => - Promise.all( - clients.map(async (client) => { - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() - await client.notify.open({ path: input }) - return wait - }), - ).catch((err) => { - log.error("failed to touch file", { err, file: input }) - }), - ) - }) - - const diagnostics = Effect.fn("LSP.diagnostics")(function* () { - const results: Record = {} - const all = yield* runAll(async (client) => client.diagnostics) - for (const result of all) { - for (const [p, diags] of result.entries()) { - const arr = results[p] || [] - arr.push(...diags) - results[p] = arr - } - } - return results - }) - - const hover = Effect.fn("LSP.hover")(function* (input: LocInput) { - return yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/hover", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ) - }) - - const definition = Effect.fn("LSP.definition")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/definition", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ) - return results.flat().filter(Boolean) - }) - - const references = Effect.fn("LSP.references")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/references", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - context: { includeDeclaration: true }, - }) - .catch(() => []), - ) - return results.flat().filter(Boolean) - }) - - const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/implementation", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => null), - ) - return results.flat().filter(Boolean) - }) - - const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) { - const file = fileURLToPath(uri) - const results = yield* run(file, (client) => - client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []), - ) - return (results.flat() as (LSP.DocumentSymbol | LSP.Symbol)[]).filter(Boolean) - }) - - const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { - const results = yield* runAll((client) => - client.connection - .sendRequest("workspace/symbol", { query }) - .then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind))) - .then((result: any) => result.slice(0, 10)) - .catch(() => []), - ) - return results.flat() as LSP.Symbol[] - }) - - const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { - const results = yield* run(input.file, (client) => - client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => []), - ) - return results.flat().filter(Boolean) - }) - - const callHierarchyRequest = Effect.fnUntraced(function* ( - input: LocInput, - direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls", - ) { - const results = yield* run(input.file, async (client) => { - const items = (await client.connection - .sendRequest("textDocument/prepareCallHierarchy", { - textDocument: { uri: pathToFileURL(input.file).href }, - position: { line: input.line, character: input.character }, - }) - .catch(() => [])) as any[] - if (!items?.length) return [] - return client.connection.sendRequest(direction, { item: items[0] }).catch(() => []) - }) - return results.flat().filter(Boolean) - }) - - const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) { - return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls") - }) - - const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) { - return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls") - }) - - return Service.of({ - init, - status, - hasClients, - touchFile, - diagnostics, - hover, - definition, - references, - implementation, - documentSymbol, - workspaceSymbol, - prepareCallHierarchy, - incomingCalls, - outgoingCalls, - }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - - export namespace Diagnostic { - const MAX_PER_FILE = 20 - - export function pretty(diagnostic: LSPClient.Diagnostic) { - const severityMap = { - 1: "ERROR", - 2: "WARN", - 3: "INFO", - 4: "HINT", - } - - const severity = severityMap[diagnostic.severity || 1] - const line = diagnostic.range.start.line + 1 - const col = diagnostic.range.start.character + 1 - - return `${severity} [${line}:${col}] ${diagnostic.message}` - } - - export function report(file: string, issues: LSPClient.Diagnostic[]) { - const errors = issues.filter((item) => item.severity === 1) - if (errors.length === 0) return "" - const limited = errors.slice(0, MAX_PER_FILE) - const more = errors.length - MAX_PER_FILE - const suffix = more > 0 ? `\n... and ${more} more` : "" - return `\n${limited.map(pretty).join("\n")}${suffix}\n` - } - } -} +export * as LSP from "./lsp" +export * as LSPClient from "./client" +export * as LSPServer from "./server" diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts new file mode 100644 index 0000000000..7f5b36313d --- /dev/null +++ b/packages/opencode/src/lsp/lsp.ts @@ -0,0 +1,535 @@ +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import { Log } from "../util" +import { LSPClient } from "." +import path from "path" +import { pathToFileURL, fileURLToPath } from "url" +import { LSPServer } from "." +import z from "zod" +import { Config } from "../config" +import { Instance } from "../project/instance" +import { Flag } from "@/flag/flag" +import { Process } from "../util" +import { spawn as lspspawn } from "./launch" +import { Effect, Layer, Context } from "effect" +import { InstanceState } from "@/effect" + +const log = Log.create({ service: "lsp" }) + +export const Event = { + Updated: BusEvent.define("lsp.updated", z.object({})), +} + +export const Range = z + .object({ + start: z.object({ + line: z.number(), + character: z.number(), + }), + end: z.object({ + line: z.number(), + character: z.number(), + }), + }) + .meta({ + ref: "Range", + }) +export type Range = z.infer + +export const Symbol = z + .object({ + name: z.string(), + kind: z.number(), + location: z.object({ + uri: z.string(), + range: Range, + }), + }) + .meta({ + ref: "Symbol", + }) +export type Symbol = z.infer + +export const DocumentSymbol = z + .object({ + name: z.string(), + detail: z.string().optional(), + kind: z.number(), + range: Range, + selectionRange: Range, + }) + .meta({ + ref: "DocumentSymbol", + }) +export type DocumentSymbol = z.infer + +export const Status = z + .object({ + id: z.string(), + name: z.string(), + root: z.string(), + status: z.union([z.literal("connected"), z.literal("error")]), + }) + .meta({ + ref: "LSPStatus", + }) +export type Status = z.infer + +enum SymbolKind { + File = 1, + Module = 2, + Namespace = 3, + Package = 4, + Class = 5, + Method = 6, + Property = 7, + Field = 8, + Constructor = 9, + Enum = 10, + Interface = 11, + Function = 12, + Variable = 13, + Constant = 14, + String = 15, + Number = 16, + Boolean = 17, + Array = 18, + Object = 19, + Key = 20, + Null = 21, + EnumMember = 22, + Struct = 23, + Event = 24, + Operator = 25, + TypeParameter = 26, +} + +const kinds = [ + SymbolKind.Class, + SymbolKind.Function, + SymbolKind.Method, + SymbolKind.Interface, + SymbolKind.Variable, + SymbolKind.Constant, + SymbolKind.Struct, + SymbolKind.Enum, +] + +const filterExperimentalServers = (servers: Record) => { + if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) { + if (servers["pyright"]) { + log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled") + delete servers["pyright"] + } + } else { + if (servers["ty"]) { + delete servers["ty"] + } + } +} + +type LocInput = { file: string; line: number; character: number } + +interface State { + clients: LSPClient.Info[] + servers: Record + broken: Set + spawning: Map> +} + +export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly hasClients: (file: string) => Effect.Effect + readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect + readonly diagnostics: () => Effect.Effect> + readonly hover: (input: LocInput) => Effect.Effect + readonly definition: (input: LocInput) => Effect.Effect + readonly references: (input: LocInput) => Effect.Effect + readonly implementation: (input: LocInput) => Effect.Effect + readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]> + readonly workspaceSymbol: (query: string) => Effect.Effect + readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect + readonly incomingCalls: (input: LocInput) => Effect.Effect + readonly outgoingCalls: (input: LocInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/LSP") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + + const state = yield* InstanceState.make( + Effect.fn("LSP.state")(function* () { + const cfg = yield* config.get() + + const servers: Record = {} + + if (cfg.lsp === false) { + log.info("all LSPs are disabled") + } else { + for (const server of Object.values(LSPServer)) { + servers[server.id] = server + } + + filterExperimentalServers(servers) + + for (const [name, item] of Object.entries(cfg.lsp ?? {})) { + const existing = servers[name] + if (item.disabled) { + log.info(`LSP server ${name} is disabled`) + delete servers[name] + continue + } + servers[name] = { + ...existing, + id: name, + root: existing?.root ?? (async () => Instance.directory), + extensions: item.extensions ?? existing?.extensions ?? [], + spawn: async (root) => ({ + process: lspspawn(item.command[0], item.command.slice(1), { + cwd: root, + env: { ...process.env, ...item.env }, + }), + initialization: item.initialization, + }), + } + } + + log.info("enabled LSP servers", { + serverIds: Object.values(servers) + .map((server) => server.id) + .join(", "), + }) + } + + const s: State = { + clients: [], + servers, + broken: new Set(), + spawning: new Map(), + } + + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await Promise.all(s.clients.map((client) => client.shutdown())) + }), + ) + + return s + }), + ) + + const getClients = Effect.fnUntraced(function* (file: string) { + if (!Instance.containsPath(file)) return [] as LSPClient.Info[] + const s = yield* InstanceState.get(state) + return yield* Effect.promise(async () => { + const extension = path.parse(file).ext || file + const result: LSPClient.Info[] = [] + + async function schedule(server: LSPServer.Info, root: string, key: string) { + const handle = await server + .spawn(root) + .then((value) => { + if (!value) s.broken.add(key) + return value + }) + .catch((err) => { + s.broken.add(key) + log.error(`Failed to spawn LSP server ${server.id}`, { error: err }) + return undefined + }) + + if (!handle) return undefined + log.info("spawned lsp server", { serverID: server.id, root }) + + const client = await LSPClient.create({ + serverID: server.id, + server: handle, + root, + }).catch(async (err) => { + s.broken.add(key) + await Process.stop(handle.process) + log.error(`Failed to initialize LSP client ${server.id}`, { error: err }) + return undefined + }) + + if (!client) return undefined + + const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (existing) { + await Process.stop(handle.process) + return existing + } + + s.clients.push(client) + return client + } + + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue + + const root = await server.root(file) + if (!root) continue + if (s.broken.has(root + server.id)) continue + + const match = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (match) { + result.push(match) + continue + } + + const inflight = s.spawning.get(root + server.id) + if (inflight) { + const client = await inflight + if (!client) continue + result.push(client) + continue + } + + const task = schedule(server, root, root + server.id) + s.spawning.set(root + server.id, task) + + task.finally(() => { + if (s.spawning.get(root + server.id) === task) { + s.spawning.delete(root + server.id) + } + }) + + const client = await task + if (!client) continue + + result.push(client) + Bus.publish(Event.Updated, {}) + } + + return result + }) + }) + + const run = Effect.fnUntraced(function* (file: string, fn: (client: LSPClient.Info) => Promise) { + const clients = yield* getClients(file) + return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x)))) + }) + + const runAll = Effect.fnUntraced(function* (fn: (client: LSPClient.Info) => Promise) { + const s = yield* InstanceState.get(state) + return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x)))) + }) + + const init = Effect.fn("LSP.init")(function* () { + yield* InstanceState.get(state) + }) + + const status = Effect.fn("LSP.status")(function* () { + const s = yield* InstanceState.get(state) + const result: Status[] = [] + for (const client of s.clients) { + result.push({ + id: client.serverID, + name: s.servers[client.serverID].id, + root: path.relative(Instance.directory, client.root), + status: "connected", + }) + } + return result + }) + + const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) { + const s = yield* InstanceState.get(state) + return yield* Effect.promise(async () => { + const extension = path.parse(file).ext || file + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(extension)) continue + const root = await server.root(file) + if (!root) continue + if (s.broken.has(root + server.id)) continue + return true + } + return false + }) + }) + + const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { + log.info("touching file", { file: input }) + const clients = yield* getClients(input) + yield* Effect.promise(() => + Promise.all( + clients.map(async (client) => { + const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() + await client.notify.open({ path: input }) + return wait + }), + ).catch((err) => { + log.error("failed to touch file", { err, file: input }) + }), + ) + }) + + const diagnostics = Effect.fn("LSP.diagnostics")(function* () { + const results: Record = {} + const all = yield* runAll(async (client) => client.diagnostics) + for (const result of all) { + for (const [p, diags] of result.entries()) { + const arr = results[p] || [] + arr.push(...diags) + results[p] = arr + } + } + return results + }) + + const hover = Effect.fn("LSP.hover")(function* (input: LocInput) { + return yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/hover", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + }) + + const definition = Effect.fn("LSP.definition")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/definition", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + return results.flat().filter(Boolean) + }) + + const references = Effect.fn("LSP.references")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/references", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + context: { includeDeclaration: true }, + }) + .catch(() => []), + ) + return results.flat().filter(Boolean) + }) + + const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/implementation", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => null), + ) + return results.flat().filter(Boolean) + }) + + const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) { + const file = fileURLToPath(uri) + const results = yield* run(file, (client) => + client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []), + ) + return (results.flat() as (DocumentSymbol | Symbol)[]).filter(Boolean) + }) + + const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) { + const results = yield* runAll((client) => + client.connection + .sendRequest("workspace/symbol", { query }) + .then((result: any) => result.filter((x: Symbol) => kinds.includes(x.kind))) + .then((result: any) => result.slice(0, 10)) + .catch(() => []), + ) + return results.flat() as Symbol[] + }) + + const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) { + const results = yield* run(input.file, (client) => + client.connection + .sendRequest("textDocument/prepareCallHierarchy", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => []), + ) + return results.flat().filter(Boolean) + }) + + const callHierarchyRequest = Effect.fnUntraced(function* ( + input: LocInput, + direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls", + ) { + const results = yield* run(input.file, async (client) => { + const items = (await client.connection + .sendRequest("textDocument/prepareCallHierarchy", { + textDocument: { uri: pathToFileURL(input.file).href }, + position: { line: input.line, character: input.character }, + }) + .catch(() => [])) as any[] + if (!items?.length) return [] + return client.connection.sendRequest(direction, { item: items[0] }).catch(() => []) + }) + return results.flat().filter(Boolean) + }) + + const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) { + return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls") + }) + + const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) { + return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls") + }) + + return Service.of({ + init, + status, + hasClients, + touchFile, + diagnostics, + hover, + definition, + references, + implementation, + documentSymbol, + workspaceSymbol, + prepareCallHierarchy, + incomingCalls, + outgoingCalls, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + +export namespace Diagnostic { + const MAX_PER_FILE = 20 + + export function pretty(diagnostic: LSPClient.Diagnostic) { + const severityMap = { + 1: "ERROR", + 2: "WARN", + 3: "INFO", + 4: "HINT", + } + + const severity = severityMap[diagnostic.severity || 1] + const line = diagnostic.range.start.line + 1 + const col = diagnostic.range.start.character + 1 + + return `${severity} [${line}:${col}] ${diagnostic.message}` + } + + export function report(file: string, issues: LSPClient.Diagnostic[]) { + const errors = issues.filter((item) => item.severity === 1) + if (errors.length === 0) return "" + const limited = errors.slice(0, MAX_PER_FILE) + const more = errors.length - MAX_PER_FILE + const suffix = more > 0 ? `\n... and ${more} more` : "" + return `\n${limited.map(pretty).join("\n")}${suffix}\n` + } +} diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 8110e86082..25aaaa36a4 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -15,972 +15,673 @@ import { Module } from "@opencode-ai/shared/util/module" import { spawn } from "./launch" import { Npm } from "../npm" -export namespace LSPServer { - const log = Log.create({ service: "lsp.server" }) - const pathExists = async (p: string) => - fs - .stat(p) - .then(() => true) - .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 log = Log.create({ service: "lsp.server" }) +const pathExists = async (p: string) => + fs + .stat(p) + .then(() => true) + .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 }) - export interface Handle { - process: ChildProcessWithoutNullStreams - initialization?: Record - } +export interface Handle { + process: ChildProcessWithoutNullStreams + initialization?: Record +} - type RootFunction = (file: string) => Promise +type RootFunction = (file: string) => Promise - const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { - return async (file) => { - if (excludePatterns) { - const excludedFiles = Filesystem.up({ - targets: excludePatterns, - start: path.dirname(file), - stop: Instance.directory, - }) - const excluded = await excludedFiles.next() - await excludedFiles.return() - if (excluded.value) return undefined - } - const files = Filesystem.up({ - targets: includePatterns, +const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => { + return async (file) => { + if (excludePatterns) { + const excludedFiles = Filesystem.up({ + targets: excludePatterns, start: path.dirname(file), stop: Instance.directory, }) - const first = await files.next() - await files.return() - if (!first.value) return Instance.directory - return path.dirname(first.value) + const excluded = await excludedFiles.next() + await excludedFiles.return() + if (excluded.value) return undefined } + const files = Filesystem.up({ + targets: includePatterns, + start: path.dirname(file), + stop: Instance.directory, + }) + const first = await files.next() + await files.return() + if (!first.value) return Instance.directory + return path.dirname(first.value) } +} - export interface Info { - id: string - extensions: string[] - global?: boolean - root: RootFunction - spawn(root: string): Promise - } +export interface Info { + id: string + extensions: string[] + global?: boolean + root: RootFunction + spawn(root: string): Promise +} - export const Deno: Info = { - id: "deno", - root: async (file) => { - const files = Filesystem.up({ - targets: ["deno.json", "deno.jsonc"], - start: path.dirname(file), - stop: Instance.directory, - }) - const first = await files.next() - await files.return() - if (!first.value) return undefined - return path.dirname(first.value) - }, - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], - async spawn(root) { - const deno = which("deno") - if (!deno) { - log.info("deno not found, please install deno first") - return - } - return { - process: spawn(deno, ["lsp"], { - cwd: root, - }), - } - }, - } - - export const Typescript: Info = { - id: "typescript", - root: NearestRoot( - ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"], - ["deno.json", "deno.jsonc"], - ), - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], - async spawn(root) { - const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) - log.info("typescript server", { tsserver }) - if (!tsserver) return - const bin = await Npm.which("typescript-language-server") - if (!bin) return - const proc = spawn(bin, ["--stdio"], { - cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, - initialization: { - tsserver: { - path: tsserver, - }, - }, - } - }, - } - - export const Vue: Info = { - id: "vue", - extensions: [".vue"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - let binary = which("vue-language-server") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("@vue/language-server") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, - initialization: { - // Leave empty; the server will auto-detect workspace TypeScript. - }, - } - }, - } - - export const ESLint: Info = { - id: "eslint", - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], - async spawn(root) { - const eslint = Module.resolve("eslint", Instance.directory) - if (!eslint) return - log.info("spawning eslint server") - const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") - if (!(await Filesystem.exists(serverPath))) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading and building VS Code ESLint server") - const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") - if (!response.ok) return - - const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip") - if (response.body) await Filesystem.writeStream(zipPath, response.body) - - const ok = await Archive.extractZip(zipPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract vscode-eslint archive", { error }) - return false - }) - if (!ok) return - await fs.rm(zipPath, { force: true }) - - const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main") - const finalPath = path.join(Global.Path.bin, "vscode-eslint") - - const stats = await fs.stat(finalPath).catch(() => undefined) - if (stats) { - log.info("removing old eslint installation", { path: finalPath }) - await fs.rm(finalPath, { force: true, recursive: true }) - } - await fs.rename(extractedPath, finalPath) - - const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" - await Process.run([npmCmd, "install"], { cwd: finalPath }) - await Process.run([npmCmd, "run", "compile"], { cwd: finalPath }) - - log.info("installed VS Code ESLint server", { serverPath }) - } - - const proc = spawn("node", [serverPath, "--stdio"], { - cwd: root, - env: { - ...process.env, - }, - }) - - return { - process: proc, - } - }, - } - - export const Oxlint: Info = { - id: "oxlint", - root: NearestRoot([ - ".oxlintrc.json", - "package-lock.json", - "bun.lockb", - "bun.lock", - "pnpm-lock.yaml", - "yarn.lock", - "package.json", - ]), - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], - async spawn(root) { - const ext = process.platform === "win32" ? ".cmd" : "" - - const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext) - const lintTarget = path.join("node_modules", ".bin", "oxlint" + ext) - - const resolveBin = async (target: string) => { - const localBin = path.join(root, target) - if (await Filesystem.exists(localBin)) return localBin - - const candidates = Filesystem.up({ - targets: [target], - start: root, - stop: Instance.worktree, - }) - const first = await candidates.next() - await candidates.return() - if (first.value) return first.value - - return undefined - } - - let lintBin = await resolveBin(lintTarget) - if (!lintBin) { - const found = which("oxlint") - if (found) lintBin = found - } - - if (lintBin) { - const proc = spawn(lintBin, ["--help"]) - await proc.exited - if (proc.stdout) { - const help = await text(proc.stdout) - if (help.includes("--lsp")) { - return { - process: spawn(lintBin, ["--lsp"], { - cwd: root, - }), - } - } - } - } - - let serverBin = await resolveBin(serverTarget) - if (!serverBin) { - const found = which("oxc_language_server") - if (found) serverBin = found - } - if (serverBin) { - return { - process: spawn(serverBin, [], { - cwd: root, - }), - } - } - - log.info("oxlint not found, please install oxlint") +export const Deno: Info = { + id: "deno", + root: async (file) => { + const files = Filesystem.up({ + targets: ["deno.json", "deno.jsonc"], + start: path.dirname(file), + stop: Instance.directory, + }) + const first = await files.next() + await files.return() + if (!first.value) return undefined + return path.dirname(first.value) + }, + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + async spawn(root) { + const deno = which("deno") + if (!deno) { + log.info("deno not found, please install deno first") return - }, - } - - export const Biome: Info = { - id: "biome", - root: NearestRoot([ - "biome.json", - "biome.jsonc", - "package-lock.json", - "bun.lockb", - "bun.lock", - "pnpm-lock.yaml", - "yarn.lock", - ]), - extensions: [ - ".ts", - ".tsx", - ".js", - ".jsx", - ".mjs", - ".cjs", - ".mts", - ".cts", - ".json", - ".jsonc", - ".vue", - ".astro", - ".svelte", - ".css", - ".graphql", - ".gql", - ".html", - ], - async spawn(root) { - const localBin = path.join(root, "node_modules", ".bin", "biome") - let bin: string | undefined - if (await Filesystem.exists(localBin)) bin = localBin - if (!bin) { - const found = which("biome") - if (found) bin = found - } - - let args = ["lsp-proxy", "--stdio"] - - if (!bin) { - const resolved = Module.resolve("biome", root) - if (!resolved) return - bin = await Npm.which("biome") - if (!bin) return - args = ["lsp-proxy", "--stdio"] - } - - const proc = spawn(bin, args, { + } + return { + process: spawn(deno, ["lsp"], { cwd: root, - env: { - ...process.env, + }), + } + }, +} + +export const Typescript: Info = { + id: "typescript", + root: NearestRoot( + ["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"], + ["deno.json", "deno.jsonc"], + ), + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], + async spawn(root) { + const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) + log.info("typescript server", { tsserver }) + if (!tsserver) return + const bin = await Npm.which("typescript-language-server") + if (!bin) return + const proc = spawn(bin, ["--stdio"], { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + tsserver: { + path: tsserver, }, + }, + } + }, +} + +export const Vue: Info = { + id: "vue", + extensions: [".vue"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root) { + let binary = which("vue-language-server") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("@vue/language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + // Leave empty; the server will auto-detect workspace TypeScript. + }, + } + }, +} + +export const ESLint: Info = { + id: "eslint", + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"], + async spawn(root) { + const eslint = Module.resolve("eslint", Instance.directory) + if (!eslint) return + log.info("spawning eslint server") + const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js") + if (!(await Filesystem.exists(serverPath))) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading and building VS Code ESLint server") + const response = await fetch("https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip") + if (!response.ok) return + + const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip") + if (response.body) await Filesystem.writeStream(zipPath, response.body) + + const ok = await Archive.extractZip(zipPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract vscode-eslint archive", { error }) + return false + }) + if (!ok) return + await fs.rm(zipPath, { force: true }) + + const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main") + const finalPath = path.join(Global.Path.bin, "vscode-eslint") + + const stats = await fs.stat(finalPath).catch(() => undefined) + if (stats) { + log.info("removing old eslint installation", { path: finalPath }) + await fs.rm(finalPath, { force: true, recursive: true }) + } + await fs.rename(extractedPath, finalPath) + + const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm" + await Process.run([npmCmd, "install"], { cwd: finalPath }) + await Process.run([npmCmd, "run", "compile"], { cwd: finalPath }) + + log.info("installed VS Code ESLint server", { serverPath }) + } + + const proc = spawn("node", [serverPath, "--stdio"], { + cwd: root, + env: { + ...process.env, + }, + }) + + return { + process: proc, + } + }, +} + +export const Oxlint: Info = { + id: "oxlint", + root: NearestRoot([ + ".oxlintrc.json", + "package-lock.json", + "bun.lockb", + "bun.lock", + "pnpm-lock.yaml", + "yarn.lock", + "package.json", + ]), + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"], + async spawn(root) { + const ext = process.platform === "win32" ? ".cmd" : "" + + const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext) + const lintTarget = path.join("node_modules", ".bin", "oxlint" + ext) + + const resolveBin = async (target: string) => { + const localBin = path.join(root, target) + if (await Filesystem.exists(localBin)) return localBin + + const candidates = Filesystem.up({ + targets: [target], + start: root, + stop: Instance.worktree, }) - - return { - process: proc, - } - }, - } - - export const Gopls: Info = { - id: "gopls", - root: async (file) => { - const work = await NearestRoot(["go.work"])(file) - if (work) return work - return NearestRoot(["go.mod", "go.sum"])(file) - }, - extensions: [".go"], - async spawn(root) { - let bin = which("gopls") - if (!bin) { - if (!which("go")) return - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - - log.info("installing gopls") - const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], { - env: { ...process.env, GOBIN: Global.Path.bin }, - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install gopls") - return - } - bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed gopls`, { - bin, - }) - } - return { - process: spawn(bin!, { - cwd: root, - }), - } - }, - } - - export const Rubocop: Info = { - id: "ruby-lsp", - root: NearestRoot(["Gemfile"]), - extensions: [".rb", ".rake", ".gemspec", ".ru"], - async spawn(root) { - let bin = which("rubocop") - if (!bin) { - const ruby = which("ruby") - const gem = which("gem") - if (!ruby || !gem) { - log.info("Ruby not found, please install Ruby first") - return - } - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("installing rubocop") - const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], { - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install rubocop") - return - } - bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed rubocop`, { - bin, - }) - } - return { - process: spawn(bin!, ["--lsp"], { - cwd: root, - }), - } - }, - } - - export const Ty: Info = { - id: "ty", - extensions: [".py", ".pyi"], - root: NearestRoot([ - "pyproject.toml", - "ty.toml", - "setup.py", - "setup.cfg", - "requirements.txt", - "Pipfile", - "pyrightconfig.json", - ]), - async spawn(root) { - if (!Flag.OPENCODE_EXPERIMENTAL_LSP_TY) { - return undefined - } - - let binary = which("ty") - - const initialization: Record = {} - - const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( - (p): p is string => p !== undefined, - ) - for (const venvPath of potentialVenvPaths) { - const isWindows = process.platform === "win32" - const potentialPythonPath = isWindows - ? path.join(venvPath, "Scripts", "python.exe") - : path.join(venvPath, "bin", "python") - if (await Filesystem.exists(potentialPythonPath)) { - initialization["pythonPath"] = potentialPythonPath - break - } - } - - if (!binary) { - for (const venvPath of potentialVenvPaths) { - const isWindows = process.platform === "win32" - const potentialTyPath = isWindows - ? path.join(venvPath, "Scripts", "ty.exe") - : path.join(venvPath, "bin", "ty") - if (await Filesystem.exists(potentialTyPath)) { - binary = potentialTyPath - break - } - } - } - - if (!binary) { - log.error("ty not found, please install ty first") - return - } - - const proc = spawn(binary, ["server"], { - cwd: root, - }) - - return { - process: proc, - initialization, - } - }, - } - - export const Pyright: Info = { - id: "pyright", - extensions: [".py", ".pyi"], - root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]), - async spawn(root) { - let binary = which("pyright-langserver") - const args = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("pyright") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - - const initialization: Record = {} - - const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( - (p): p is string => p !== undefined, - ) - for (const venvPath of potentialVenvPaths) { - const isWindows = process.platform === "win32" - const potentialPythonPath = isWindows - ? path.join(venvPath, "Scripts", "python.exe") - : path.join(venvPath, "bin", "python") - if (await Filesystem.exists(potentialPythonPath)) { - initialization["pythonPath"] = potentialPythonPath - break - } - } - - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, - initialization, - } - }, - } - - export const ElixirLS: Info = { - id: "elixir-ls", - extensions: [".ex", ".exs"], - root: NearestRoot(["mix.exs", "mix.lock"]), - async spawn(root) { - let binary = which("elixir-ls") - if (!binary) { - const elixirLsPath = path.join(Global.Path.bin, "elixir-ls") - binary = path.join( - Global.Path.bin, - "elixir-ls-master", - "release", - process.platform === "win32" ? "language_server.bat" : "language_server.sh", - ) - - if (!(await Filesystem.exists(binary))) { - const elixir = which("elixir") - if (!elixir) { - log.error("elixir is required to run elixir-ls") - return - } - - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading elixir-ls from GitHub releases") - - const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") - if (!response.ok) return - const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") - if (response.body) await Filesystem.writeStream(zipPath, response.body) - - const ok = await Archive.extractZip(zipPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract elixir-ls archive", { error }) - return false - }) - if (!ok) return - - await fs.rm(zipPath, { - force: true, - recursive: true, - }) - - const cwd = path.join(Global.Path.bin, "elixir-ls-master") - const env = { MIX_ENV: "prod", ...process.env } - await Process.run(["mix", "deps.get"], { cwd, env }) - await Process.run(["mix", "compile"], { cwd, env }) - await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env }) - - log.info(`installed elixir-ls`, { - path: elixirLsPath, - }) - } - } - - return { - process: spawn(binary, { - cwd: root, - }), - } - }, - } - - export const Zls: Info = { - id: "zls", - extensions: [".zig", ".zon"], - root: NearestRoot(["build.zig"]), - async spawn(root) { - let bin = which("zls") - - if (!bin) { - const zig = which("zig") - if (!zig) { - log.error("Zig is required to use zls. Please install Zig first.") - return - } - - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading zls from GitHub releases") - - const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch zls release info") - return - } - - const release = (await releaseResponse.json()) as any - - const platform = process.platform - const arch = process.arch - let assetName = "" - - let zlsArch: string = arch - if (arch === "arm64") zlsArch = "aarch64" - else if (arch === "x64") zlsArch = "x86_64" - else if (arch === "ia32") zlsArch = "x86" - - let zlsPlatform: string = platform - if (platform === "darwin") zlsPlatform = "macos" - else if (platform === "win32") zlsPlatform = "windows" - - const ext = platform === "win32" ? "zip" : "tar.xz" - - assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}` - - const supportedCombos = [ - "zls-x86_64-linux.tar.xz", - "zls-x86_64-macos.tar.xz", - "zls-x86_64-windows.zip", - "zls-aarch64-linux.tar.xz", - "zls-aarch64-macos.tar.xz", - "zls-aarch64-windows.zip", - "zls-x86-linux.tar.xz", - "zls-x86-windows.zip", - ] - - if (!supportedCombos.includes(assetName)) { - log.error(`Platform ${platform} and architecture ${arch} is not supported by zls`) - return - } - - const asset = release.assets.find((a: any) => a.name === assetName) - if (!asset) { - log.error(`Could not find asset ${assetName} in latest zls release`) - return - } - - const downloadUrl = asset.browser_download_url - const downloadResponse = await fetch(downloadUrl) - if (!downloadResponse.ok) { - log.error("Failed to download zls") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract zls archive", { error }) - return false - }) - if (!ok) return - } else { - await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin }) - } - - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract zls binary") - return - } - - if (platform !== "win32") { - await fs.chmod(bin, 0o755).catch(() => {}) - } - - log.info(`installed zls`, { bin }) - } - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const CSharp: Info = { - id: "csharp", - root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), - extensions: [".cs"], - async spawn(root) { - let bin = which("csharp-ls") - if (!bin) { - if (!which("dotnet")) { - log.error(".NET SDK is required to install csharp-ls") - return - } - - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("installing csharp-ls via dotnet tool") - const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], { - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install csharp-ls") - return - } - - bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed csharp-ls`, { bin }) - } - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const FSharp: Info = { - id: "fsharp", - root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), - extensions: [".fs", ".fsi", ".fsx", ".fsscript"], - async spawn(root) { - let bin = which("fsautocomplete") - if (!bin) { - if (!which("dotnet")) { - log.error(".NET SDK is required to install fsautocomplete") - return - } - - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("installing fsautocomplete via dotnet tool") - const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], { - stdout: "pipe", - stderr: "pipe", - stdin: "pipe", - }) - const exit = await proc.exited - if (exit !== 0) { - log.error("Failed to install fsautocomplete") - return - } - - bin = path.join(Global.Path.bin, "fsautocomplete" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed fsautocomplete`, { bin }) - } - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const SourceKit: Info = { - id: "sourcekit-lsp", - extensions: [".swift", ".objc", "objcpp"], - root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]), - async spawn(root) { - // Check if sourcekit-lsp is available in the PATH - // This is installed with the Swift toolchain - const sourcekit = which("sourcekit-lsp") - if (sourcekit) { - return { - process: spawn(sourcekit, { - cwd: root, - }), - } - } - - // If sourcekit-lsp not found, check if xcrun is available - // This is specific to macOS where sourcekit-lsp is typically installed with Xcode - if (!which("xcrun")) return - - const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"]) - - if (lspLoc.code !== 0) return - - const bin = lspLoc.text.trim() - - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const RustAnalyzer: Info = { - id: "rust", - root: async (root) => { - const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root) - if (crateRoot === undefined) { - return undefined - } - let currentDir = crateRoot - - while (currentDir !== path.dirname(currentDir)) { - // Stop at filesystem root - const cargoTomlPath = path.join(currentDir, "Cargo.toml") - try { - const cargoTomlContent = await Filesystem.readText(cargoTomlPath) - if (cargoTomlContent.includes("[workspace]")) { - return currentDir - } - } catch { - // File doesn't exist or can't be read, continue searching up - } - - const parentDir = path.dirname(currentDir) - if (parentDir === currentDir) break // Reached filesystem root - currentDir = parentDir - - // Stop if we've gone above the app root - if (!currentDir.startsWith(Instance.worktree)) break - } - - return crateRoot - }, - extensions: [".rs"], - async spawn(root) { - const bin = which("rust-analyzer") - if (!bin) { - log.info("rust-analyzer not found in path, please install it") - return - } - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - - export const Clangd: Info = { - id: "clangd", - root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]), - extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], - async spawn(root) { - const args = ["--background-index", "--clang-tidy"] - const fromPath = which("clangd") - if (fromPath) { - return { - process: spawn(fromPath, args, { - cwd: root, - }), - } - } - - const ext = process.platform === "win32" ? ".exe" : "" - const direct = path.join(Global.Path.bin, "clangd" + ext) - if (await Filesystem.exists(direct)) { - return { - process: spawn(direct, args, { - cwd: root, - }), - } - } - - const entries = await fs.readdir(Global.Path.bin, { withFileTypes: true }).catch(() => []) - for (const entry of entries) { - if (!entry.isDirectory()) continue - if (!entry.name.startsWith("clangd_")) continue - const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext) - if (await Filesystem.exists(candidate)) { + const first = await candidates.next() + await candidates.return() + if (first.value) return first.value + + return undefined + } + + let lintBin = await resolveBin(lintTarget) + if (!lintBin) { + const found = which("oxlint") + if (found) lintBin = found + } + + if (lintBin) { + const proc = spawn(lintBin, ["--help"]) + await proc.exited + if (proc.stdout) { + const help = await text(proc.stdout) + if (help.includes("--lsp")) { return { - process: spawn(candidate, args, { + process: spawn(lintBin, ["--lsp"], { cwd: root, }), } } } + } + let serverBin = await resolveBin(serverTarget) + if (!serverBin) { + const found = which("oxc_language_server") + if (found) serverBin = found + } + if (serverBin) { + return { + process: spawn(serverBin, [], { + cwd: root, + }), + } + } + + log.info("oxlint not found, please install oxlint") + return + }, +} + +export const Biome: Info = { + id: "biome", + root: NearestRoot([ + "biome.json", + "biome.jsonc", + "package-lock.json", + "bun.lockb", + "bun.lock", + "pnpm-lock.yaml", + "yarn.lock", + ]), + extensions: [ + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".mts", + ".cts", + ".json", + ".jsonc", + ".vue", + ".astro", + ".svelte", + ".css", + ".graphql", + ".gql", + ".html", + ], + async spawn(root) { + const localBin = path.join(root, "node_modules", ".bin", "biome") + let bin: string | undefined + if (await Filesystem.exists(localBin)) bin = localBin + if (!bin) { + const found = which("biome") + if (found) bin = found + } + + let args = ["lsp-proxy", "--stdio"] + + if (!bin) { + const resolved = Module.resolve("biome", root) + if (!resolved) return + bin = await Npm.which("biome") + if (!bin) return + args = ["lsp-proxy", "--stdio"] + } + + const proc = spawn(bin, args, { + cwd: root, + env: { + ...process.env, + }, + }) + + return { + process: proc, + } + }, +} + +export const Gopls: Info = { + id: "gopls", + root: async (file) => { + const work = await NearestRoot(["go.work"])(file) + if (work) return work + return NearestRoot(["go.mod", "go.sum"])(file) + }, + extensions: [".go"], + async spawn(root) { + let bin = which("gopls") + if (!bin) { + if (!which("go")) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading clangd from GitHub releases") - const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch clangd release info") + log.info("installing gopls") + const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], { + env: { ...process.env, GOBIN: Global.Path.bin }, + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + log.error("Failed to install gopls") return } + bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : "")) + log.info(`installed gopls`, { + bin, + }) + } + return { + process: spawn(bin!, { + cwd: root, + }), + } + }, +} - const release: { - tag_name?: string - assets?: { name?: string; browser_download_url?: string }[] - } = await releaseResponse.json() - - const tag = release.tag_name - if (!tag) { - log.error("clangd release did not include a tag name") +export const Rubocop: Info = { + id: "ruby-lsp", + root: NearestRoot(["Gemfile"]), + extensions: [".rb", ".rake", ".gemspec", ".ru"], + async spawn(root) { + let bin = which("rubocop") + if (!bin) { + const ruby = which("ruby") + const gem = which("gem") + if (!ruby || !gem) { + log.info("Ruby not found, please install Ruby first") return } - const platform = process.platform - const tokens: Record = { - darwin: "mac", - linux: "linux", - win32: "windows", - } - const token = tokens[platform] - if (!token) { - log.error(`Platform ${platform} is not supported by clangd auto-download`) + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("installing rubocop") + const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + log.error("Failed to install rubocop") return } + bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : "")) + log.info(`installed rubocop`, { + bin, + }) + } + return { + process: spawn(bin!, ["--lsp"], { + cwd: root, + }), + } + }, +} - const assets = release.assets ?? [] - const valid = (item: { name?: string; browser_download_url?: string }) => { - if (!item.name) return false - if (!item.browser_download_url) return false - if (!item.name.includes(token)) return false - return item.name.includes(tag) +export const Ty: Info = { + id: "ty", + extensions: [".py", ".pyi"], + root: NearestRoot([ + "pyproject.toml", + "ty.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + "Pipfile", + "pyrightconfig.json", + ]), + async spawn(root) { + if (!Flag.OPENCODE_EXPERIMENTAL_LSP_TY) { + return undefined + } + + let binary = which("ty") + + const initialization: Record = {} + + const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( + (p): p is string => p !== undefined, + ) + for (const venvPath of potentialVenvPaths) { + const isWindows = process.platform === "win32" + const potentialPythonPath = isWindows + ? path.join(venvPath, "Scripts", "python.exe") + : path.join(venvPath, "bin", "python") + if (await Filesystem.exists(potentialPythonPath)) { + initialization["pythonPath"] = potentialPythonPath + break } + } - const asset = - assets.find((item) => valid(item) && item.name?.endsWith(".zip")) ?? - assets.find((item) => valid(item) && item.name?.endsWith(".tar.xz")) ?? - assets.find((item) => valid(item)) - if (!asset?.name || !asset.browser_download_url) { - log.error("clangd could not match release asset", { tag, platform }) - return + if (!binary) { + for (const venvPath of potentialVenvPaths) { + const isWindows = process.platform === "win32" + const potentialTyPath = isWindows + ? path.join(venvPath, "Scripts", "ty.exe") + : path.join(venvPath, "bin", "ty") + if (await Filesystem.exists(potentialTyPath)) { + binary = potentialTyPath + break + } } + } - const name = asset.name - const downloadResponse = await fetch(asset.browser_download_url) - if (!downloadResponse.ok) { - log.error("Failed to download clangd") - return + if (!binary) { + log.error("ty not found, please install ty first") + return + } + + const proc = spawn(binary, ["server"], { + cwd: root, + }) + + return { + process: proc, + initialization, + } + }, +} + +export const Pyright: Info = { + id: "pyright", + extensions: [".py", ".pyi"], + root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]), + async spawn(root) { + let binary = which("pyright-langserver") + const args = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("pyright") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + + const initialization: Record = {} + + const potentialVenvPaths = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter( + (p): p is string => p !== undefined, + ) + for (const venvPath of potentialVenvPaths) { + const isWindows = process.platform === "win32" + const potentialPythonPath = isWindows + ? path.join(venvPath, "Scripts", "python.exe") + : path.join(venvPath, "bin", "python") + if (await Filesystem.exists(potentialPythonPath)) { + initialization["pythonPath"] = potentialPythonPath + break } + } - const archive = path.join(Global.Path.bin, name) - const buf = await downloadResponse.arrayBuffer() - if (buf.byteLength === 0) { - log.error("Failed to write clangd archive") - return - } - await Filesystem.write(archive, Buffer.from(buf)) + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization, + } + }, +} - const zip = name.endsWith(".zip") - const tar = name.endsWith(".tar.xz") - if (!zip && !tar) { - log.error("clangd encountered unsupported asset", { asset: name }) - return - } +export const ElixirLS: Info = { + id: "elixir-ls", + extensions: [".ex", ".exs"], + root: NearestRoot(["mix.exs", "mix.lock"]), + async spawn(root) { + let binary = which("elixir-ls") + if (!binary) { + const elixirLsPath = path.join(Global.Path.bin, "elixir-ls") + binary = path.join( + Global.Path.bin, + "elixir-ls-master", + "release", + process.platform === "win32" ? "language_server.bat" : "language_server.sh", + ) - if (zip) { - const ok = await Archive.extractZip(archive, Global.Path.bin) + if (!(await Filesystem.exists(binary))) { + const elixir = which("elixir") + if (!elixir) { + log.error("elixir is required to run elixir-ls") + return + } + + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading elixir-ls from GitHub releases") + + const response = await fetch("https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip") + if (!response.ok) return + const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") + if (response.body) await Filesystem.writeStream(zipPath, response.body) + + const ok = await Archive.extractZip(zipPath, Global.Path.bin) .then(() => true) .catch((error) => { - log.error("Failed to extract clangd archive", { error }) + log.error("Failed to extract elixir-ls archive", { error }) return false }) if (!ok) return - } - if (tar) { - await run(["tar", "-xf", archive], { cwd: Global.Path.bin }) - } - await fs.rm(archive, { force: true }) - const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext) + await fs.rm(zipPath, { + force: true, + recursive: true, + }) + + const cwd = path.join(Global.Path.bin, "elixir-ls-master") + const env = { MIX_ENV: "prod", ...process.env } + await Process.run(["mix", "deps.get"], { cwd, env }) + await Process.run(["mix", "compile"], { cwd, env }) + await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env }) + + log.info(`installed elixir-ls`, { + path: elixirLsPath, + }) + } + } + + return { + process: spawn(binary, { + cwd: root, + }), + } + }, +} + +export const Zls: Info = { + id: "zls", + extensions: [".zig", ".zon"], + root: NearestRoot(["build.zig"]), + async spawn(root) { + let bin = which("zls") + + if (!bin) { + const zig = which("zig") + if (!zig) { + log.error("Zig is required to use zls. Please install Zig first.") + return + } + + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading zls from GitHub releases") + + const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest") + if (!releaseResponse.ok) { + log.error("Failed to fetch zls release info") + return + } + + const release = (await releaseResponse.json()) as any + + const platform = process.platform + const arch = process.arch + let assetName = "" + + let zlsArch: string = arch + if (arch === "arm64") zlsArch = "aarch64" + else if (arch === "x64") zlsArch = "x86_64" + else if (arch === "ia32") zlsArch = "x86" + + let zlsPlatform: string = platform + if (platform === "darwin") zlsPlatform = "macos" + else if (platform === "win32") zlsPlatform = "windows" + + const ext = platform === "win32" ? "zip" : "tar.xz" + + assetName = `zls-${zlsArch}-${zlsPlatform}.${ext}` + + const supportedCombos = [ + "zls-x86_64-linux.tar.xz", + "zls-x86_64-macos.tar.xz", + "zls-x86_64-windows.zip", + "zls-aarch64-linux.tar.xz", + "zls-aarch64-macos.tar.xz", + "zls-aarch64-windows.zip", + "zls-x86-linux.tar.xz", + "zls-x86-windows.zip", + ] + + if (!supportedCombos.includes(assetName)) { + log.error(`Platform ${platform} and architecture ${arch} is not supported by zls`) + return + } + + const asset = release.assets.find((a: any) => a.name === assetName) + if (!asset) { + log.error(`Could not find asset ${assetName} in latest zls release`) + return + } + + const downloadUrl = asset.browser_download_url + const downloadResponse = await fetch(downloadUrl) + if (!downloadResponse.ok) { + log.error("Failed to download zls") + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + if (ext === "zip") { + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract zls archive", { error }) + return false + }) + if (!ok) return + } else { + await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin }) + } + + await fs.rm(tempPath, { force: true }) + + bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : "")) + if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract clangd binary") + log.error("Failed to extract zls binary") return } @@ -988,971 +689,1268 @@ export namespace LSPServer { await fs.chmod(bin, 0o755).catch(() => {}) } - await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {}) - await fs.symlink(bin, path.join(Global.Path.bin, "clangd")).catch(() => {}) + log.info(`installed zls`, { bin }) + } - log.info(`installed clangd`, { bin }) + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} +export const CSharp: Info = { + id: "csharp", + root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), + extensions: [".cs"], + async spawn(root) { + let bin = which("csharp-ls") + if (!bin) { + if (!which("dotnet")) { + log.error(".NET SDK is required to install csharp-ls") + return + } + + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("installing csharp-ls via dotnet tool") + const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + log.error("Failed to install csharp-ls") + return + } + + bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : "")) + log.info(`installed csharp-ls`, { bin }) + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const FSharp: Info = { + id: "fsharp", + root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), + extensions: [".fs", ".fsi", ".fsx", ".fsscript"], + async spawn(root) { + let bin = which("fsautocomplete") + if (!bin) { + if (!which("dotnet")) { + log.error(".NET SDK is required to install fsautocomplete") + return + } + + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("installing fsautocomplete via dotnet tool") + const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], { + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }) + const exit = await proc.exited + if (exit !== 0) { + log.error("Failed to install fsautocomplete") + return + } + + bin = path.join(Global.Path.bin, "fsautocomplete" + (process.platform === "win32" ? ".exe" : "")) + log.info(`installed fsautocomplete`, { bin }) + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const SourceKit: Info = { + id: "sourcekit-lsp", + extensions: [".swift", ".objc", "objcpp"], + root: NearestRoot(["Package.swift", "*.xcodeproj", "*.xcworkspace"]), + async spawn(root) { + // Check if sourcekit-lsp is available in the PATH + // This is installed with the Swift toolchain + const sourcekit = which("sourcekit-lsp") + if (sourcekit) { return { - process: spawn(bin, args, { + process: spawn(sourcekit, { cwd: root, }), } - }, - } + } - export const Svelte: Info = { - id: "svelte", - extensions: [".svelte"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - let binary = which("svelteserver") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("svelte-language-server") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - const proc = spawn(binary, args, { + // If sourcekit-lsp not found, check if xcrun is available + // This is specific to macOS where sourcekit-lsp is typically installed with Xcode + if (!which("xcrun")) return + + const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"]) + + if (lspLoc.code !== 0) return + + const bin = lspLoc.text.trim() + + return { + process: spawn(bin, { cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, - initialization: {}, - } - }, - } + }), + } + }, +} - export const Astro: Info = { - id: "astro", - extensions: [".astro"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) - if (!tsserver) { - log.info("typescript not found, required for Astro language server") - return - } - const tsdk = path.dirname(tsserver) +export const RustAnalyzer: Info = { + id: "rust", + root: async (root) => { + const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root) + if (crateRoot === undefined) { + return undefined + } + let currentDir = crateRoot - let binary = which("astro-ls") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("@astrojs/language-server") - if (!resolved) return - binary = resolved + while (currentDir !== path.dirname(currentDir)) { + // Stop at filesystem root + const cargoTomlPath = path.join(currentDir, "Cargo.toml") + try { + const cargoTomlContent = await Filesystem.readText(cargoTomlPath) + if (cargoTomlContent.includes("[workspace]")) { + return currentDir + } + } catch { + // File doesn't exist or can't be read, continue searching up } - args.push("--stdio") - const proc = spawn(binary, args, { + + const parentDir = path.dirname(currentDir) + if (parentDir === currentDir) break // Reached filesystem root + currentDir = parentDir + + // Stop if we've gone above the app root + if (!currentDir.startsWith(Instance.worktree)) break + } + + return crateRoot + }, + extensions: [".rs"], + async spawn(root) { + const bin = which("rust-analyzer") + if (!bin) { + log.info("rust-analyzer not found in path, please install it") + return + } + return { + process: spawn(bin, { cwd: root, - env: { - ...process.env, - }, - }) + }), + } + }, +} + +export const Clangd: Info = { + id: "clangd", + root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]), + extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"], + async spawn(root) { + const args = ["--background-index", "--clang-tidy"] + const fromPath = which("clangd") + if (fromPath) { return { - process: proc, - initialization: { - typescript: { - tsdk, - }, - }, + process: spawn(fromPath, args, { + cwd: root, + }), } - }, - } + } - export const JDTLS: Info = { - id: "jdtls", - root: async (file) => { - // Without exclusions, NearestRoot defaults to instance directory so we can't - // distinguish between a) no project found and b) project found at instance dir. - // So we can't choose the root from (potential) monorepo markers first. - // Look for potential subproject markers first while excluding potential monorepo markers. - const settingsMarkers = ["settings.gradle", "settings.gradle.kts"] - const gradleMarkers = ["gradlew", "gradlew.bat"] - const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers) - - const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([ - NearestRoot( - ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], - exclusionsForMonorepos, - )(file), - NearestRoot(gradleMarkers, settingsMarkers)(file), - NearestRoot(settingsMarkers)(file), - ]) - - // If projectRoot is undefined we know we are in a monorepo or no project at all. - // So can safely fall through to the other roots - if (projectRoot) return projectRoot - if (wrapperRoot) return wrapperRoot - if (settingsRoot) return settingsRoot - }, - extensions: [".java"], - async spawn(root) { - const java = which("java") - if (!java) { - log.error("Java 21 or newer is required to run the JDTLS. Please install it first.") - return - } - const javaMajorVersion = await run(["java", "-version"]).then((result) => { - const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString()) - return !m ? undefined : parseInt(m[1]) - }) - if (javaMajorVersion == null || javaMajorVersion < 21) { - log.error("JDTLS requires at least Java 21.") - return - } - const distPath = path.join(Global.Path.bin, "jdtls") - const launcherDir = path.join(distPath, "plugins") - const installed = await pathExists(launcherDir) - if (!installed) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("Downloading JDTLS LSP server.") - await fs.mkdir(distPath, { recursive: true }) - const releaseURL = - "https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz" - const archiveName = "release.tar.gz" - - log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath }) - const download = await fetch(releaseURL) - if (!download.ok || !download.body) { - log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText }) - return - } - await Filesystem.writeStream(path.join(distPath, archiveName), download.body) - - log.info("Extracting JDTLS archive") - const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath }) - if (tarResult.code !== 0) { - log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() }) - return - } - - await fs.rm(path.join(distPath, archiveName), { force: true }) - log.info("JDTLS download and extraction completed") - } - const jarFileName = - (await fs.readdir(launcherDir).catch(() => [])) - .find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item)) - ?.trim() ?? "" - const launcherJar = path.join(launcherDir, jarFileName) - if (!(await pathExists(launcherJar))) { - log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) - return - } - const configFile = path.join( - distPath, - (() => { - switch (process.platform) { - case "darwin": - return "config_mac" - case "linux": - return "config_linux" - case "win32": - return "config_win" - default: - return "config_linux" - } - })(), - ) - const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jdtls-data")) + const ext = process.platform === "win32" ? ".exe" : "" + const direct = path.join(Global.Path.bin, "clangd" + ext) + if (await Filesystem.exists(direct)) { return { - process: spawn( - java, - [ - "-jar", - launcherJar, - "-configuration", - configFile, - "-data", - dataDir, - "-Declipse.application=org.eclipse.jdt.ls.core.id1", - "-Dosgi.bundles.defaultStartLevel=4", - "-Declipse.product=org.eclipse.jdt.ls.core.product", - "-Dlog.level=ALL", - "--add-modules=ALL-SYSTEM", - "--add-opens java.base/java.util=ALL-UNNAMED", - "--add-opens java.base/java.lang=ALL-UNNAMED", - ], - { + process: spawn(direct, args, { + cwd: root, + }), + } + } + + const entries = await fs.readdir(Global.Path.bin, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (!entry.name.startsWith("clangd_")) continue + const candidate = path.join(Global.Path.bin, entry.name, "bin", "clangd" + ext) + if (await Filesystem.exists(candidate)) { + return { + process: spawn(candidate, args, { cwd: root, - }, - ), + }), + } } - }, - } + } - export const KotlinLS: Info = { - id: "kotlin-ls", - extensions: [".kt", ".kts"], - root: async (file) => { - // 1) Nearest Gradle root (multi-project or included build) - const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file) - if (settingsRoot) return settingsRoot - // 2) Gradle wrapper (strong root signal) - const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file) - if (wrapperRoot) return wrapperRoot - // 3) Single-project or module-level build - const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file) - if (buildRoot) return buildRoot - // 4) Maven fallback - return NearestRoot(["pom.xml"])(file) - }, - async spawn(root) { - const distPath = path.join(Global.Path.bin, "kotlin-ls") - const launcherScript = - process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh") - const installed = await Filesystem.exists(launcherScript) - if (!installed) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("Downloading Kotlin Language Server from GitHub.") + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading clangd from GitHub releases") - const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch kotlin-lsp release info") - return + const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest") + if (!releaseResponse.ok) { + log.error("Failed to fetch clangd release info") + return + } + + const release: { + tag_name?: string + assets?: { name?: string; browser_download_url?: string }[] + } = await releaseResponse.json() + + const tag = release.tag_name + if (!tag) { + log.error("clangd release did not include a tag name") + return + } + const platform = process.platform + const tokens: Record = { + darwin: "mac", + linux: "linux", + win32: "windows", + } + const token = tokens[platform] + if (!token) { + log.error(`Platform ${platform} is not supported by clangd auto-download`) + return + } + + const assets = release.assets ?? [] + const valid = (item: { name?: string; browser_download_url?: string }) => { + if (!item.name) return false + if (!item.browser_download_url) return false + if (!item.name.includes(token)) return false + return item.name.includes(tag) + } + + const asset = + assets.find((item) => valid(item) && item.name?.endsWith(".zip")) ?? + assets.find((item) => valid(item) && item.name?.endsWith(".tar.xz")) ?? + assets.find((item) => valid(item)) + if (!asset?.name || !asset.browser_download_url) { + log.error("clangd could not match release asset", { tag, platform }) + return + } + + const name = asset.name + const downloadResponse = await fetch(asset.browser_download_url) + if (!downloadResponse.ok) { + log.error("Failed to download clangd") + return + } + + const archive = path.join(Global.Path.bin, name) + const buf = await downloadResponse.arrayBuffer() + if (buf.byteLength === 0) { + log.error("Failed to write clangd archive") + return + } + await Filesystem.write(archive, Buffer.from(buf)) + + const zip = name.endsWith(".zip") + const tar = name.endsWith(".tar.xz") + if (!zip && !tar) { + log.error("clangd encountered unsupported asset", { asset: name }) + return + } + + if (zip) { + const ok = await Archive.extractZip(archive, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract clangd archive", { error }) + return false + }) + if (!ok) return + } + if (tar) { + await run(["tar", "-xf", archive], { cwd: Global.Path.bin }) + } + await fs.rm(archive, { force: true }) + + const bin = path.join(Global.Path.bin, "clangd_" + tag, "bin", "clangd" + ext) + if (!(await Filesystem.exists(bin))) { + log.error("Failed to extract clangd binary") + return + } + + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) + } + + await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {}) + await fs.symlink(bin, path.join(Global.Path.bin, "clangd")).catch(() => {}) + + log.info(`installed clangd`, { bin }) + + return { + process: spawn(bin, args, { + cwd: root, + }), + } + }, +} + +export const Svelte: Info = { + id: "svelte", + extensions: [".svelte"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root) { + let binary = which("svelteserver") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("svelte-language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: {}, + } + }, +} + +export const Astro: Info = { + id: "astro", + extensions: [".astro"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root) { + const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) + if (!tsserver) { + log.info("typescript not found, required for Astro language server") + return + } + const tsdk = path.dirname(tsserver) + + let binary = which("astro-ls") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("@astrojs/language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + typescript: { + tsdk, + }, + }, + } + }, +} + +export const JDTLS: Info = { + id: "jdtls", + root: async (file) => { + // Without exclusions, NearestRoot defaults to instance directory so we can't + // distinguish between a) no project found and b) project found at instance dir. + // So we can't choose the root from (potential) monorepo markers first. + // Look for potential subproject markers first while excluding potential monorepo markers. + const settingsMarkers = ["settings.gradle", "settings.gradle.kts"] + const gradleMarkers = ["gradlew", "gradlew.bat"] + const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers) + + const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([ + NearestRoot( + ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], + exclusionsForMonorepos, + )(file), + NearestRoot(gradleMarkers, settingsMarkers)(file), + NearestRoot(settingsMarkers)(file), + ]) + + // If projectRoot is undefined we know we are in a monorepo or no project at all. + // So can safely fall through to the other roots + if (projectRoot) return projectRoot + if (wrapperRoot) return wrapperRoot + if (settingsRoot) return settingsRoot + }, + extensions: [".java"], + async spawn(root) { + const java = which("java") + if (!java) { + log.error("Java 21 or newer is required to run the JDTLS. Please install it first.") + return + } + const javaMajorVersion = await run(["java", "-version"]).then((result) => { + const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString()) + return !m ? undefined : parseInt(m[1]) + }) + if (javaMajorVersion == null || javaMajorVersion < 21) { + log.error("JDTLS requires at least Java 21.") + return + } + const distPath = path.join(Global.Path.bin, "jdtls") + const launcherDir = path.join(distPath, "plugins") + const installed = await pathExists(launcherDir) + if (!installed) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("Downloading JDTLS LSP server.") + await fs.mkdir(distPath, { recursive: true }) + const releaseURL = + "https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz" + const archiveName = "release.tar.gz" + + log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath }) + const download = await fetch(releaseURL) + if (!download.ok || !download.body) { + log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText }) + return + } + await Filesystem.writeStream(path.join(distPath, archiveName), download.body) + + log.info("Extracting JDTLS archive") + const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath }) + if (tarResult.code !== 0) { + log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() }) + return + } + + await fs.rm(path.join(distPath, archiveName), { force: true }) + log.info("JDTLS download and extraction completed") + } + const jarFileName = + (await fs.readdir(launcherDir).catch(() => [])) + .find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item)) + ?.trim() ?? "" + const launcherJar = path.join(launcherDir, jarFileName) + if (!(await pathExists(launcherJar))) { + log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`) + return + } + const configFile = path.join( + distPath, + (() => { + switch (process.platform) { + case "darwin": + return "config_mac" + case "linux": + return "config_linux" + case "win32": + return "config_win" + default: + return "config_linux" } + })(), + ) + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-jdtls-data")) + return { + process: spawn( + java, + [ + "-jar", + launcherJar, + "-configuration", + configFile, + "-data", + dataDir, + "-Declipse.application=org.eclipse.jdt.ls.core.id1", + "-Dosgi.bundles.defaultStartLevel=4", + "-Declipse.product=org.eclipse.jdt.ls.core.product", + "-Dlog.level=ALL", + "--add-modules=ALL-SYSTEM", + "--add-opens java.base/java.util=ALL-UNNAMED", + "--add-opens java.base/java.lang=ALL-UNNAMED", + ], + { + cwd: root, + }, + ), + } + }, +} - const release = await releaseResponse.json() - const version = release.name?.replace(/^v/, "") +export const KotlinLS: Info = { + id: "kotlin-ls", + extensions: [".kt", ".kts"], + root: async (file) => { + // 1) Nearest Gradle root (multi-project or included build) + const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file) + if (settingsRoot) return settingsRoot + // 2) Gradle wrapper (strong root signal) + const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file) + if (wrapperRoot) return wrapperRoot + // 3) Single-project or module-level build + const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file) + if (buildRoot) return buildRoot + // 4) Maven fallback + return NearestRoot(["pom.xml"])(file) + }, + async spawn(root) { + const distPath = path.join(Global.Path.bin, "kotlin-ls") + const launcherScript = + process.platform === "win32" ? path.join(distPath, "kotlin-lsp.cmd") : path.join(distPath, "kotlin-lsp.sh") + const installed = await Filesystem.exists(launcherScript) + if (!installed) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("Downloading Kotlin Language Server from GitHub.") - if (!version) { - log.error("Could not determine Kotlin LSP version from release") - return - } + const releaseResponse = await fetch("https://api.github.com/repos/Kotlin/kotlin-lsp/releases/latest") + if (!releaseResponse.ok) { + log.error("Failed to fetch kotlin-lsp release info") + return + } - const platform = process.platform - const arch = process.arch + const release = await releaseResponse.json() + const version = release.name?.replace(/^v/, "") - let kotlinArch: string = arch - if (arch === "arm64") kotlinArch = "aarch64" - else if (arch === "x64") kotlinArch = "x64" + if (!version) { + log.error("Could not determine Kotlin LSP version from release") + return + } - let kotlinPlatform: string = platform - if (platform === "darwin") kotlinPlatform = "mac" - else if (platform === "linux") kotlinPlatform = "linux" - else if (platform === "win32") kotlinPlatform = "win" + const platform = process.platform + const arch = process.arch - const supportedCombos = ["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"] + let kotlinArch: string = arch + if (arch === "arm64") kotlinArch = "aarch64" + else if (arch === "x64") kotlinArch = "x64" - const combo = `${kotlinPlatform}-${kotlinArch}` + let kotlinPlatform: string = platform + if (platform === "darwin") kotlinPlatform = "mac" + else if (platform === "linux") kotlinPlatform = "linux" + else if (platform === "win32") kotlinPlatform = "win" - if (!supportedCombos.includes(combo)) { - log.error(`Platform ${platform}/${arch} is not supported by Kotlin LSP`) - return - } + const supportedCombos = ["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"] - const assetName = `kotlin-lsp-${version}-${kotlinPlatform}-${kotlinArch}.zip` - const releaseURL = `https://download-cdn.jetbrains.com/kotlin-lsp/${version}/${assetName}` + const combo = `${kotlinPlatform}-${kotlinArch}` - await fs.mkdir(distPath, { recursive: true }) - const archivePath = path.join(distPath, "kotlin-ls.zip") - const download = await fetch(releaseURL) - if (!download.ok || !download.body) { - log.error("Failed to download Kotlin Language Server", { - status: download.status, - statusText: download.statusText, - }) - return - } - await Filesystem.writeStream(archivePath, download.body) - const ok = await Archive.extractZip(archivePath, distPath) + if (!supportedCombos.includes(combo)) { + log.error(`Platform ${platform}/${arch} is not supported by Kotlin LSP`) + return + } + + const assetName = `kotlin-lsp-${version}-${kotlinPlatform}-${kotlinArch}.zip` + const releaseURL = `https://download-cdn.jetbrains.com/kotlin-lsp/${version}/${assetName}` + + await fs.mkdir(distPath, { recursive: true }) + const archivePath = path.join(distPath, "kotlin-ls.zip") + const download = await fetch(releaseURL) + if (!download.ok || !download.body) { + log.error("Failed to download Kotlin Language Server", { + status: download.status, + statusText: download.statusText, + }) + return + } + await Filesystem.writeStream(archivePath, download.body) + const ok = await Archive.extractZip(archivePath, distPath) + .then(() => true) + .catch((error) => { + log.error("Failed to extract Kotlin LS archive", { error }) + return false + }) + if (!ok) return + await fs.rm(archivePath, { force: true }) + if (process.platform !== "win32") { + await fs.chmod(launcherScript, 0o755).catch(() => {}) + } + log.info("Installed Kotlin Language Server", { path: launcherScript }) + } + if (!(await Filesystem.exists(launcherScript))) { + log.error(`Failed to locate the Kotlin LS launcher script in the installed directory: ${distPath}.`) + return + } + return { + process: spawn(launcherScript, ["--stdio"], { + cwd: root, + }), + } + }, +} + +export const YamlLS: Info = { + id: "yaml-ls", + extensions: [".yaml", ".yml"], + root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), + async spawn(root) { + let binary = which("yaml-language-server") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("yaml-language-server") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + } + }, +} + +export const LuaLS: Info = { + id: "lua-ls", + root: NearestRoot([ + ".luarc.json", + ".luarc.jsonc", + ".luacheckrc", + ".stylua.toml", + "stylua.toml", + "selene.toml", + "selene.yml", + ]), + extensions: [".lua"], + async spawn(root) { + let bin = which("lua-language-server") + + if (!bin) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading lua-language-server from GitHub releases") + + const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") + if (!releaseResponse.ok) { + log.error("Failed to fetch lua-language-server release info") + return + } + + const release = await releaseResponse.json() + + const platform = process.platform + const arch = process.arch + let assetName = "" + + let lualsArch: string = arch + if (arch === "arm64") lualsArch = "arm64" + else if (arch === "x64") lualsArch = "x64" + else if (arch === "ia32") lualsArch = "ia32" + + let lualsPlatform: string = platform + if (platform === "darwin") lualsPlatform = "darwin" + else if (platform === "linux") lualsPlatform = "linux" + else if (platform === "win32") lualsPlatform = "win32" + + const ext = platform === "win32" ? "zip" : "tar.gz" + + assetName = `lua-language-server-${release.tag_name}-${lualsPlatform}-${lualsArch}.${ext}` + + const supportedCombos = [ + "darwin-arm64.tar.gz", + "darwin-x64.tar.gz", + "linux-x64.tar.gz", + "linux-arm64.tar.gz", + "win32-x64.zip", + "win32-ia32.zip", + ] + + const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}` + if (!supportedCombos.includes(assetSuffix)) { + log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`) + return + } + + const asset = release.assets.find((a: any) => a.name === assetName) + if (!asset) { + log.error(`Could not find asset ${assetName} in latest lua-language-server release`) + return + } + + const downloadUrl = asset.browser_download_url + const downloadResponse = await fetch(downloadUrl) + if (!downloadResponse.ok) { + log.error("Failed to download lua-language-server") + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + // Unlike zls which is a single self-contained binary, + // lua-language-server needs supporting files (meta/, locale/, etc.) + // Extract entire archive to dedicated directory to preserve all files + const installDir = path.join(Global.Path.bin, `lua-language-server-${lualsArch}-${lualsPlatform}`) + + // Remove old installation if exists + const stats = await fs.stat(installDir).catch(() => undefined) + if (stats) { + await fs.rm(installDir, { force: true, recursive: true }) + } + + await fs.mkdir(installDir, { recursive: true }) + + if (ext === "zip") { + const ok = await Archive.extractZip(tempPath, installDir) .then(() => true) .catch((error) => { - log.error("Failed to extract Kotlin LS archive", { error }) + log.error("Failed to extract lua-language-server archive", { error }) + return false + }) + if (!ok) return + } else { + const ok = await run(["tar", "-xzf", tempPath, "-C", installDir]) + .then((result) => result.code === 0) + .catch((error: unknown) => { + log.error("Failed to extract lua-language-server archive", { error }) return false }) if (!ok) return - await fs.rm(archivePath, { force: true }) - if (process.platform !== "win32") { - await fs.chmod(launcherScript, 0o755).catch(() => {}) - } - log.info("Installed Kotlin Language Server", { path: launcherScript }) } - if (!(await Filesystem.exists(launcherScript))) { - log.error(`Failed to locate the Kotlin LS launcher script in the installed directory: ${distPath}.`) + + await fs.rm(tempPath, { force: true }) + + // Binary is located in bin/ subdirectory within the extracted archive + bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : "")) + + if (!(await Filesystem.exists(bin))) { + log.error("Failed to extract lua-language-server binary") return } - return { - process: spawn(launcherScript, ["--stdio"], { - cwd: root, - }), - } - }, - } - export const YamlLS: Info = { - id: "yaml-ls", - extensions: [".yaml", ".yml"], - root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]), - async spawn(root) { - let binary = which("yaml-language-server") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("yaml-language-server") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - const proc = spawn(binary, args, { - cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, - } - }, - } - - export const LuaLS: Info = { - id: "lua-ls", - root: NearestRoot([ - ".luarc.json", - ".luarc.jsonc", - ".luacheckrc", - ".stylua.toml", - "stylua.toml", - "selene.toml", - "selene.yml", - ]), - extensions: [".lua"], - async spawn(root) { - let bin = which("lua-language-server") - - if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading lua-language-server from GitHub releases") - - const releaseResponse = await fetch("https://api.github.com/repos/LuaLS/lua-language-server/releases/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch lua-language-server release info") - return - } - - const release = await releaseResponse.json() - - const platform = process.platform - const arch = process.arch - let assetName = "" - - let lualsArch: string = arch - if (arch === "arm64") lualsArch = "arm64" - else if (arch === "x64") lualsArch = "x64" - else if (arch === "ia32") lualsArch = "ia32" - - let lualsPlatform: string = platform - if (platform === "darwin") lualsPlatform = "darwin" - else if (platform === "linux") lualsPlatform = "linux" - else if (platform === "win32") lualsPlatform = "win32" - - const ext = platform === "win32" ? "zip" : "tar.gz" - - assetName = `lua-language-server-${release.tag_name}-${lualsPlatform}-${lualsArch}.${ext}` - - const supportedCombos = [ - "darwin-arm64.tar.gz", - "darwin-x64.tar.gz", - "linux-x64.tar.gz", - "linux-arm64.tar.gz", - "win32-x64.zip", - "win32-ia32.zip", - ] - - const assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}` - if (!supportedCombos.includes(assetSuffix)) { - log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`) - return - } - - const asset = release.assets.find((a: any) => a.name === assetName) - if (!asset) { - log.error(`Could not find asset ${assetName} in latest lua-language-server release`) - return - } - - const downloadUrl = asset.browser_download_url - const downloadResponse = await fetch(downloadUrl) - if (!downloadResponse.ok) { - log.error("Failed to download lua-language-server") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - // Unlike zls which is a single self-contained binary, - // lua-language-server needs supporting files (meta/, locale/, etc.) - // Extract entire archive to dedicated directory to preserve all files - const installDir = path.join(Global.Path.bin, `lua-language-server-${lualsArch}-${lualsPlatform}`) - - // Remove old installation if exists - const stats = await fs.stat(installDir).catch(() => undefined) - if (stats) { - await fs.rm(installDir, { force: true, recursive: true }) - } - - await fs.mkdir(installDir, { recursive: true }) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, installDir) - .then(() => true) - .catch((error) => { - log.error("Failed to extract lua-language-server archive", { error }) - return false + if (platform !== "win32") { + const ok = await fs + .chmod(bin, 0o755) + .then(() => true) + .catch((error: unknown) => { + log.error("Failed to set executable permission for lua-language-server binary", { + error, }) - if (!ok) return - } else { - const ok = await run(["tar", "-xzf", tempPath, "-C", installDir]) - .then((result) => result.code === 0) - .catch((error: unknown) => { - log.error("Failed to extract lua-language-server archive", { error }) - return false - }) - if (!ok) return - } - - await fs.rm(tempPath, { force: true }) - - // Binary is located in bin/ subdirectory within the extracted archive - bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract lua-language-server binary") - return - } - - if (platform !== "win32") { - const ok = await fs - .chmod(bin, 0o755) - .then(() => true) - .catch((error: unknown) => { - log.error("Failed to set executable permission for lua-language-server binary", { - error, - }) - return false - }) - if (!ok) return - } - - log.info(`installed lua-language-server`, { bin }) + return false + }) + if (!ok) return } - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } + log.info(`installed lua-language-server`, { bin }) + } - export const PHPIntelephense: Info = { - id: "php intelephense", - extensions: [".php"], - root: NearestRoot(["composer.json", "composer.lock", ".php-version"]), - async spawn(root) { - let binary = which("intelephense") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("intelephense") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - const proc = spawn(binary, args, { + return { + process: spawn(bin, { cwd: root, - env: { - ...process.env, + }), + } + }, +} + +export const PHPIntelephense: Info = { + id: "php intelephense", + extensions: [".php"], + root: NearestRoot(["composer.json", "composer.lock", ".php-version"]), + async spawn(root) { + let binary = which("intelephense") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("intelephense") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + initialization: { + telemetry: { + enabled: false, }, - }) - return { - process: proc, - initialization: { - telemetry: { - enabled: false, - }, - }, - } - }, - } + }, + } + }, +} - export const Prisma: Info = { - id: "prisma", - extensions: [".prisma"], - root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]), - async spawn(root) { - const prisma = which("prisma") - if (!prisma) { - log.info("prisma not found, please install prisma") - return - } - return { - process: spawn(prisma, ["language-server"], { - cwd: root, - }), - } - }, - } - - export const Dart: Info = { - id: "dart", - extensions: [".dart"], - root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]), - async spawn(root) { - const dart = which("dart") - if (!dart) { - log.info("dart not found, please install dart first") - return - } - return { - process: spawn(dart, ["language-server", "--lsp"], { - cwd: root, - }), - } - }, - } - - export const Ocaml: Info = { - id: "ocaml-lsp", - extensions: [".ml", ".mli"], - root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]), - async spawn(root) { - const bin = which("ocamllsp") - if (!bin) { - log.info("ocamllsp not found, please install ocaml-lsp-server") - return - } - return { - process: spawn(bin, { - cwd: root, - }), - } - }, - } - export const BashLS: Info = { - id: "bash", - extensions: [".sh", ".bash", ".zsh", ".ksh"], - root: async () => Instance.directory, - async spawn(root) { - let binary = which("bash-language-server") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("bash-language-server") - if (!resolved) return - binary = resolved - } - args.push("start") - const proc = spawn(binary, args, { +export const Prisma: Info = { + id: "prisma", + extensions: [".prisma"], + root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]), + async spawn(root) { + const prisma = which("prisma") + if (!prisma) { + log.info("prisma not found, please install prisma") + return + } + return { + process: spawn(prisma, ["language-server"], { cwd: root, - env: { - ...process.env, - }, - }) - return { - process: proc, + }), + } + }, +} + +export const Dart: Info = { + id: "dart", + extensions: [".dart"], + root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]), + async spawn(root) { + const dart = which("dart") + if (!dart) { + log.info("dart not found, please install dart first") + return + } + return { + process: spawn(dart, ["language-server", "--lsp"], { + cwd: root, + }), + } + }, +} + +export const Ocaml: Info = { + id: "ocaml-lsp", + extensions: [".ml", ".mli"], + root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]), + async spawn(root) { + const bin = which("ocamllsp") + if (!bin) { + log.info("ocamllsp not found, please install ocaml-lsp-server") + return + } + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} +export const BashLS: Info = { + id: "bash", + extensions: [".sh", ".bash", ".zsh", ".ksh"], + root: async () => Instance.directory, + async spawn(root) { + let binary = which("bash-language-server") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("bash-language-server") + if (!resolved) return + binary = resolved + } + args.push("start") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + } + }, +} + +export const TerraformLS: Info = { + id: "terraform", + extensions: [".tf", ".tfvars"], + root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), + async spawn(root) { + let bin = which("terraform-ls") + + if (!bin) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading terraform-ls from HashiCorp releases") + + const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest") + if (!releaseResponse.ok) { + log.error("Failed to fetch terraform-ls release info") + return } - }, - } - export const TerraformLS: Info = { - id: "terraform", - extensions: [".tf", ".tfvars"], - root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), - async spawn(root) { - let bin = which("terraform-ls") + const release = (await releaseResponse.json()) as { + version?: string + builds?: { arch?: string; os?: string; url?: string }[] + } - if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading terraform-ls from HashiCorp releases") + const platform = process.platform + const arch = process.arch - const releaseResponse = await fetch("https://api.releases.hashicorp.com/v1/releases/terraform-ls/latest") - if (!releaseResponse.ok) { - log.error("Failed to fetch terraform-ls release info") - return - } + const tfArch = arch === "arm64" ? "arm64" : "amd64" + const tfPlatform = platform === "win32" ? "windows" : platform - const release = (await releaseResponse.json()) as { - version?: string - builds?: { arch?: string; os?: string; url?: string }[] - } + const builds = release.builds ?? [] + const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform) + if (!build?.url) { + log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`) + return + } - const platform = process.platform - const arch = process.arch + const downloadResponse = await fetch(build.url) + if (!downloadResponse.ok) { + log.error("Failed to download terraform-ls") + return + } - const tfArch = arch === "arm64" ? "arm64" : "amd64" - const tfPlatform = platform === "win32" ? "windows" : platform + const tempPath = path.join(Global.Path.bin, "terraform-ls.zip") + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - const builds = release.builds ?? [] - const build = builds.find((b) => b.arch === tfArch && b.os === tfPlatform) - if (!build?.url) { - log.error(`Could not find build for ${tfPlatform}/${tfArch} terraform-ls release version ${release.version}`) - return - } + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract terraform-ls archive", { error }) + return false + }) + if (!ok) return + await fs.rm(tempPath, { force: true }) - const downloadResponse = await fetch(build.url) - if (!downloadResponse.ok) { - log.error("Failed to download terraform-ls") - return - } + bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : "")) - const tempPath = path.join(Global.Path.bin, "terraform-ls.zip") - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + if (!(await Filesystem.exists(bin))) { + log.error("Failed to extract terraform-ls binary") + return + } + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) + } + + log.info(`installed terraform-ls`, { bin }) + } + + return { + process: spawn(bin, ["serve"], { + cwd: root, + }), + initialization: { + experimentalFeatures: { + prefillRequiredFields: true, + validateOnSave: true, + }, + }, + } + }, +} + +export const TexLab: Info = { + id: "texlab", + extensions: [".tex", ".bib"], + root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), + async spawn(root) { + let bin = which("texlab") + + if (!bin) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading texlab from GitHub releases") + + const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest") + if (!response.ok) { + log.error("Failed to fetch texlab release info") + return + } + + const release = (await response.json()) as { + tag_name?: string + assets?: { name?: string; browser_download_url?: string }[] + } + const version = release.tag_name?.replace("v", "") + if (!version) { + log.error("texlab release did not include a version tag") + return + } + + const platform = process.platform + const arch = process.arch + + const texArch = arch === "arm64" ? "aarch64" : "x86_64" + const texPlatform = platform === "darwin" ? "macos" : platform === "win32" ? "windows" : "linux" + const ext = platform === "win32" ? "zip" : "tar.gz" + const assetName = `texlab-${texArch}-${texPlatform}.${ext}` + + const assets = release.assets ?? [] + const asset = assets.find((a) => a.name === assetName) + if (!asset?.browser_download_url) { + log.error(`Could not find asset ${assetName} in texlab release`) + return + } + + const downloadResponse = await fetch(asset.browser_download_url) + if (!downloadResponse.ok) { + log.error("Failed to download texlab") + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + if (ext === "zip") { const ok = await Archive.extractZip(tempPath, Global.Path.bin) .then(() => true) .catch((error) => { - log.error("Failed to extract terraform-ls archive", { error }) + log.error("Failed to extract texlab archive", { error }) return false }) if (!ok) return - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract terraform-ls binary") - return - } - - if (platform !== "win32") { - await fs.chmod(bin, 0o755).catch(() => {}) - } - - log.info(`installed terraform-ls`, { bin }) + } + if (ext === "tar.gz") { + await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin }) } - return { - process: spawn(bin, ["serve"], { - cwd: root, - }), - initialization: { - experimentalFeatures: { - prefillRequiredFields: true, - validateOnSave: true, - }, - }, - } - }, - } + await fs.rm(tempPath, { force: true }) - export const TexLab: Info = { - id: "texlab", - extensions: [".tex", ".bib"], - root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), - async spawn(root) { - let bin = which("texlab") + bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : "")) - if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading texlab from GitHub releases") - - const response = await fetch("https://api.github.com/repos/latex-lsp/texlab/releases/latest") - if (!response.ok) { - log.error("Failed to fetch texlab release info") - return - } - - const release = (await response.json()) as { - tag_name?: string - assets?: { name?: string; browser_download_url?: string }[] - } - const version = release.tag_name?.replace("v", "") - if (!version) { - log.error("texlab release did not include a version tag") - return - } - - const platform = process.platform - const arch = process.arch - - const texArch = arch === "arm64" ? "aarch64" : "x86_64" - const texPlatform = platform === "darwin" ? "macos" : platform === "win32" ? "windows" : "linux" - const ext = platform === "win32" ? "zip" : "tar.gz" - const assetName = `texlab-${texArch}-${texPlatform}.${ext}` - - const assets = release.assets ?? [] - const asset = assets.find((a) => a.name === assetName) - if (!asset?.browser_download_url) { - log.error(`Could not find asset ${assetName} in texlab release`) - return - } - - const downloadResponse = await fetch(asset.browser_download_url) - if (!downloadResponse.ok) { - log.error("Failed to download texlab") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract texlab archive", { error }) - return false - }) - if (!ok) return - } - if (ext === "tar.gz") { - await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin }) - } - - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract texlab binary") - return - } - - if (platform !== "win32") { - await fs.chmod(bin, 0o755).catch(() => {}) - } - - log.info("installed texlab", { bin }) + if (!(await Filesystem.exists(bin))) { + log.error("Failed to extract texlab binary") + return } - return { - process: spawn(bin, { - cwd: root, - }), + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) } - }, - } - export const DockerfileLS: Info = { - id: "dockerfile", - extensions: [".dockerfile", "Dockerfile"], - root: async () => Instance.directory, - async spawn(root) { - let binary = which("docker-langserver") - const args: string[] = [] - if (!binary) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("dockerfile-language-server-nodejs") - if (!resolved) return - binary = resolved - } - args.push("--stdio") - const proc = spawn(binary, args, { + log.info("installed texlab", { bin }) + } + + return { + process: spawn(bin, { + cwd: root, + }), + } + }, +} + +export const DockerfileLS: Info = { + id: "dockerfile", + extensions: [".dockerfile", "Dockerfile"], + root: async () => Instance.directory, + async spawn(root) { + let binary = which("docker-langserver") + const args: string[] = [] + if (!binary) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + const resolved = await Npm.which("dockerfile-language-server-nodejs") + if (!resolved) return + binary = resolved + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + }, + }) + return { + process: proc, + } + }, +} + +export const Gleam: Info = { + id: "gleam", + extensions: [".gleam"], + root: NearestRoot(["gleam.toml"]), + async spawn(root) { + const gleam = which("gleam") + if (!gleam) { + log.info("gleam not found, please install gleam first") + return + } + return { + process: spawn(gleam, ["lsp"], { + cwd: root, + }), + } + }, +} + +export const Clojure: Info = { + id: "clojure-lsp", + extensions: [".clj", ".cljs", ".cljc", ".edn"], + root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]), + async spawn(root) { + let bin = which("clojure-lsp") + if (!bin && process.platform === "win32") { + bin = which("clojure-lsp.exe") + } + if (!bin) { + log.info("clojure-lsp not found, please install clojure-lsp first") + return + } + return { + process: spawn(bin, ["listen"], { + cwd: root, + }), + } + }, +} + +export const Nixd: Info = { + id: "nixd", + extensions: [".nix"], + root: async (file) => { + // First, look for flake.nix - the most reliable Nix project root indicator + const flakeRoot = await NearestRoot(["flake.nix"])(file) + if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot + + // If no flake.nix, fall back to git repository root + if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree + + // Finally, use the instance directory as fallback + return Instance.directory + }, + async spawn(root) { + const nixd = which("nixd") + if (!nixd) { + log.info("nixd not found, please install nixd first") + return + } + return { + process: spawn(nixd, [], { cwd: root, env: { ...process.env, }, - }) - return { - process: proc, - } - }, - } - - export const Gleam: Info = { - id: "gleam", - extensions: [".gleam"], - root: NearestRoot(["gleam.toml"]), - async spawn(root) { - const gleam = which("gleam") - if (!gleam) { - log.info("gleam not found, please install gleam first") - return - } - return { - process: spawn(gleam, ["lsp"], { - cwd: root, - }), - } - }, - } - - export const Clojure: Info = { - id: "clojure-lsp", - extensions: [".clj", ".cljs", ".cljc", ".edn"], - root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]), - async spawn(root) { - let bin = which("clojure-lsp") - if (!bin && process.platform === "win32") { - bin = which("clojure-lsp.exe") - } - if (!bin) { - log.info("clojure-lsp not found, please install clojure-lsp first") - return - } - return { - process: spawn(bin, ["listen"], { - cwd: root, - }), - } - }, - } - - export const Nixd: Info = { - id: "nixd", - extensions: [".nix"], - root: async (file) => { - // First, look for flake.nix - the most reliable Nix project root indicator - const flakeRoot = await NearestRoot(["flake.nix"])(file) - if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot - - // If no flake.nix, fall back to git repository root - if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree - - // Finally, use the instance directory as fallback - return Instance.directory - }, - async spawn(root) { - const nixd = which("nixd") - if (!nixd) { - log.info("nixd not found, please install nixd first") - return - } - return { - process: spawn(nixd, [], { - cwd: root, - env: { - ...process.env, - }, - }), - } - }, - } - - export const Tinymist: Info = { - id: "tinymist", - extensions: [".typ", ".typc"], - root: NearestRoot(["typst.toml"]), - async spawn(root) { - let bin = which("tinymist") - - if (!bin) { - if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - log.info("downloading tinymist from GitHub releases") - - const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest") - if (!response.ok) { - log.error("Failed to fetch tinymist release info") - return - } - - const release = (await response.json()) as { - tag_name?: string - assets?: { name?: string; browser_download_url?: string }[] - } - - const platform = process.platform - const arch = process.arch - - const tinymistArch = arch === "arm64" ? "aarch64" : "x86_64" - let tinymistPlatform: string - let ext: string - - if (platform === "darwin") { - tinymistPlatform = "apple-darwin" - ext = "tar.gz" - } else if (platform === "win32") { - tinymistPlatform = "pc-windows-msvc" - ext = "zip" - } else { - tinymistPlatform = "unknown-linux-gnu" - ext = "tar.gz" - } - - const assetName = `tinymist-${tinymistArch}-${tinymistPlatform}.${ext}` - - const assets = release.assets ?? [] - const asset = assets.find((a) => a.name === assetName) - if (!asset?.browser_download_url) { - log.error(`Could not find asset ${assetName} in tinymist release`) - return - } - - const downloadResponse = await fetch(asset.browser_download_url) - if (!downloadResponse.ok) { - log.error("Failed to download tinymist") - return - } - - const tempPath = path.join(Global.Path.bin, assetName) - if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) - - if (ext === "zip") { - const ok = await Archive.extractZip(tempPath, Global.Path.bin) - .then(() => true) - .catch((error) => { - log.error("Failed to extract tinymist archive", { error }) - return false - }) - if (!ok) return - } else { - await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin }) - } - - await fs.rm(tempPath, { force: true }) - - bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : "")) - - if (!(await Filesystem.exists(bin))) { - log.error("Failed to extract tinymist binary") - return - } - - if (platform !== "win32") { - await fs.chmod(bin, 0o755).catch(() => {}) - } - - log.info("installed tinymist", { bin }) - } - - return { - process: spawn(bin, { cwd: root }), - } - }, - } - - export const HLS: Info = { - id: "haskell-language-server", - extensions: [".hs", ".lhs"], - root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]), - async spawn(root) { - const bin = which("haskell-language-server-wrapper") - if (!bin) { - log.info("haskell-language-server-wrapper not found, please install haskell-language-server") - return - } - return { - process: spawn(bin, ["--lsp"], { - cwd: root, - }), - } - }, - } - - export const JuliaLS: Info = { - id: "julials", - extensions: [".jl"], - root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]), - async spawn(root) { - const julia = which("julia") - if (!julia) { - log.info("julia not found, please install julia first (https://julialang.org/downloads/)") - return - } - return { - process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], { - cwd: root, - }), - } - }, - } + }), + } + }, +} + +export const Tinymist: Info = { + id: "tinymist", + extensions: [".typ", ".typc"], + root: NearestRoot(["typst.toml"]), + async spawn(root) { + let bin = which("tinymist") + + if (!bin) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + log.info("downloading tinymist from GitHub releases") + + const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest") + if (!response.ok) { + log.error("Failed to fetch tinymist release info") + return + } + + const release = (await response.json()) as { + tag_name?: string + assets?: { name?: string; browser_download_url?: string }[] + } + + const platform = process.platform + const arch = process.arch + + const tinymistArch = arch === "arm64" ? "aarch64" : "x86_64" + let tinymistPlatform: string + let ext: string + + if (platform === "darwin") { + tinymistPlatform = "apple-darwin" + ext = "tar.gz" + } else if (platform === "win32") { + tinymistPlatform = "pc-windows-msvc" + ext = "zip" + } else { + tinymistPlatform = "unknown-linux-gnu" + ext = "tar.gz" + } + + const assetName = `tinymist-${tinymistArch}-${tinymistPlatform}.${ext}` + + const assets = release.assets ?? [] + const asset = assets.find((a) => a.name === assetName) + if (!asset?.browser_download_url) { + log.error(`Could not find asset ${assetName} in tinymist release`) + return + } + + const downloadResponse = await fetch(asset.browser_download_url) + if (!downloadResponse.ok) { + log.error("Failed to download tinymist") + return + } + + const tempPath = path.join(Global.Path.bin, assetName) + if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body) + + if (ext === "zip") { + const ok = await Archive.extractZip(tempPath, Global.Path.bin) + .then(() => true) + .catch((error) => { + log.error("Failed to extract tinymist archive", { error }) + return false + }) + if (!ok) return + } else { + await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin }) + } + + await fs.rm(tempPath, { force: true }) + + bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : "")) + + if (!(await Filesystem.exists(bin))) { + log.error("Failed to extract tinymist binary") + return + } + + if (platform !== "win32") { + await fs.chmod(bin, 0o755).catch(() => {}) + } + + log.info("installed tinymist", { bin }) + } + + return { + process: spawn(bin, { cwd: root }), + } + }, +} + +export const HLS: Info = { + id: "haskell-language-server", + extensions: [".hs", ".lhs"], + root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]), + async spawn(root) { + const bin = which("haskell-language-server-wrapper") + if (!bin) { + log.info("haskell-language-server-wrapper not found, please install haskell-language-server") + return + } + return { + process: spawn(bin, ["--lsp"], { + cwd: root, + }), + } + }, +} + +export const JuliaLS: Info = { + id: "julials", + extensions: [".jl"], + root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]), + async spawn(root) { + const julia = which("julia") + if (!julia) { + log.info("julia not found, please install julia first (https://julialang.org/downloads/)") + return + } + return { + process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], { + cwd: root, + }), + } + }, } diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index 414d11f8e7..f124fddf95 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, beforeEach } from "bun:test" import path from "path" -import { LSPClient } from "../../src/lsp/client" -import { LSPServer } from "../../src/lsp/server" +import { LSPClient } from "../../src/lsp" +import { LSPServer } from "../../src/lsp" import { Instance } from "../../src/project/instance" import { Log } from "../../src/util" diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index b12a61ae3c..7419f3bf5c 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -2,7 +2,7 @@ import { describe, expect, spyOn } from "bun:test" import path from "path" import { Effect, Layer } from "effect" import { LSP } from "../../src/lsp" -import { LSPServer } from "../../src/lsp/server" +import { LSPServer } from "../../src/lsp" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index a6de869fcb..fe14729736 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import path from "path" import { Effect, Layer } from "effect" import { LSP } from "../../src/lsp" -import { LSPServer } from "../../src/lsp/server" +import { LSPServer } from "../../src/lsp" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect"