summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-07-08 18:14:24 -0400
committerDax Raad <[email protected]>2025-07-08 18:14:49 -0400
commit6de955847c7e8df7cd2714f9c891ac97ae7603de (patch)
treecd7c4da56607fa2cc5a8b8ad571240e417083a4c
parent3ba5d528b42462dab68591a894f40288ea1508fa (diff)
downloadopencode-6de955847c7e8df7cd2714f9c891ac97ae7603de.tar.gz
opencode-6de955847c7e8df7cd2714f9c891ac97ae7603de.zip
big rework of LSP system
-rw-r--r--packages/opencode/src/cli/cmd/debug/lsp.ts1
-rw-r--r--packages/opencode/src/cli/cmd/debug/ripgrep.ts2
-rw-r--r--packages/opencode/src/file/ripgrep.ts13
-rw-r--r--packages/opencode/src/lsp/client.ts51
-rw-r--r--packages/opencode/src/lsp/index.ts42
-rw-r--r--packages/opencode/src/lsp/server.ts176
-rw-r--r--packages/opencode/src/tool/glob.ts2
-rw-r--r--packages/opencode/src/util/filesystem.ts12
8 files changed, 144 insertions, 155 deletions
diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts
index 8a54b4b08..28e01912f 100644
--- a/packages/opencode/src/cli/cmd/debug/lsp.ts
+++ b/packages/opencode/src/cli/cmd/debug/lsp.ts
@@ -25,7 +25,6 @@ export const SymbolsCommand = cmd({
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
- await LSP.touchFile("./src/index.ts", true)
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts
index 3e2b434e1..b8005c908 100644
--- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts
+++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts
@@ -45,7 +45,7 @@ const FilesCommand = cmd({
const files = await Ripgrep.files({
cwd: app.path.cwd,
query: args.query,
- glob: args.glob,
+ glob: args.glob ? [args.glob] : undefined,
limit: args.limit,
})
console.log(files.join("\n"))
diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts
index c5f2cb798..07334bc00 100644
--- a/packages/opencode/src/file/ripgrep.ts
+++ b/packages/opencode/src/file/ripgrep.ts
@@ -185,10 +185,15 @@ export namespace Ripgrep {
return filepath
}
- export async function files(input: { cwd: string; query?: string; glob?: string; limit?: number }) {
- const commands = [
- `${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*' ${input.glob ? `--glob='${input.glob}'` : ``}`,
- ]
+ export async function files(input: { cwd: string; query?: string; glob?: string[]; limit?: number }) {
+ const commands = [`${$.escape(await filepath())} --files --follow --hidden --glob='!.git/*'`]
+
+ if (input.glob) {
+ for (const g of input.glob) {
+ commands[0] += ` --glob='${g}'`
+ }
+ }
+
if (input.query) commands.push(`${await Fzf.filepath()} --filter=${input.query}`)
if (input.limit) commands.push(`head -n ${input.limit}`)
const joined = commands.join(" | ")
diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts
index 7fafe7a90..3d0d383ff 100644
--- a/packages/opencode/src/lsp/client.ts
+++ b/packages/opencode/src/lsp/client.ts
@@ -34,46 +34,54 @@ export namespace LSPClient {
),
}
- export async function create(serverID: string, server: LSPServer.Handle) {
+ export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
const app = App.info()
- log.info("starting client", { id: serverID })
+ const l = log.clone().tag("serverID", input.serverID)
+ l.info("starting client")
const connection = createMessageConnection(
- new StreamMessageReader(server.process.stdout),
- new StreamMessageWriter(server.process.stdin),
+ new StreamMessageReader(input.server.process.stdout),
+ new StreamMessageWriter(input.server.process.stdin),
)
const diagnostics = new Map<string, Diagnostic[]>()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const path = new URL(params.uri).pathname
- log.info("textDocument/publishDiagnostics", {
+ l.info("textDocument/publishDiagnostics", {
path,
})
const exists = diagnostics.has(path)
diagnostics.set(path, params.diagnostics)
- if (!exists && serverID === "typescript") return
- Bus.publish(Event.Diagnostics, { path, serverID })
+ if (!exists && input.serverID === "typescript") return
+ Bus.publish(Event.Diagnostics, { path, serverID: input.serverID })
+ })
+ connection.onRequest("window/workDoneProgress/create", (params) => {
+ l.info("window/workDoneProgress/create", params)
+ return null
})
connection.onRequest("workspace/configuration", async () => {
return [{}]
})
connection.listen()
- log.info("sending initialize", { id: serverID })
+ l.info("sending initialize")
await withTimeout(
connection.sendRequest("initialize", {
- rootUri: "file://" + app.path.cwd,
- processId: server.process.pid,
+ rootUri: "file://" + input.root,
+ processId: input.server.process.pid,
workspaceFolders: [
{
name: "workspace",
- uri: "file://" + app.path.cwd,
+ uri: "file://" + input.root,
},
],
initializationOptions: {
- ...server.initialization,
+ ...input.server.initialization,
},
capabilities: {
+ window: {
+ workDoneProgress: true,
+ },
workspace: {
configuration: true,
},
@@ -90,9 +98,9 @@ export namespace LSPClient {
}),
5_000,
).catch((err) => {
- log.error("initialize error", { error: err })
+ l.error("initialize error", { error: err })
throw new InitializeError(
- { serverID },
+ { serverID: input.serverID },
{
cause: err,
},
@@ -100,17 +108,15 @@ export namespace LSPClient {
})
await connection.sendNotification("initialized", {})
- log.info("initialized", {
- serverID,
- })
const files: {
[path: string]: number
} = {}
const result = {
+ root: input.root,
get serverID() {
- return serverID
+ return input.serverID
},
get connection() {
return connection
@@ -170,13 +176,18 @@ export namespace LSPClient {
})
},
async shutdown() {
- log.info("shutting down", { serverID })
+ l.info("shutting down")
connection.end()
connection.dispose()
- log.info("shutdown", { serverID })
+ l.info("shutdown")
},
}
+ if (input.server.onInitialized) {
+ await input.server.onInitialized(result)
+ }
+ l.info("initialized")
+
return result
}
}
diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts
index 3434c0305..18767958b 100644
--- a/packages/opencode/src/lsp/index.ts
+++ b/packages/opencode/src/lsp/index.ts
@@ -3,8 +3,8 @@ import { Log } from "../util/log"
import { LSPClient } from "./client"
import path from "path"
import { LSPServer } from "./server"
-import { Ripgrep } from "../file/ripgrep"
import { z } from "zod"
+import { Filesystem } from "../util/filesystem"
export namespace LSP {
const log = Log.create({ service: "lsp" })
@@ -36,29 +36,36 @@ export namespace LSP {
"lsp",
async (app) => {
log.info("initializing")
- const clients = new Map<string, LSPClient.Info>()
+ const clients: LSPClient.Info[] = []
+
for (const server of Object.values(LSPServer)) {
- for (const extension of server.extensions) {
- const [file] = await Ripgrep.files({
- cwd: app.path.cwd,
- glob: "*" + extension,
+ const roots = await server.roots(app)
+
+ for (const root of roots) {
+ if (!Filesystem.overlaps(app.path.cwd, root)) continue
+ log.info("", {
+ root,
+ serverID: server.id,
})
- if (!file) continue
- const handle = await server.spawn(App.info())
+ const handle = await server.spawn(App.info(), root)
if (!handle) break
- const client = await LSPClient.create(server.id, handle).catch((err) => log.error("", { error: err }))
+ const client = await LSPClient.create({
+ serverID: server.id,
+ server: handle,
+ root,
+ }).catch((err) => log.error("", { error: err }))
if (!client) break
- clients.set(server.id, client)
- break
+ clients.push(client)
}
}
+
log.info("initialized")
return {
clients,
}
},
async (state) => {
- for (const client of state.clients.values()) {
+ for (const client of state.clients) {
await client.shutdown()
}
},
@@ -109,14 +116,17 @@ export namespace LSP {
export async function workspaceSymbol(query: string) {
return run((client) =>
- client.connection.sendRequest("workspace/symbol", {
- query,
- }),
+ client.connection
+ .sendRequest("workspace/symbol", {
+ query,
+ })
+ .then((result: any) => result.slice(0, 10))
+ .catch(() => []),
).then((result) => result.flat() as LSP.Symbol[])
}
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
- const clients = await state().then((x) => [...x.clients.values()])
+ const clients = await state().then((x) => x.clients)
const tasks = clients.map((x) => input(x))
return Promise.all(tasks)
}
diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts
index 5546294be..006e67312 100644
--- a/packages/opencode/src/lsp/server.ts
+++ b/packages/opencode/src/lsp/server.ts
@@ -6,6 +6,9 @@ import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import fs from "fs/promises"
+import { unique } from "remeda"
+import { Ripgrep } from "../file/ripgrep"
+import type { LSPClient } from "./client"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -13,21 +16,40 @@ export namespace LSPServer {
export interface Handle {
process: ChildProcessWithoutNullStreams
initialization?: Record<string, any>
+ onInitialized?: (lsp: LSPClient.Info) => Promise<void>
+ }
+
+ type RootsFunction = (app: App.Info) => Promise<string[]>
+
+ const SimpleRoots = (patterns: string[]): RootsFunction => {
+ return async (app) => {
+ const glob = `**/*/{${patterns.join(",")}}`
+ const files = await Ripgrep.files({
+ glob: [glob],
+ cwd: app.path.root,
+ })
+ const dirs = files.map((file) => path.dirname(file))
+ return unique(dirs).map((dir) => path.join(app.path.root, dir))
+ }
}
export interface Info {
id: string
extensions: string[]
- spawn(app: App.Info): Promise<Handle | undefined>
+ global?: boolean
+ roots: (app: App.Info) => Promise<string[]>
+ spawn(app: App.Info, root: string): Promise<Handle | undefined>
}
export const Typescript: Info = {
id: "typescript",
+ roots: SimpleRoots(["tsconfig.json", "jsconfig.json", "package.json"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
- async spawn(app) {
+ async spawn(app, root) {
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], {
+ cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
@@ -40,14 +62,31 @@ export namespace LSPServer {
path: tsserver,
},
},
+ // tsserver sucks and won't start processing codebase until you open a file
+ onInitialized: async (lsp) => {
+ const [hint] = await Ripgrep.files({
+ cwd: lsp.root,
+ glob: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.mts", "*.cts"],
+ limit: 1,
+ })
+ await new Promise<void>(async (resolve) => {
+ const notif = lsp.connection.onNotification("$/progress", (params) => {
+ if (params.value.kind !== "end") return
+ notif.dispose()
+ resolve()
+ })
+ await lsp.notify.open({ path: hint })
+ })
+ },
}
},
}
export const Gopls: Info = {
id: "golang",
+ roots: SimpleRoots(["go.mod", "go.sum"]),
extensions: [".go"],
- async spawn() {
+ async spawn(_, root) {
let bin = Bun.which("gopls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
@@ -72,15 +111,18 @@ export namespace LSPServer {
})
}
return {
- process: spawn(bin!),
+ process: spawn(bin!, {
+ cwd: root,
+ }),
}
},
}
export const RubyLsp: Info = {
id: "ruby-lsp",
+ roots: SimpleRoots(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
- async spawn() {
+ async spawn(_, root) {
let bin = Bun.which("ruby-lsp", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
})
@@ -109,7 +151,9 @@ export namespace LSPServer {
})
}
return {
- process: spawn(bin!, ["--stdio"]),
+ process: spawn(bin!, ["--stdio"], {
+ cwd: root,
+ }),
}
},
}
@@ -117,8 +161,17 @@ export namespace LSPServer {
export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
- async spawn() {
+ roots: SimpleRoots([
+ "pyproject.toml",
+ "setup.py",
+ "setup.cfg",
+ "requirements.txt",
+ "Pipfile",
+ "pyrightconfig.json",
+ ]),
+ async spawn(_, root) {
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
+ cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
@@ -133,7 +186,8 @@ export namespace LSPServer {
export const ElixirLS: Info = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
- async spawn() {
+ roots: SimpleRoots(["mix.exs", "mix.lock"]),
+ async spawn(_, root) {
let binary = Bun.which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
@@ -177,109 +231,9 @@ export namespace LSPServer {
}
return {
- process: spawn(binary),
- }
- },
- }
-
- export const Zls: Info = {
- id: "zls",
- extensions: [".zig", ".zon"],
- async spawn() {
- let bin = Bun.which("zls", {
- PATH: process.env["PATH"] + ":" + Global.Path.bin,
- })
-
- if (!bin) {
- const zig = Bun.which("zig")
- if (!zig) {
- log.error("Zig is required to use zls. Please install Zig first.")
- 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()
-
- 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("Unsupported platform/architecture for zls", { platform, arch, assetName })
- return
- }
-
- const asset = release.assets?.find((a: any) => a.name === assetName)
-
- if (!asset) {
- log.error("Could not find zls download for platform", { platform, arch, assetName })
- return
- }
-
- const downloadUrl = asset.browser_download_url
- log.info("downloading zls", { url: downloadUrl })
-
- const response = await fetch(downloadUrl)
- if (!response.ok) {
- log.error("Failed to download zls")
- return
- }
-
- const isZip = assetName.endsWith(".zip")
- const archivePath = path.join(Global.Path.bin, isZip ? "zls.zip" : "zls.tar.xz")
- await Bun.file(archivePath).write(response)
-
- if (isZip) {
- await $`unzip -o -q ${archivePath} -d ${Global.Path.bin}`.nothrow()
- } else {
- await $`tar -xf ${archivePath} -C ${Global.Path.bin}`.quiet()
- }
-
- await fs.rm(archivePath, { force: true })
-
- if (platform !== "win32") {
- bin = path.join(Global.Path.bin, "zls")
- await $`chmod +x ${bin}`.quiet()
- } else {
- bin = path.join(Global.Path.bin, "zls.exe")
- }
-
- log.info("installed zls", { bin })
- }
-
- return {
- process: spawn(bin!),
+ process: spawn(binary, {
+ cwd: root,
+ }),
}
},
}
diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts
index 44f7ad8ee..6496099e1 100644
--- a/packages/opencode/src/tool/glob.ts
+++ b/packages/opencode/src/tool/glob.ts
@@ -27,7 +27,7 @@ export const GlobTool = Tool.define({
let truncated = false
for (const file of await Ripgrep.files({
cwd: search,
- glob: params.pattern,
+ glob: [params.pattern],
})) {
if (files.length >= limit) {
truncated = true
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index c4fd163c5..318f60b93 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -1,7 +1,17 @@
import { exists } from "fs/promises"
-import { dirname, join } from "path"
+import { dirname, join, relative } from "path"
export namespace Filesystem {
+ export function overlaps(a: string, b: string) {
+ const relA = relative(a, b)
+ const relB = relative(b, a)
+ return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
+ }
+
+ export function contains(parent: string, child: string) {
+ return relative(parent, child).startsWith("..")
+ }
+
export async function findUp(target: string, start: string, stop?: string) {
let current = start
const result = []