summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-15 23:30:52 -0400
committerGitHub <[email protected]>2026-04-15 23:30:52 -0400
commit509bc11f81430575c58887960a02e63fa0107c03 (patch)
tree37db6b2378327a2e15145fe1dcaa339ec052294a
parentf24207844f84d43536b1ac5655e6f3cb80237f9f (diff)
downloadopencode-509bc11f81430575c58887960a02e63fa0107c03.tar.gz
opencode-509bc11f81430575c58887960a02e63fa0107c03.zip
feat: unwrap lsp namespaces to flat exports + barrel (#22748)
-rw-r--r--packages/opencode/src/config/config.ts2
-rw-r--r--packages/opencode/src/lsp/client.ts392
-rw-r--r--packages/opencode/src/lsp/index.ts540
-rw-r--r--packages/opencode/src/lsp/lsp.ts535
-rw-r--r--packages/opencode/src/lsp/server.ts3350
-rw-r--r--packages/opencode/test/lsp/client.test.ts4
-rw-r--r--packages/opencode/test/lsp/index.test.ts2
-rw-r--r--packages/opencode/test/lsp/lifecycle.test.ts2
8 files changed, 2412 insertions, 2415 deletions
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 04801098b..d8cfd5e48 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 27301e79a..fed2bf5c9 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<Awaited<ReturnType<typeof create>>>
+export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
- 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 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")
+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),
- )
+ const connection = createMessageConnection(
+ new StreamMessageReader(input.server.process.stdout as any),
+ new StreamMessageWriter(input.server.process.stdin as any),
+ )
- const diagnostics = new Map<string, Diagnostic[]>()
- 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 })
+ const diagnostics = new Map<string, Diagnostic[]>()
+ connection.onNotification("textDocument/publishDiagnostics", (params) => {
+ const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
+ l.info("textDocument/publishDiagnostics", {
+ path: filePath,
+ count: params.diagnostics.length,
})
- 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,
+ 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,
},
- ])
- 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,
},
- capabilities: {
- window: {
- workDoneProgress: true,
+ workspace: {
+ configuration: true,
+ didChangeWatchedFiles: {
+ dynamicRegistration: true,
},
- workspace: {
- configuration: true,
- didChangeWatchedFiles: {
- dynamicRegistration: true,
- },
+ },
+ textDocument: {
+ synchronization: {
+ didOpen: true,
+ didChange: true,
},
- textDocument: {
- synchronization: {
- didOpen: true,
- didChange: true,
- },
- publishDiagnostics: {
- versionSupport: 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
+ }),
+ 45_000,
+ ).catch((err) => {
+ l.error("initialize error", { error: err })
+ throw new InitializeError(
+ { serverID: input.serverID },
+ {
+ cause: err,
},
- 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
- }
+ )
+ })
+
+ 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: 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
- },
- },
- get diagnostics() {
- return diagnostics
+ }
+
+ 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
},
- 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<typeof setTimeout> | undefined
- return await withTimeout(
- new Promise<void>((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<typeof setTimeout> | undefined
+ return await withTimeout(
+ new Promise<void>((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")
- },
- }
+ }),
+ 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")
+ l.info("initialized")
- return result
- }
+ return result
}
diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts
index 5146c40ab..9fc06fa21 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<typeof Range>
-
- 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<typeof Symbol>
-
- 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<typeof DocumentSymbol>
-
- 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<typeof Status>
-
- 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<string, LSPServer.Info>) => {
- 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<string, LSPServer.Info>
- broken: Set<string>
- spawning: Map<string, Promise<LSPClient.Info | undefined>>
- }
-
- export interface Interface {
- readonly init: () => Effect.Effect<void>
- readonly status: () => Effect.Effect<Status[]>
- readonly hasClients: (file: string) => Effect.Effect<boolean>
- readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect<void>
- readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
- readonly hover: (input: LocInput) => Effect.Effect<any>
- readonly definition: (input: LocInput) => Effect.Effect<any[]>
- readonly references: (input: LocInput) => Effect.Effect<any[]>
- readonly implementation: (input: LocInput) => Effect.Effect<any[]>
- readonly documentSymbol: (uri: string) => Effect.Effect<(LSP.DocumentSymbol | LSP.Symbol)[]>
- readonly workspaceSymbol: (query: string) => Effect.Effect<LSP.Symbol[]>
- readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>
- readonly incomingCalls: (input: LocInput) => Effect.Effect<any[]>
- readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
- }
-
- export class Service extends Context.Service<Service, Interface>()("@opencode/LSP") {}
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const config = yield* Config.Service
-
- const state = yield* InstanceState.make<State>(
- Effect.fn("LSP.state")(function* () {
- const cfg = yield* config.get()
-
- const servers: Record<string, LSPServer.Info> = {}
-
- 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* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
- const clients = yield* getClients(file)
- return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
- })
-
- const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
- 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<string, LSPClient.Diagnostic[]> = {}
- 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 `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
- }
- }
-}
+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 000000000..7f5b36313
--- /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<typeof Range>
+
+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<typeof Symbol>
+
+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<typeof DocumentSymbol>
+
+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<typeof Status>
+
+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<string, LSPServer.Info>) => {
+ 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<string, LSPServer.Info>
+ broken: Set<string>
+ spawning: Map<string, Promise<LSPClient.Info | undefined>>
+}
+
+export interface Interface {
+ readonly init: () => Effect.Effect<void>
+ readonly status: () => Effect.Effect<Status[]>
+ readonly hasClients: (file: string) => Effect.Effect<boolean>
+ readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect<void>
+ readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
+ readonly hover: (input: LocInput) => Effect.Effect<any>
+ readonly definition: (input: LocInput) => Effect.Effect<any[]>
+ readonly references: (input: LocInput) => Effect.Effect<any[]>
+ readonly implementation: (input: LocInput) => Effect.Effect<any[]>
+ readonly documentSymbol: (uri: string) => Effect.Effect<(DocumentSymbol | Symbol)[]>
+ readonly workspaceSymbol: (query: string) => Effect.Effect<Symbol[]>
+ readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>
+ readonly incomingCalls: (input: LocInput) => Effect.Effect<any[]>
+ readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/LSP") {}
+
+export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const config = yield* Config.Service
+
+ const state = yield* InstanceState.make<State>(
+ Effect.fn("LSP.state")(function* () {
+ const cfg = yield* config.get()
+
+ const servers: Record<string, LSPServer.Info> = {}
+
+ 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* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
+ const clients = yield* getClients(file)
+ return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
+ })
+
+ const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
+ 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<string, LSPClient.Diagnostic[]> = {}
+ 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 `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
+ }
+}
diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts
index 8110e8608..25aaaa36a 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 })
-
- export interface Handle {
- process: ChildProcessWithoutNullStreams
- initialization?: Record<string, any>
- }
+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<string, any>
+}
- type RootFunction = (file: string) => Promise<string | undefined>
+type RootFunction = (file: string) => Promise<string | undefined>
- 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<Handle | undefined>
- }
-
- 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 interface Info {
+ id: string
+ extensions: string[]
+ global?: boolean
+ root: RootFunction
+ spawn(root: string): Promise<Handle | undefined>
+}
- 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"], {
+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,
- 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 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 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")
+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.
+ },
+ }
+ },
+}
- 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)
+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 npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
- await Process.run([npmCmd, "install"], { cwd: finalPath })
- await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
+ const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main")
+ const finalPath = path.join(Global.Path.bin, "vscode-eslint")
- log.info("installed VS Code ESLint server", { serverPath })
+ 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 proc = spawn("node", [serverPath, "--stdio"], {
- cwd: root,
- env: {
- ...process.env,
- },
- })
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
+ await Process.run([npmCmd, "install"], { cwd: finalPath })
+ await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
- return {
- process: proc,
- }
- },
- }
+ log.info("installed VS Code ESLint server", { serverPath })
+ }
- 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,
- }),
- }
- }
- }
- }
+ const proc = spawn("node", [serverPath, "--stdio"], {
+ cwd: root,
+ env: {
+ ...process.env,
+ },
+ })
- 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
- },
- }
+ return {
+ process: proc,
+ }
+ },
+}
- 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,
- },
+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 {
- process: proc,
- }
- },
- }
+ return undefined
+ }
- 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
+ let lintBin = await resolveBin(lintTarget)
+ if (!lintBin) {
+ const found = which("oxlint")
+ if (found) lintBin = found
+ }
- 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
+ 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,
+ }),
+ }
}
- 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,
- })
- }
+ let serverBin = await resolveBin(serverTarget)
+ if (!serverBin) {
+ const found = which("oxc_language_server")
+ if (found) serverBin = found
+ }
+ if (serverBin) {
return {
- process: spawn(bin!, ["--lsp"], {
+ process: spawn(serverBin, [], {
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<string, string> = {}
-
- 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
- }
- }
- }
+ log.info("oxlint not found, please install oxlint")
+ return
+ },
+}
- if (!binary) {
- log.error("ty not found, please install ty 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
+ }
- const proc = spawn(binary, ["server"], {
- cwd: root,
- })
+ let args = ["lsp-proxy", "--stdio"]
- return {
- process: proc,
- initialization,
- }
- },
- }
+ if (!bin) {
+ const resolved = Module.resolve("biome", root)
+ if (!resolved) return
+ bin = await Npm.which("biome")
+ if (!bin) return
+ args = ["lsp-proxy", "--stdio"]
+ }
- 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 proc = spawn(bin, args, {
+ cwd: root,
+ env: {
+ ...process.env,
+ },
+ })
- const initialization: Record<string, string> = {}
+ return {
+ process: proc,
+ }
+ },
+}
- 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
- }
- }
+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
- const proc = spawn(binary, args, {
- cwd: root,
- env: {
- ...process.env,
- },
+ 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",
})
- return {
- process: proc,
- initialization,
+ const exit = await proc.exited
+ if (exit !== 0) {
+ log.error("Failed to install gopls")
+ 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 (!(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 })
+ bin = path.join(Global.Path.bin, "gopls" + (process.platform === "win32" ? ".exe" : ""))
+ log.info(`installed gopls`, {
+ bin,
+ })
+ }
+ return {
+ process: spawn(bin!, {
+ cwd: root,
+ }),
+ }
+ },
+}
- log.info(`installed elixir-ls`, {
- path: elixirLsPath,
- })
- }
+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
}
-
- return {
- process: spawn(binary, {
- cwd: root,
- }),
+ 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
}
- },
- }
-
- 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)
+ bin = path.join(Global.Path.bin, "rubocop" + (process.platform === "win32" ? ".exe" : ""))
+ log.info(`installed rubocop`, {
+ bin,
+ })
+ }
+ return {
+ process: spawn(bin!, ["--lsp"], {
+ cwd: root,
+ }),
+ }
+ },
+}
- 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 })
- }
+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
+ }
- await fs.rm(tempPath, { force: true })
+ let binary = which("ty")
- bin = path.join(Global.Path.bin, "zls" + (platform === "win32" ? ".exe" : ""))
+ const initialization: Record<string, string> = {}
- if (!(await Filesystem.exists(bin))) {
- log.error("Failed to extract zls binary")
- return
- }
+ 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 (platform !== "win32") {
- await fs.chmod(bin, 0o755).catch(() => {})
+ 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
}
-
- log.info(`installed zls`, { bin })
}
+ }
- return {
- process: spawn(bin, {
- cwd: root,
- }),
- }
- },
- }
+ if (!binary) {
+ log.error("ty not found, please install ty first")
+ return
+ }
- 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
- }
+ const proc = spawn(binary, ["server"], {
+ cwd: root,
+ })
- 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
- }
+ return {
+ process: proc,
+ initialization,
+ }
+ },
+}
- bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : ""))
- log.info(`installed csharp-ls`, { bin })
+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<string, string> = {}
+
+ 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
}
+ }
- return {
- process: spawn(bin, {
- cwd: root,
- }),
- }
- },
- }
+ const proc = spawn(binary, args, {
+ cwd: root,
+ env: {
+ ...process.env,
+ },
+ })
+ return {
+ process: proc,
+ initialization,
+ }
+ },
+}
- 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
- }
+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 (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")
+ if (!(await Filesystem.exists(binary))) {
+ const elixir = which("elixir")
+ if (!elixir) {
+ log.error("elixir is required to run elixir-ls")
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 (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ log.info("downloading elixir-ls from GitHub releases")
- // 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 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 lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"])
+ 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
- if (lspLoc.code !== 0) return
+ await fs.rm(zipPath, {
+ force: true,
+ recursive: true,
+ })
- const bin = lspLoc.text.trim()
+ 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 })
- return {
- process: spawn(bin, {
- cwd: root,
- }),
+ log.info(`installed elixir-ls`, {
+ path: elixirLsPath,
+ })
}
- },
- }
-
- 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 {
+ process: spawn(binary, {
+ cwd: root,
+ }),
+ }
+ },
+}
- 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")
+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
}
- 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)) {
- return {
- process: spawn(candidate, args, {
- cwd: root,
- }),
- }
- }
- }
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- log.info("downloading clangd from GitHub releases")
+ log.info("downloading zls from GitHub releases")
- const releaseResponse = await fetch("https://api.github.com/repos/clangd/clangd/releases/latest")
+ const releaseResponse = await fetch("https://api.github.com/repos/zigtools/zls/releases/latest")
if (!releaseResponse.ok) {
- log.error("Failed to fetch clangd release info")
+ log.error("Failed to fetch zls release info")
return
}
- const release: {
- tag_name?: string
- assets?: { name?: string; browser_download_url?: string }[]
- } = await releaseResponse.json()
+ const release = (await releaseResponse.json()) as any
- 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<string, string> = {
- darwin: "mac",
- linux: "linux",
- win32: "windows",
- }
- const token = tokens[platform]
- if (!token) {
- log.error(`Platform ${platform} is not supported by clangd auto-download`)
+ 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 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 })
+ 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 name = asset.name
- const downloadResponse = await fetch(asset.browser_download_url)
+ const downloadUrl = asset.browser_download_url
+ const downloadResponse = await fetch(downloadUrl)
if (!downloadResponse.ok) {
- log.error("Failed to download clangd")
+ log.error("Failed to download zls")
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 tempPath = path.join(Global.Path.bin, assetName)
+ if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
- 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)
+ if (ext === "zip") {
+ const ok = await Archive.extractZip(tempPath, Global.Path.bin)
.then(() => true)
.catch((error) => {
- log.error("Failed to extract clangd archive", { error })
+ log.error("Failed to extract zls archive", { error })
return false
})
if (!ok) return
+ } else {
+ await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin })
}
- 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(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,
+ }),
+ }
+ },
+}
- return {
- process: spawn(bin, args, {
- 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
}
- },
- }
- 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,
- },
+ 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",
})
- 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")
+ const exit = await proc.exited
+ if (exit !== 0) {
+ log.error("Failed to install csharp-ls")
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, {
+ bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : ""))
+ log.info(`installed csharp-ls`, { bin })
+ }
+
+ return {
+ process: spawn(bin, {
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.")
+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
}
- const javaMajorVersion = await run(["java", "-version"]).then((result) => {
- const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString())
- return !m ? undefined : parseInt(m[1])
+
+ 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",
})
- if (javaMajorVersion == null || javaMajorVersion < 21) {
- log.error("JDTLS requires at least Java 21.")
+ const exit = await proc.exited
+ if (exit !== 0) {
+ log.error("Failed to install fsautocomplete")
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
- }
+ bin = path.join(Global.Path.bin, "fsautocomplete" + (process.platform === "win32" ? ".exe" : ""))
+ log.info(`installed fsautocomplete`, { bin })
+ }
- 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(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(
- 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,
- },
- ),
+ process: spawn(sourcekit, {
+ 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 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 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 lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"])
- const release = await releaseResponse.json()
- const version = release.name?.replace(/^v/, "")
-
- if (!version) {
- log.error("Could not determine Kotlin LSP version from release")
- return
- }
+ if (lspLoc.code !== 0) return
- const platform = process.platform
- const arch = process.arch
+ const bin = lspLoc.text.trim()
- let kotlinArch: string = arch
- if (arch === "arm64") kotlinArch = "aarch64"
- else if (arch === "x64") kotlinArch = "x64"
+ return {
+ process: spawn(bin, {
+ cwd: root,
+ }),
+ }
+ },
+}
- let kotlinPlatform: string = platform
- if (platform === "darwin") kotlinPlatform = "mac"
- else if (platform === "linux") kotlinPlatform = "linux"
- else if (platform === "win32") kotlinPlatform = "win"
+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
- const supportedCombos = ["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"]
+ 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 combo = `${kotlinPlatform}-${kotlinArch}`
+ const parentDir = path.dirname(currentDir)
+ if (parentDir === currentDir) break // Reached filesystem root
+ currentDir = parentDir
- if (!supportedCombos.includes(combo)) {
- log.error(`Platform ${platform}/${arch} is not supported by Kotlin LSP`)
- return
- }
+ // Stop if we've gone above the app root
+ if (!currentDir.startsWith(Instance.worktree)) break
+ }
- const assetName = `kotlin-lsp-${version}-${kotlinPlatform}-${kotlinArch}.zip`
- const releaseURL = `https://download-cdn.jetbrains.com/kotlin-lsp/${version}/${assetName}`
+ 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,
+ }),
+ }
+ },
+}
- 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
- }
+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(launcherScript, ["--stdio"], {
+ process: spawn(fromPath, args, {
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,
- },
- })
+ const ext = process.platform === "win32" ? ".exe" : ""
+ const direct = path.join(Global.Path.bin, "clangd" + ext)
+ if (await Filesystem.exists(direct)) {
return {
- process: proc,
+ process: spawn(direct, args, {
+ cwd: root,
+ }),
}
- },
- }
-
- 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 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,
+ }),
}
+ }
+ }
- const release = await releaseResponse.json()
-
- const platform = process.platform
- const arch = process.arch
- let assetName = ""
+ if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ log.info("downloading clangd from GitHub releases")
- let lualsArch: string = arch
- if (arch === "arm64") lualsArch = "arm64"
- else if (arch === "x64") lualsArch = "x64"
- else if (arch === "ia32") lualsArch = "ia32"
+ 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
+ }
- let lualsPlatform: string = platform
- if (platform === "darwin") lualsPlatform = "darwin"
- else if (platform === "linux") lualsPlatform = "linux"
- else if (platform === "win32") lualsPlatform = "win32"
+ const release: {
+ tag_name?: string
+ assets?: { name?: string; browser_download_url?: string }[]
+ } = await releaseResponse.json()
- const ext = platform === "win32" ? "zip" : "tar.gz"
+ 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<string, string> = {
+ darwin: "mac",
+ linux: "linux",
+ win32: "windows",
+ }
+ const token = tokens[platform]
+ if (!token) {
+ log.error(`Platform ${platform} is not supported by clangd auto-download`)
+ return
+ }
- assetName = `lua-language-server-${release.tag_name}-${lualsPlatform}-${lualsArch}.${ext}`
+ 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 supportedCombos = [
- "darwin-arm64.tar.gz",
- "darwin-x64.tar.gz",
- "linux-x64.tar.gz",
- "linux-arm64.tar.gz",
- "win32-x64.zip",
- "win32-ia32.zip",
- ]
+ 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 assetSuffix = `${lualsPlatform}-${lualsArch}.${ext}`
- if (!supportedCombos.includes(assetSuffix)) {
- log.error(`Platform ${platform} and architecture ${arch} is not supported by lua-language-server`)
- return
- }
+ const name = asset.name
+ const downloadResponse = await fetch(asset.browser_download_url)
+ if (!downloadResponse.ok) {
+ log.error("Failed to download clangd")
+ 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 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 downloadUrl = asset.browser_download_url
- const downloadResponse = await fetch(downloadUrl)
- if (!downloadResponse.ok) {
- log.error("Failed to download lua-language-server")
- return
- }
+ const zip = name.endsWith(".zip")
+ const tar = name.endsWith(".tar.xz")
+ if (!zip && !tar) {
+ log.error("clangd encountered unsupported asset", { asset: name })
+ return
+ }
- const tempPath = path.join(Global.Path.bin, assetName)
- if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
+ 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 })
- // 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}`)
+ 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
+ }
- // Remove old installation if exists
- const stats = await fs.stat(installDir).catch(() => undefined)
- if (stats) {
- await fs.rm(installDir, { force: true, recursive: true })
- }
+ if (platform !== "win32") {
+ await fs.chmod(bin, 0o755).catch(() => {})
+ }
- await fs.mkdir(installDir, { recursive: true })
+ await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
+ await fs.symlink(bin, path.join(Global.Path.bin, "clangd")).catch(() => {})
- 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 (!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
- }
+ log.info(`installed clangd`, { bin })
- await fs.rm(tempPath, { force: true })
+ return {
+ process: spawn(bin, args, {
+ cwd: root,
+ }),
+ }
+ },
+}
- // Binary is located in bin/ subdirectory within the extracted archive
- bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : ""))
+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: {},
+ }
+ },
+}
- if (!(await Filesystem.exists(bin))) {
- log.error("Failed to extract lua-language-server binary")
- return
- }
+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)
- 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
- }
+ 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,
+ },
+ },
+ }
+ },
+}
- log.info(`installed lua-language-server`, { bin })
+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)
- return {
- process: spawn(bin, {
- cwd: root,
- }),
+ 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
}
- },
- }
- 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,
- },
+ 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,
},
- }
- },
- }
+ ),
+ }
+ },
+}
- 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")
+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.")
+
+ 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
}
- 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")
+ const release = await releaseResponse.json()
+ const version = release.name?.replace(/^v/, "")
+
+ if (!version) {
+ log.error("Could not determine Kotlin LSP version from release")
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")
+ const platform = process.platform
+ const arch = process.arch
+
+ let kotlinArch: string = arch
+ if (arch === "arm64") kotlinArch = "aarch64"
+ else if (arch === "x64") kotlinArch = "x64"
+
+ let kotlinPlatform: string = platform
+ if (platform === "darwin") kotlinPlatform = "mac"
+ else if (platform === "linux") kotlinPlatform = "linux"
+ else if (platform === "win32") kotlinPlatform = "win"
+
+ const supportedCombos = ["mac-x64", "mac-aarch64", "linux-x64", "linux-aarch64", "win-x64", "win-aarch64"]
+
+ const combo = `${kotlinPlatform}-${kotlinArch}`
+
+ if (!supportedCombos.includes(combo)) {
+ log.error(`Platform ${platform}/${arch} is not supported by Kotlin LSP`)
return
}
- return {
- process: spawn(bin, {
- cwd: root,
- }),
+
+ 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
}
- },
- }
- 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
+ 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(() => {})
}
- args.push("start")
- const proc = spawn(binary, args, {
+ 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,
- 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")
+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,
+ }
+ },
+}
- if (!bin) {
- if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- log.info("downloading terraform-ls from HashiCorp releases")
+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.releases.hashicorp.com/v1/releases/terraform-ls/latest")
- if (!releaseResponse.ok) {
- log.error("Failed to fetch terraform-ls release info")
- return
- }
+ 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()) as {
- version?: string
- builds?: { arch?: string; os?: string; url?: string }[]
- }
+ const release = await releaseResponse.json()
- const platform = process.platform
- const arch = process.arch
+ 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 tfArch = arch === "arm64" ? "arm64" : "amd64"
- const tfPlatform = platform === "win32" ? "windows" : platform
+ 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 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 downloadUrl = asset.browser_download_url
+ const downloadResponse = await fetch(downloadUrl)
+ if (!downloadResponse.ok) {
+ log.error("Failed to download lua-language-server")
+ return
+ }
- const downloadResponse = await fetch(build.url)
- if (!downloadResponse.ok) {
- log.error("Failed to download terraform-ls")
- return
- }
+ const tempPath = path.join(Global.Path.bin, assetName)
+ if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
- const tempPath = path.join(Global.Path.bin, "terraform-ls.zip")
- 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}`)
- const ok = await Archive.extractZip(tempPath, Global.Path.bin)
+ // 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 terraform-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(tempPath, { force: true })
+ }
- bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : ""))
+ await fs.rm(tempPath, { force: true })
- if (!(await Filesystem.exists(bin))) {
- log.error("Failed to extract terraform-ls binary")
- return
- }
+ // Binary is located in bin/ subdirectory within the extracted archive
+ bin = path.join(installDir, "bin", "lua-language-server" + (platform === "win32" ? ".exe" : ""))
- if (platform !== "win32") {
- await fs.chmod(bin, 0o755).catch(() => {})
- }
+ if (!(await Filesystem.exists(bin))) {
+ log.error("Failed to extract lua-language-server binary")
+ return
+ }
- log.info(`installed terraform-ls`, { bin })
+ 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
}
- return {
- process: spawn(bin, ["serve"], {
- cwd: root,
- }),
- initialization: {
- experimentalFeatures: {
- prefillRequiredFields: true,
- validateOnSave: true,
- },
+ log.info(`installed lua-language-server`, { bin })
+ }
+
+ return {
+ process: spawn(bin, {
+ cwd: root,
+ }),
+ }
+ },
+}
+
+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,
},
- }
- },
- }
+ },
+ }
+ },
+}
- export const TexLab: Info = {
- id: "texlab",
- extensions: [".tex", ".bib"],
- root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
- async spawn(root) {
- let bin = which("texlab")
+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,
+ }),
+ }
+ },
+}
- if (!bin) {
- if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- log.info("downloading texlab from GitHub releases")
+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,
+ }),
+ }
+ },
+}
- 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
- }
+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,
+ }
+ },
+}
- 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
- }
+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 platform = process.platform
- const arch = process.arch
+ if (!bin) {
+ if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ log.info("downloading terraform-ls from HashiCorp releases")
- 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 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 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 release = (await releaseResponse.json()) as {
+ version?: string
+ builds?: { arch?: string; os?: string; url?: string }[]
+ }
- const downloadResponse = await fetch(asset.browser_download_url)
- if (!downloadResponse.ok) {
- log.error("Failed to download texlab")
- return
- }
+ const platform = process.platform
+ const arch = process.arch
- const tempPath = path.join(Global.Path.bin, assetName)
- if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
+ const tfArch = arch === "arm64" ? "arm64" : "amd64"
+ const tfPlatform = platform === "win32" ? "windows" : platform
- 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 })
- }
+ 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
+ }
- 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, "texlab" + (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 texlab binary")
- 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 })
- if (platform !== "win32") {
- await fs.chmod(bin, 0o755).catch(() => {})
- }
+ bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : ""))
- log.info("installed texlab", { bin })
+ if (!(await Filesystem.exists(bin))) {
+ log.error("Failed to extract terraform-ls 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 terraform-ls`, { bin })
+ }
+
+ return {
+ process: spawn(bin, ["serve"], {
cwd: root,
- env: {
- ...process.env,
+ }),
+ initialization: {
+ experimentalFeatures: {
+ prefillRequiredFields: true,
+ validateOnSave: true,
},
- })
- return {
- process: proc,
- }
- },
- }
+ },
+ }
+ },
+}
+
+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")
- 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")
+ 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
}
- 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")
+ 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
}
- return {
- process: spawn(bin, ["listen"], {
- cwd: root,
- }),
+
+ 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
}
- },
- }
- 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")
+ const downloadResponse = await fetch(asset.browser_download_url)
+ if (!downloadResponse.ok) {
+ log.error("Failed to download texlab")
return
}
- return {
- process: spawn(nixd, [], {
- cwd: root,
- env: {
- ...process.env,
- },
- }),
+
+ 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 })
}
- },
- }
- export const Tinymist: Info = {
- id: "tinymist",
- extensions: [".typ", ".typc"],
- root: NearestRoot(["typst.toml"]),
- async spawn(root) {
- let bin = which("tinymist")
+ await fs.rm(tempPath, { force: true })
- if (!bin) {
- if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- log.info("downloading tinymist from GitHub releases")
+ bin = path.join(Global.Path.bin, "texlab" + (platform === "win32" ? ".exe" : ""))
- 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
- }
+ if (!(await Filesystem.exists(bin))) {
+ log.error("Failed to extract texlab binary")
+ return
+ }
- const release = (await response.json()) as {
- tag_name?: string
- assets?: { name?: string; browser_download_url?: string }[]
- }
+ if (platform !== "win32") {
+ await fs.chmod(bin, 0o755).catch(() => {})
+ }
- 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"
- }
+ log.info("installed texlab", { bin })
+ }
- const assetName = `tinymist-${tinymistArch}-${tinymistPlatform}.${ext}`
+ return {
+ process: spawn(bin, {
+ cwd: root,
+ }),
+ }
+ },
+}
- 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
- }
+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,
+ }
+ },
+}
- const downloadResponse = await fetch(asset.browser_download_url)
- if (!downloadResponse.ok) {
- log.error("Failed to download tinymist")
- return
- }
+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,
+ }),
+ }
+ },
+}
- const tempPath = path.join(Global.Path.bin, assetName)
- if (downloadResponse.body) await Filesystem.writeStream(tempPath, downloadResponse.body)
+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,
+ }),
+ }
+ },
+}
- 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 })
- }
+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,
+ },
+ }),
+ }
+ },
+}
- await fs.rm(tempPath, { force: true })
+export const Tinymist: Info = {
+ id: "tinymist",
+ extensions: [".typ", ".typc"],
+ root: NearestRoot(["typst.toml"]),
+ async spawn(root) {
+ let bin = which("tinymist")
- bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : ""))
+ if (!bin) {
+ if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+ log.info("downloading tinymist from GitHub releases")
- if (!(await Filesystem.exists(bin))) {
- log.error("Failed to extract tinymist binary")
- return
- }
+ 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
+ }
- if (platform !== "win32") {
- await fs.chmod(bin, 0o755).catch(() => {})
- }
+ const release = (await response.json()) as {
+ tag_name?: string
+ assets?: { name?: string; browser_download_url?: string }[]
+ }
- log.info("installed tinymist", { bin })
+ 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"
}
- return {
- process: spawn(bin, { cwd: root }),
+ 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
}
- },
- }
- 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")
+ const downloadResponse = await fetch(asset.browser_download_url)
+ if (!downloadResponse.ok) {
+ log.error("Failed to download tinymist")
return
}
- return {
- process: spawn(bin, ["--lsp"], {
- cwd: root,
- }),
+
+ 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 })
}
- },
- }
- 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/)")
+ 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
}
- return {
- process: spawn(julia, ["--startup-file=no", "--history-file=no", "-e", "using LanguageServer; runserver()"], {
- cwd: root,
- }),
+
+ 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 414d11f8e..f124fddf9 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 b12a61ae3..7419f3bf5 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 a6de869fc..fe1472973 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"