diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 8a54b4b0..28e01912 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 3e2b434e..b8005c90 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 c5f2cb79..07334bc0 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 7fafe7a9..3d0d383f 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() 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 3434c030..18767958 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() + 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(input: (client: LSPClient.Info) => Promise): Promise { - 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 5546294b..006e6731 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 + onInitialized?: (lsp: LSPClient.Info) => Promise + } + + type RootsFunction = (app: App.Info) => Promise + + 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 + global?: boolean + roots: (app: App.Info) => Promise + spawn(app: App.Info, root: string): Promise } 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(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 44f7ad8e..6496099e 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 c4fd163c..318f60b9 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 = []