From 69dc89bc84de41f9159241824d047da8b1c3e799 Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Thu, 8 Jan 2026 15:32:37 +0200 Subject: [PATCH 01/13] feat(global): add Platform helpers for isWindows and binExt --- packages/opencode/src/global/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 7be58634e1cf..dd15193af635 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -11,6 +11,11 @@ const config = path.join(xdgConfig!, app) const state = path.join(xdgState!, app) export namespace Global { + export const Platform = { + isWindows: process.platform === "win32", + binExt: process.platform === "win32" ? ".exe" : "", + } + export const Path = { // Allow override via OPENCODE_TEST_HOME for test isolation get home() { From 03aa388f0aa985bc90942fca566efa054ccc555c Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Thu, 8 Jan 2026 15:32:43 +0200 Subject: [PATCH 02/13] refactor(ripgrep): improve code structure and readability - Extract platform config, download, and extraction into separate functions - Split extraction logic into extractPosix and extractWindows - Simplify filepath export by removing unnecessary wrapper - Replace deprecated Bun.readableStreamToText with Response.text() - Remove duplicate conditional check for zip extraction --- packages/opencode/src/file/ripgrep.ts | 188 ++++++++++++++------------ 1 file changed, 102 insertions(+), 86 deletions(-) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 841f5f305175..5bb64f0f18bc 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -88,17 +88,97 @@ export namespace Ripgrep { export type Begin = z.infer export type End = z.infer export type Summary = z.infer - const PLATFORM = { - "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" }, - "arm64-linux": { - platform: "aarch64-unknown-linux-gnu", - extension: "tar.gz", - }, - "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" }, - "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" }, - "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" }, + + const RG_VERSION = "14.1.1" + + const PLATFORMS = { + "arm64-darwin": { target: "aarch64-apple-darwin", ext: "tar.gz" }, + "arm64-linux": { target: "aarch64-unknown-linux-gnu", ext: "tar.gz" }, + "x64-darwin": { target: "x86_64-apple-darwin", ext: "tar.gz" }, + "x64-linux": { target: "x86_64-unknown-linux-musl", ext: "tar.gz" }, + "x64-win32": { target: "x86_64-pc-windows-msvc", ext: "zip" }, } as const + type Platform = keyof typeof PLATFORMS + + function getPlatformConfig() { + const platform = `${process.arch}-${process.platform}` + if (!(platform in PLATFORMS)) { + throw new UnsupportedPlatformError({ platform }) + } + return PLATFORMS[platform as Platform] + } + + async function findExisting(): Promise { + const systemPath = Bun.which("rg") + if (systemPath) return systemPath + + const localPath = path.join(Global.Path.bin, `rg${Global.Platform.binExt}`) + if (await Bun.file(localPath).exists()) return localPath + + return null + } + + async function downloadRg(config: { target: string; ext: string }): Promise { + const filename = `ripgrep-${RG_VERSION}-${config.target}.${config.ext}` + const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${filename}` + + const response = await fetch(url) + if (!response.ok) throw new DownloadFailedError({ url, status: response.status }) + + const archivePath = path.join(Global.Path.bin, filename) + await Bun.write(archivePath, await response.arrayBuffer()) + + return archivePath + } + + async function extractPosix(archivePath: string, binRgPath: string): Promise { + const args = ["tar", "-xzf", archivePath, "--strip-components=1"] + + if (process.platform === "darwin") args.push("--include=*/rg") + if (process.platform === "linux") args.push("--wildcards", "*/rg") + + const proc = Bun.spawn(args, { + cwd: Global.Path.bin, + stderr: "pipe", + stdout: "pipe", + }) + await proc.exited + + if (proc.exitCode !== 0) { + throw new ExtractionFailedError({ + filepath: binRgPath, + stderr: await new Response(proc.stderr).text(), + }) + } + + await fs.chmod(binRgPath, 0o755) + } + + async function extractWindows(archivePath: string, binRgPath: string): Promise { + const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()]))) + const entries = await zipFileReader.getEntries() + + const rgEntry = entries.find((e) => e.filename.endsWith("rg.exe")) + if (!rgEntry) { + throw new ExtractionFailedError({ + filepath: archivePath, + stderr: "rg.exe not found in zip archive", + }) + } + + const rgBlob = await rgEntry.getData!(new BlobWriter()) + if (!rgBlob) { + throw new ExtractionFailedError({ + filepath: archivePath, + stderr: "Failed to extract rg.exe from zip archive", + }) + } + + await Bun.write(binRgPath, await rgBlob.arrayBuffer()) + await zipFileReader.close() + } + export const ExtractionFailedError = NamedError.create( "RipgrepExtractionFailedError", z.object({ @@ -122,88 +202,24 @@ export namespace Ripgrep { }), ) - const state = lazy(async () => { - let filepath = Bun.which("rg") - if (filepath) return { filepath } - filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : "")) - - const file = Bun.file(filepath) - if (!(await file.exists())) { - const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM - const config = PLATFORM[platformKey] - if (!config) throw new UnsupportedPlatformError({ platform: platformKey }) - - const version = "14.1.1" - const filename = `ripgrep-${version}-${config.platform}.${config.extension}` - const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}` - - const response = await fetch(url) - if (!response.ok) throw new DownloadFailedError({ url, status: response.status }) - - const buffer = await response.arrayBuffer() - const archivePath = path.join(Global.Path.bin, filename) - await Bun.write(archivePath, buffer) - if (config.extension === "tar.gz") { - const args = ["tar", "-xzf", archivePath, "--strip-components=1"] - - if (platformKey.endsWith("-darwin")) args.push("--include=*/rg") - if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg") - - const proc = Bun.spawn(args, { - cwd: Global.Path.bin, - stderr: "pipe", - stdout: "pipe", - }) - await proc.exited - if (proc.exitCode !== 0) - throw new ExtractionFailedError({ - filepath, - stderr: await Bun.readableStreamToText(proc.stderr), - }) - } - if (config.extension === "zip") { - if (config.extension === "zip") { - const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()]))) - const entries = await zipFileReader.getEntries() - let rgEntry: any - for (const entry of entries) { - if (entry.filename.endsWith("rg.exe")) { - rgEntry = entry - break - } - } + export const filepath = lazy(async (): Promise => { + const existing = await findExisting() + if (existing) return existing - if (!rgEntry) { - throw new ExtractionFailedError({ - filepath: archivePath, - stderr: "rg.exe not found in zip archive", - }) - } + const binRgPath = path.join(Global.Path.bin, `rg${Global.Platform.binExt}`) + const config = getPlatformConfig() + const archivePath = await downloadRg(config) - const rgBlob = await rgEntry.getData(new BlobWriter()) - if (!rgBlob) { - throw new ExtractionFailedError({ - filepath: archivePath, - stderr: "Failed to extract rg.exe from zip archive", - }) - } - await Bun.write(filepath, await rgBlob.arrayBuffer()) - await zipFileReader.close() - } - } - await fs.unlink(archivePath) - if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755) + if (Global.Platform.isWindows) { + await extractWindows(archivePath, binRgPath) + } else { + await extractPosix(archivePath, binRgPath) } - return { - filepath, - } - }) + await fs.unlink(archivePath) - export async function filepath() { - const { filepath } = await state() - return filepath - } + return binRgPath + }) export async function* files(input: { cwd: string From 0dc6debfeadcb60cddb385a64265fd8b40b49ba8 Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Thu, 8 Jan 2026 15:54:23 +0200 Subject: [PATCH 03/13] refactor(ripgrep): organize exports and extract helpers - Group all exports at bottom of file with clear sections - Extract buildFilesArgs helper for ripgrep argument construction - Extract streamLines helper for async line streaming - Add FilesInput interface and MAX_BUFFER_BYTES constant - Rename internal Match schema to MatchSchema to avoid conflict --- packages/opencode/src/file/ripgrep.ts | 159 +++++++++++++++----------- 1 file changed, 92 insertions(+), 67 deletions(-) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 5bb64f0f18bc..5e731ba397fb 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -11,7 +11,28 @@ import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" import { Log } from "@/util/log" export namespace Ripgrep { + // ============================================================================ + // Internal: Constants + // ============================================================================ + const log = Log.create({ service: "ripgrep" }) + + const RG_VERSION = "14.1.1" + + const PLATFORMS = { + "arm64-darwin": { target: "aarch64-apple-darwin", ext: "tar.gz" }, + "arm64-linux": { target: "aarch64-unknown-linux-gnu", ext: "tar.gz" }, + "x64-darwin": { target: "x86_64-apple-darwin", ext: "tar.gz" }, + "x64-linux": { target: "x86_64-unknown-linux-musl", ext: "tar.gz" }, + "x64-win32": { target: "x86_64-pc-windows-msvc", ext: "zip" }, + } as const + + type Platform = keyof typeof PLATFORMS + + // ============================================================================ + // Internal: Schemas (for parsing ripgrep JSON output) + // ============================================================================ + const Stats = z.object({ elapsed: z.object({ secs: z.number(), @@ -35,7 +56,7 @@ export namespace Ripgrep { }), }) - export const Match = z.object({ + const MatchSchema = z.object({ type: z.literal("match"), data: z.object({ path: z.object({ @@ -81,25 +102,11 @@ export namespace Ripgrep { }), }) - const Result = z.union([Begin, Match, End, Summary]) + const Result = z.union([Begin, MatchSchema, End, Summary]) - export type Result = z.infer - export type Match = z.infer - export type Begin = z.infer - export type End = z.infer - export type Summary = z.infer - - const RG_VERSION = "14.1.1" - - const PLATFORMS = { - "arm64-darwin": { target: "aarch64-apple-darwin", ext: "tar.gz" }, - "arm64-linux": { target: "aarch64-unknown-linux-gnu", ext: "tar.gz" }, - "x64-darwin": { target: "x86_64-apple-darwin", ext: "tar.gz" }, - "x64-linux": { target: "x86_64-unknown-linux-musl", ext: "tar.gz" }, - "x64-win32": { target: "x86_64-pc-windows-msvc", ext: "zip" }, - } as const - - type Platform = keyof typeof PLATFORMS + // ============================================================================ + // Internal: Functions + // ============================================================================ function getPlatformConfig() { const platform = `${process.arch}-${process.platform}` @@ -179,6 +186,54 @@ export namespace Ripgrep { await zipFileReader.close() } + const MAX_BUFFER_BYTES = 20 * 1024 * 1024 + + interface FilesInput { + cwd: string + glob?: string[] + hidden?: boolean + follow?: boolean + maxDepth?: number + } + + function buildFilesArgs(input: FilesInput): string[] { + const args = ["--files", "--glob=!.git/*"] + if (input.follow !== false) args.push("--follow") + if (input.hidden !== false) args.push("--hidden") + if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) + for (const g of input.glob ?? []) args.push(`--glob=${g}`) + return args + } + + async function* streamLines(stdout: ReadableStream): AsyncGenerator { + const reader = stdout.getReader() + const decoder = new TextDecoder() + let buffer = "" + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() || "" + + for (const line of lines) { + if (line) yield line + } + } + + if (buffer) yield buffer + } finally { + reader.releaseLock() + } + } + + // ============================================================================ + // Exports: Errors + // ============================================================================ + export const ExtractionFailedError = NamedError.create( "RipgrepExtractionFailedError", z.object({ @@ -202,6 +257,14 @@ export namespace Ripgrep { }), ) + export type Result = z.infer + export type Match = z.infer + export type Begin = z.infer + export type End = z.infer + export type Summary = z.infer + + export const Match = MatchSchema + export const filepath = lazy(async (): Promise => { const existing = await findExisting() if (existing) return existing @@ -221,23 +284,7 @@ export namespace Ripgrep { return binRgPath }) - export async function* files(input: { - cwd: string - glob?: string[] - hidden?: boolean - follow?: boolean - maxDepth?: number - }) { - const args = [await filepath(), "--files", "--glob=!.git/*"] - if (input.follow !== false) args.push("--follow") - if (input.hidden !== false) args.push("--hidden") - if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) - if (input.glob) { - for (const g of input.glob) { - args.push(`--glob=${g}`) - } - } - + export async function* files(input: FilesInput) { // Bun.spawn should throw this, but it incorrectly reports that the executable does not exist. // See https://github.com/oven-sh/bun/issues/24012 if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) { @@ -248,42 +295,21 @@ export namespace Ripgrep { }) } - const proc = Bun.spawn(args, { + const proc = Bun.spawn([await filepath(), ...buildFilesArgs(input)], { cwd: input.cwd, stdout: "pipe", stderr: "ignore", - maxBuffer: 1024 * 1024 * 20, + maxBuffer: MAX_BUFFER_BYTES, }) - const reader = proc.stdout.getReader() - const decoder = new TextDecoder() - let buffer = "" - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break + yield* streamLines(proc.stdout) - buffer += decoder.decode(value, { stream: true }) - // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = buffer.split(/\r?\n/) - buffer = lines.pop() || "" - - for (const line of lines) { - if (line) yield line - } - } - - if (buffer) yield buffer - } finally { - reader.releaseLock() - await proc.exited - } + await proc.exited } export async function tree(input: { cwd: string; limit?: number }) { log.info("tree", input) - const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd })) + const fileList = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd })) interface Node { path: string[] children: Node[] @@ -311,7 +337,7 @@ export namespace Ripgrep { path: [], children: [], } - for (const file of files) { + for (const file of fileList) { if (file.includes(".opencode")) continue const parts = file.split(path.sep) getPath(root, parts, true) @@ -400,14 +426,13 @@ export namespace Ripgrep { args.push(input.pattern) const command = args.join(" ") - const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() - if (result.exitCode !== 0) { + const searchResult = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() + if (searchResult.exitCode !== 0) { return [] } // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = result.text().trim().split(/\r?\n/).filter(Boolean) - // Parse JSON lines from ripgrep output + const lines = searchResult.text().trim().split(/\r?\n/).filter(Boolean) return lines .map((line) => JSON.parse(line)) From a2c76c97d54800c89411f27f3a11809238e73d72 Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 9 Jan 2026 10:41:42 +0200 Subject: [PATCH 04/13] refactor(ripgrep): split into directory with separate modules Split ripgrep.ts into a directory structure for better organization: - binary.ts: Platform config, download, extract, filepath - schema.ts: Zod schemas for ripgrep JSON output - tree.ts: TreeNode interface and tree helpers (build, sort, truncate, render) - index.ts: Public API (files, tree, search) and re-exports --- packages/opencode/src/file/ripgrep.ts | 443 ------------------- packages/opencode/src/file/ripgrep/binary.ts | 138 ++++++ packages/opencode/src/file/ripgrep/index.ts | 144 ++++++ packages/opencode/src/file/ripgrep/schema.ts | 78 ++++ packages/opencode/src/file/ripgrep/tree.ts | 109 +++++ 5 files changed, 469 insertions(+), 443 deletions(-) delete mode 100644 packages/opencode/src/file/ripgrep.ts create mode 100644 packages/opencode/src/file/ripgrep/binary.ts create mode 100644 packages/opencode/src/file/ripgrep/index.ts create mode 100644 packages/opencode/src/file/ripgrep/schema.ts create mode 100644 packages/opencode/src/file/ripgrep/tree.ts diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts deleted file mode 100644 index 5e731ba397fb..000000000000 --- a/packages/opencode/src/file/ripgrep.ts +++ /dev/null @@ -1,443 +0,0 @@ -// Ripgrep utility functions -import path from "path" -import { Global } from "../global" -import fs from "fs/promises" -import z from "zod" -import { NamedError } from "@opencode-ai/util/error" -import { lazy } from "../util/lazy" -import { $ } from "bun" - -import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" -import { Log } from "@/util/log" - -export namespace Ripgrep { - // ============================================================================ - // Internal: Constants - // ============================================================================ - - const log = Log.create({ service: "ripgrep" }) - - const RG_VERSION = "14.1.1" - - const PLATFORMS = { - "arm64-darwin": { target: "aarch64-apple-darwin", ext: "tar.gz" }, - "arm64-linux": { target: "aarch64-unknown-linux-gnu", ext: "tar.gz" }, - "x64-darwin": { target: "x86_64-apple-darwin", ext: "tar.gz" }, - "x64-linux": { target: "x86_64-unknown-linux-musl", ext: "tar.gz" }, - "x64-win32": { target: "x86_64-pc-windows-msvc", ext: "zip" }, - } as const - - type Platform = keyof typeof PLATFORMS - - // ============================================================================ - // Internal: Schemas (for parsing ripgrep JSON output) - // ============================================================================ - - const Stats = z.object({ - elapsed: z.object({ - secs: z.number(), - nanos: z.number(), - human: z.string(), - }), - searches: z.number(), - searches_with_match: z.number(), - bytes_searched: z.number(), - bytes_printed: z.number(), - matched_lines: z.number(), - matches: z.number(), - }) - - const Begin = z.object({ - type: z.literal("begin"), - data: z.object({ - path: z.object({ - text: z.string(), - }), - }), - }) - - const MatchSchema = z.object({ - type: z.literal("match"), - data: z.object({ - path: z.object({ - text: z.string(), - }), - lines: z.object({ - text: z.string(), - }), - line_number: z.number(), - absolute_offset: z.number(), - submatches: z.array( - z.object({ - match: z.object({ - text: z.string(), - }), - start: z.number(), - end: z.number(), - }), - ), - }), - }) - - const End = z.object({ - type: z.literal("end"), - data: z.object({ - path: z.object({ - text: z.string(), - }), - binary_offset: z.number().nullable(), - stats: Stats, - }), - }) - - const Summary = z.object({ - type: z.literal("summary"), - data: z.object({ - elapsed_total: z.object({ - human: z.string(), - nanos: z.number(), - secs: z.number(), - }), - stats: Stats, - }), - }) - - const Result = z.union([Begin, MatchSchema, End, Summary]) - - // ============================================================================ - // Internal: Functions - // ============================================================================ - - function getPlatformConfig() { - const platform = `${process.arch}-${process.platform}` - if (!(platform in PLATFORMS)) { - throw new UnsupportedPlatformError({ platform }) - } - return PLATFORMS[platform as Platform] - } - - async function findExisting(): Promise { - const systemPath = Bun.which("rg") - if (systemPath) return systemPath - - const localPath = path.join(Global.Path.bin, `rg${Global.Platform.binExt}`) - if (await Bun.file(localPath).exists()) return localPath - - return null - } - - async function downloadRg(config: { target: string; ext: string }): Promise { - const filename = `ripgrep-${RG_VERSION}-${config.target}.${config.ext}` - const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${filename}` - - const response = await fetch(url) - if (!response.ok) throw new DownloadFailedError({ url, status: response.status }) - - const archivePath = path.join(Global.Path.bin, filename) - await Bun.write(archivePath, await response.arrayBuffer()) - - return archivePath - } - - async function extractPosix(archivePath: string, binRgPath: string): Promise { - const args = ["tar", "-xzf", archivePath, "--strip-components=1"] - - if (process.platform === "darwin") args.push("--include=*/rg") - if (process.platform === "linux") args.push("--wildcards", "*/rg") - - const proc = Bun.spawn(args, { - cwd: Global.Path.bin, - stderr: "pipe", - stdout: "pipe", - }) - await proc.exited - - if (proc.exitCode !== 0) { - throw new ExtractionFailedError({ - filepath: binRgPath, - stderr: await new Response(proc.stderr).text(), - }) - } - - await fs.chmod(binRgPath, 0o755) - } - - async function extractWindows(archivePath: string, binRgPath: string): Promise { - const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()]))) - const entries = await zipFileReader.getEntries() - - const rgEntry = entries.find((e) => e.filename.endsWith("rg.exe")) - if (!rgEntry) { - throw new ExtractionFailedError({ - filepath: archivePath, - stderr: "rg.exe not found in zip archive", - }) - } - - const rgBlob = await rgEntry.getData!(new BlobWriter()) - if (!rgBlob) { - throw new ExtractionFailedError({ - filepath: archivePath, - stderr: "Failed to extract rg.exe from zip archive", - }) - } - - await Bun.write(binRgPath, await rgBlob.arrayBuffer()) - await zipFileReader.close() - } - - const MAX_BUFFER_BYTES = 20 * 1024 * 1024 - - interface FilesInput { - cwd: string - glob?: string[] - hidden?: boolean - follow?: boolean - maxDepth?: number - } - - function buildFilesArgs(input: FilesInput): string[] { - const args = ["--files", "--glob=!.git/*"] - if (input.follow !== false) args.push("--follow") - if (input.hidden !== false) args.push("--hidden") - if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) - for (const g of input.glob ?? []) args.push(`--glob=${g}`) - return args - } - - async function* streamLines(stdout: ReadableStream): AsyncGenerator { - const reader = stdout.getReader() - const decoder = new TextDecoder() - let buffer = "" - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split(/\r?\n/) - buffer = lines.pop() || "" - - for (const line of lines) { - if (line) yield line - } - } - - if (buffer) yield buffer - } finally { - reader.releaseLock() - } - } - - // ============================================================================ - // Exports: Errors - // ============================================================================ - - export const ExtractionFailedError = NamedError.create( - "RipgrepExtractionFailedError", - z.object({ - filepath: z.string(), - stderr: z.string(), - }), - ) - - export const UnsupportedPlatformError = NamedError.create( - "RipgrepUnsupportedPlatformError", - z.object({ - platform: z.string(), - }), - ) - - export const DownloadFailedError = NamedError.create( - "RipgrepDownloadFailedError", - z.object({ - url: z.string(), - status: z.number(), - }), - ) - - export type Result = z.infer - export type Match = z.infer - export type Begin = z.infer - export type End = z.infer - export type Summary = z.infer - - export const Match = MatchSchema - - export const filepath = lazy(async (): Promise => { - const existing = await findExisting() - if (existing) return existing - - const binRgPath = path.join(Global.Path.bin, `rg${Global.Platform.binExt}`) - const config = getPlatformConfig() - const archivePath = await downloadRg(config) - - if (Global.Platform.isWindows) { - await extractWindows(archivePath, binRgPath) - } else { - await extractPosix(archivePath, binRgPath) - } - - await fs.unlink(archivePath) - - return binRgPath - }) - - export async function* files(input: FilesInput) { - // Bun.spawn should throw this, but it incorrectly reports that the executable does not exist. - // See https://github.com/oven-sh/bun/issues/24012 - if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) { - throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), { - code: "ENOENT", - errno: -2, - path: input.cwd, - }) - } - - const proc = Bun.spawn([await filepath(), ...buildFilesArgs(input)], { - cwd: input.cwd, - stdout: "pipe", - stderr: "ignore", - maxBuffer: MAX_BUFFER_BYTES, - }) - - yield* streamLines(proc.stdout) - - await proc.exited - } - - export async function tree(input: { cwd: string; limit?: number }) { - log.info("tree", input) - const fileList = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd })) - interface Node { - path: string[] - children: Node[] - } - - function getPath(node: Node, parts: string[], create: boolean) { - if (parts.length === 0) return node - let current = node - for (const part of parts) { - let existing = current.children.find((x) => x.path.at(-1) === part) - if (!existing) { - if (!create) return - existing = { - path: current.path.concat(part), - children: [], - } - current.children.push(existing) - } - current = existing - } - return current - } - - const root: Node = { - path: [], - children: [], - } - for (const file of fileList) { - if (file.includes(".opencode")) continue - const parts = file.split(path.sep) - getPath(root, parts, true) - } - - function sort(node: Node) { - node.children.sort((a, b) => { - if (!a.children.length && b.children.length) return 1 - if (!b.children.length && a.children.length) return -1 - return a.path.at(-1)!.localeCompare(b.path.at(-1)!) - }) - for (const child of node.children) { - sort(child) - } - } - sort(root) - - let current = [root] - const result: Node = { - path: [], - children: [], - } - - let processed = 0 - const limit = input.limit ?? 50 - while (current.length > 0) { - const next = [] - for (const node of current) { - if (node.children.length) next.push(...node.children) - } - const max = Math.max(...current.map((x) => x.children.length)) - for (let i = 0; i < max && processed < limit; i++) { - for (const node of current) { - const child = node.children[i] - if (!child) continue - getPath(result, child.path, true) - processed++ - if (processed >= limit) break - } - } - if (processed >= limit) { - for (const node of [...current, ...next]) { - const compare = getPath(result, node.path, false) - if (!compare) continue - if (compare?.children.length !== node.children.length) { - const diff = node.children.length - compare.children.length - compare.children.push({ - path: compare.path.concat(`[${diff} truncated]`), - children: [], - }) - } - } - break - } - current = next - } - - const lines: string[] = [] - - function render(node: Node, depth: number) { - const indent = "\t".repeat(depth) - lines.push(indent + node.path.at(-1) + (node.children.length ? "/" : "")) - for (const child of node.children) { - render(child, depth + 1) - } - } - result.children.map((x) => render(x, 0)) - - return lines.join("\n") - } - - export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) { - const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] - - if (input.glob) { - for (const g of input.glob) { - args.push(`--glob=${g}`) - } - } - - if (input.limit) { - args.push(`--max-count=${input.limit}`) - } - - args.push("--") - args.push(input.pattern) - - const command = args.join(" ") - const searchResult = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() - if (searchResult.exitCode !== 0) { - return [] - } - - // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = searchResult.text().trim().split(/\r?\n/).filter(Boolean) - - return lines - .map((line) => JSON.parse(line)) - .map((parsed) => Result.parse(parsed)) - .filter((r) => r.type === "match") - .map((r) => r.data) - } -} diff --git a/packages/opencode/src/file/ripgrep/binary.ts b/packages/opencode/src/file/ripgrep/binary.ts new file mode 100644 index 000000000000..ad5c147d44a4 --- /dev/null +++ b/packages/opencode/src/file/ripgrep/binary.ts @@ -0,0 +1,138 @@ +import path from "path" +import fs from "fs/promises" +import z from "zod" +import { Global } from "../../global" +import { NamedError } from "@opencode-ai/util/error" +import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" + +const RG_VERSION = "14.1.1" + +const PLATFORMS = { + "arm64-darwin": { target: "aarch64-apple-darwin", ext: "tar.gz" }, + "arm64-linux": { target: "aarch64-unknown-linux-gnu", ext: "tar.gz" }, + "x64-darwin": { target: "x86_64-apple-darwin", ext: "tar.gz" }, + "x64-linux": { target: "x86_64-unknown-linux-musl", ext: "tar.gz" }, + "x64-win32": { target: "x86_64-pc-windows-msvc", ext: "zip" }, +} as const + +type Platform = keyof typeof PLATFORMS + +const ExtractionFailedError = NamedError.create( + "RipgrepExtractionFailedError", + z.object({ + filepath: z.string(), + stderr: z.string(), + }), +) + +const UnsupportedPlatformError = NamedError.create( + "RipgrepUnsupportedPlatformError", + z.object({ + platform: z.string(), + }), +) + +const DownloadFailedError = NamedError.create( + "RipgrepDownloadFailedError", + z.object({ + url: z.string(), + status: z.number(), + }), +) + +function getPlatformConfig() { + const platform = `${process.arch}-${process.platform}` + if (!(platform in PLATFORMS)) { + throw new UnsupportedPlatformError({ platform }) + } + return PLATFORMS[platform as Platform] +} + +async function findExisting(): Promise { + const systemPath = Bun.which("rg") + if (systemPath) return systemPath + + const localPath = path.join(Global.Path.bin, `rg${Global.Platform.binExt}`) + if (await Bun.file(localPath).exists()) return localPath + + return null +} + +async function downloadRg(config: { target: string; ext: string }): Promise { + const filename = `ripgrep-${RG_VERSION}-${config.target}.${config.ext}` + const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${filename}` + + const response = await fetch(url) + if (!response.ok) throw new DownloadFailedError({ url, status: response.status }) + + const archivePath = path.join(Global.Path.bin, filename) + await Bun.write(archivePath, await response.arrayBuffer()) + + return archivePath +} + +async function extractPosix(archivePath: string, binRgPath: string): Promise { + const args = ["tar", "-xzf", archivePath, "--strip-components=1"] + + if (process.platform === "darwin") args.push("--include=*/rg") + if (process.platform === "linux") args.push("--wildcards", "*/rg") + + const proc = Bun.spawn(args, { + cwd: Global.Path.bin, + stderr: "pipe", + stdout: "pipe", + }) + await proc.exited + + if (proc.exitCode !== 0) { + throw new ExtractionFailedError({ + filepath: binRgPath, + stderr: await new Response(proc.stderr).text(), + }) + } + + await fs.chmod(binRgPath, 0o755) +} + +async function extractWindows(archivePath: string, binRgPath: string): Promise { + const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()]))) + const entries = await zipFileReader.getEntries() + + const rgEntry = entries.find((e) => e.filename.endsWith("rg.exe")) + if (!rgEntry) { + throw new ExtractionFailedError({ + filepath: archivePath, + stderr: "rg.exe not found in zip archive", + }) + } + + const rgBlob = await rgEntry.getData!(new BlobWriter()) + if (!rgBlob) { + throw new ExtractionFailedError({ + filepath: archivePath, + stderr: "Failed to extract rg.exe from zip archive", + }) + } + + await Bun.write(binRgPath, await rgBlob.arrayBuffer()) + await zipFileReader.close() +} + +export async function ensureInstalled(): Promise { + const existing = await findExisting() + if (existing) return existing + + const binRgPath = path.join(Global.Path.bin, `rg${Global.Platform.binExt}`) + const config = getPlatformConfig() + const archivePath = await downloadRg(config) + + if (Global.Platform.isWindows) { + await extractWindows(archivePath, binRgPath) + } else { + await extractPosix(archivePath, binRgPath) + } + + await fs.unlink(archivePath) + + return binRgPath +} diff --git a/packages/opencode/src/file/ripgrep/index.ts b/packages/opencode/src/file/ripgrep/index.ts new file mode 100644 index 000000000000..1cca67ec96d1 --- /dev/null +++ b/packages/opencode/src/file/ripgrep/index.ts @@ -0,0 +1,144 @@ +import fs from "fs/promises" +import { $ } from "bun" +import { Log } from "@/util/log" + +import { ensureInstalled } from "./binary" +import { lazy } from "../../util/lazy" +import { + Result as _Result, + Match as _Match, + Begin as _Begin, + End as _End, + Summary as _Summary, +} from "./schema" +import type { + Result as _ResultType, + Match as _MatchType, + Begin as _BeginType, + End as _EndType, + Summary as _SummaryType, +} from "./schema" +import { DEFAULT_TREE_LIMIT, buildTree, sortTreeInPlace, truncateBFS, renderTree } from "./tree" + +export namespace Ripgrep { + const log = Log.create({ service: "ripgrep" }) + + const MAX_BUFFER_BYTES = 20 * 1024 * 1024 + + interface FilesInput { + cwd: string + glob?: string[] + hidden?: boolean + follow?: boolean + maxDepth?: number + } + + function buildFilesArgs(input: FilesInput): string[] { + const args = ["--files", "--glob=!.git/*"] + if (input.follow !== false) args.push("--follow") + if (input.hidden !== false) args.push("--hidden") + if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) + for (const g of input.glob ?? []) args.push(`--glob=${g}`) + return args + } + + async function* streamLines(stdout: ReadableStream): AsyncGenerator { + const reader = stdout.getReader() + const decoder = new TextDecoder() + let buffer = "" + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() || "" + + for (const line of lines) { + if (line) yield line + } + } + + if (buffer) yield buffer + } finally { + reader.releaseLock() + } + } + + export const filepath = lazy(ensureInstalled) + + // Re-export from schema.ts + export const Result = _Result + export const Match = _Match + export type Result = _ResultType + export type Match = _MatchType + export type Begin = _BeginType + export type End = _EndType + export type Summary = _SummaryType + + export async function* files(input: FilesInput) { + // Bun.spawn should throw this, but it incorrectly reports that the executable does not exist. + // See https://github.com/oven-sh/bun/issues/24012 + if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) { + throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), { + code: "ENOENT", + errno: -2, + path: input.cwd, + }) + } + + const proc = Bun.spawn([await filepath(), ...buildFilesArgs(input)], { + cwd: input.cwd, + stdout: "pipe", + stderr: "ignore", + maxBuffer: MAX_BUFFER_BYTES, + }) + + yield* streamLines(proc.stdout) + + await proc.exited + } + + export async function tree(input: { cwd: string; limit?: number }) { + log.info("tree", input) + const fileList = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd })) + const root = buildTree(fileList) + sortTreeInPlace(root) + const truncated = truncateBFS(root, input.limit ?? DEFAULT_TREE_LIMIT) + return renderTree(truncated) + } + + export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) { + const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] + + if (input.glob) { + for (const g of input.glob) { + args.push(`--glob=${g}`) + } + } + + if (input.limit) { + args.push(`--max-count=${input.limit}`) + } + + args.push("--") + args.push(input.pattern) + + const command = args.join(" ") + const searchResult = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() + if (searchResult.exitCode !== 0) { + return [] + } + + // Handle both Unix (\n) and Windows (\r\n) line endings + const lines = searchResult.text().trim().split(/\r?\n/).filter(Boolean) + + return lines + .map((line) => JSON.parse(line)) + .map((parsed) => Result.parse(parsed)) + .filter((r) => r.type === "match") + .map((r) => r.data) + } +} diff --git a/packages/opencode/src/file/ripgrep/schema.ts b/packages/opencode/src/file/ripgrep/schema.ts new file mode 100644 index 000000000000..4cefec328648 --- /dev/null +++ b/packages/opencode/src/file/ripgrep/schema.ts @@ -0,0 +1,78 @@ +import z from "zod" + +export const Stats = z.object({ + elapsed: z.object({ + secs: z.number(), + nanos: z.number(), + human: z.string(), + }), + searches: z.number(), + searches_with_match: z.number(), + bytes_searched: z.number(), + bytes_printed: z.number(), + matched_lines: z.number(), + matches: z.number(), +}) + +export const Begin = z.object({ + type: z.literal("begin"), + data: z.object({ + path: z.object({ + text: z.string(), + }), + }), +}) + +export const Match = z.object({ + type: z.literal("match"), + data: z.object({ + path: z.object({ + text: z.string(), + }), + lines: z.object({ + text: z.string(), + }), + line_number: z.number(), + absolute_offset: z.number(), + submatches: z.array( + z.object({ + match: z.object({ + text: z.string(), + }), + start: z.number(), + end: z.number(), + }), + ), + }), +}) + +export const End = z.object({ + type: z.literal("end"), + data: z.object({ + path: z.object({ + text: z.string(), + }), + binary_offset: z.number().nullable(), + stats: Stats, + }), +}) + +export const Summary = z.object({ + type: z.literal("summary"), + data: z.object({ + elapsed_total: z.object({ + human: z.string(), + nanos: z.number(), + secs: z.number(), + }), + stats: Stats, + }), +}) + +export const Result = z.union([Begin, Match, End, Summary]) + +export type Result = z.infer +export type Match = z.infer +export type Begin = z.infer +export type End = z.infer +export type Summary = z.infer diff --git a/packages/opencode/src/file/ripgrep/tree.ts b/packages/opencode/src/file/ripgrep/tree.ts new file mode 100644 index 000000000000..f3fb2a7bb2e2 --- /dev/null +++ b/packages/opencode/src/file/ripgrep/tree.ts @@ -0,0 +1,109 @@ +import path from "path" + +export const DEFAULT_TREE_LIMIT = 50 + +export interface TreeNode { + path: string[] + children: TreeNode[] +} + +export function getOrCreateNode(root: TreeNode, parts: string[], create: boolean): TreeNode | undefined { + if (parts.length === 0) return root + let current = root + for (const part of parts) { + let child = current.children.find((c) => c.path.at(-1) === part) + if (!child) { + if (!create) return undefined + child = { path: current.path.concat(part), children: [] } + current.children.push(child) + } + current = child + } + return current +} + +export function buildTree(files: string[]): TreeNode { + const root: TreeNode = { path: [], children: [] } + for (const file of files) { + if (file.includes(".opencode")) continue + getOrCreateNode(root, file.split(path.sep), true) + } + return root +} + +export function sortTreeInPlace(node: TreeNode): void { + node.children.sort((a, b) => { + const aIsDir = a.children.length > 0 + const bIsDir = b.children.length > 0 + if (aIsDir !== bIsDir) return aIsDir ? -1 : 1 + return a.path.at(-1)!.localeCompare(b.path.at(-1)!) + }) + for (const child of node.children) { + sortTreeInPlace(child) + } +} + +export function truncateBFS(source: TreeNode, limit: number): TreeNode { + const result: TreeNode = { path: [], children: [] } + let queue = [source] + let processed = 0 + + while (queue.length > 0 && processed < limit) { + const nextQueue: TreeNode[] = [] + + for (const node of queue) { + if (node.children.length > 0) { + nextQueue.push(...node.children) + } + } + + const maxChildren = Math.max(...queue.map((n) => n.children.length)) + for (let i = 0; i < maxChildren && processed < limit; i++) { + for (const node of queue) { + const child = node.children[i] + if (!child) continue + getOrCreateNode(result, child.path, true) + processed++ + if (processed >= limit) break + } + } + + if (processed >= limit) { + for (const node of [...queue, ...nextQueue]) { + const resultNode = getOrCreateNode(result, node.path, false) + if (!resultNode) continue + const truncatedCount = node.children.length - resultNode.children.length + if (truncatedCount > 0) { + resultNode.children.push({ + path: resultNode.path.concat(`[${truncatedCount} truncated]`), + children: [], + }) + } + } + break + } + + queue = nextQueue + } + + return result +} + +export function renderTree(node: TreeNode): string { + const lines: string[] = [] + + function render(n: TreeNode, depth: number) { + const name = n.path.at(-1) + const suffix = n.children.length > 0 ? "/" : "" + lines.push("\t".repeat(depth) + name + suffix) + for (const child of n.children) { + render(child, depth + 1) + } + } + + for (const child of node.children) { + render(child, 0) + } + + return lines.join("\n") +} From bae67bcb856b85189edb69a09251b01ffad6910d Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 9 Jan 2026 10:51:45 +0200 Subject: [PATCH 05/13] refactor(ripgrep): extract streamLines to io.ts and rename ensureInstalled to rg --- packages/opencode/src/file/ripgrep/binary.ts | 2 +- packages/opencode/src/file/ripgrep/index.ts | 47 +++++--------------- packages/opencode/src/file/ripgrep/io.ts | 24 ++++++++++ 3 files changed, 35 insertions(+), 38 deletions(-) create mode 100644 packages/opencode/src/file/ripgrep/io.ts diff --git a/packages/opencode/src/file/ripgrep/binary.ts b/packages/opencode/src/file/ripgrep/binary.ts index ad5c147d44a4..f4c5b2de1d4b 100644 --- a/packages/opencode/src/file/ripgrep/binary.ts +++ b/packages/opencode/src/file/ripgrep/binary.ts @@ -118,7 +118,7 @@ async function extractWindows(archivePath: string, binRgPath: string): Promise { +export async function rg(): Promise { const existing = await findExisting() if (existing) return existing diff --git a/packages/opencode/src/file/ripgrep/index.ts b/packages/opencode/src/file/ripgrep/index.ts index 1cca67ec96d1..a5c1d8b06fc1 100644 --- a/packages/opencode/src/file/ripgrep/index.ts +++ b/packages/opencode/src/file/ripgrep/index.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import { $ } from "bun" import { Log } from "@/util/log" -import { ensureInstalled } from "./binary" +import { rg } from "./binary" import { lazy } from "../../util/lazy" import { Result as _Result, @@ -19,6 +19,7 @@ import type { Summary as _SummaryType, } from "./schema" import { DEFAULT_TREE_LIMIT, buildTree, sortTreeInPlace, truncateBFS, renderTree } from "./tree" +import { streamLines } from "./io" export namespace Ripgrep { const log = Log.create({ service: "ripgrep" }) @@ -33,41 +34,7 @@ export namespace Ripgrep { maxDepth?: number } - function buildFilesArgs(input: FilesInput): string[] { - const args = ["--files", "--glob=!.git/*"] - if (input.follow !== false) args.push("--follow") - if (input.hidden !== false) args.push("--hidden") - if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) - for (const g of input.glob ?? []) args.push(`--glob=${g}`) - return args - } - - async function* streamLines(stdout: ReadableStream): AsyncGenerator { - const reader = stdout.getReader() - const decoder = new TextDecoder() - let buffer = "" - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split(/\r?\n/) - buffer = lines.pop() || "" - - for (const line of lines) { - if (line) yield line - } - } - - if (buffer) yield buffer - } finally { - reader.releaseLock() - } - } - - export const filepath = lazy(ensureInstalled) + export const filepath = lazy(rg) // Re-export from schema.ts export const Result = _Result @@ -89,7 +56,13 @@ export namespace Ripgrep { }) } - const proc = Bun.spawn([await filepath(), ...buildFilesArgs(input)], { + const args = ["--files", "--glob=!.git/*"] + if (input.follow !== false) args.push("--follow") + if (input.hidden !== false) args.push("--hidden") + if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) + for (const g of input.glob ?? []) args.push(`--glob=${g}`) + + const proc = Bun.spawn([await filepath(), ...args], { cwd: input.cwd, stdout: "pipe", stderr: "ignore", diff --git a/packages/opencode/src/file/ripgrep/io.ts b/packages/opencode/src/file/ripgrep/io.ts new file mode 100644 index 000000000000..ab8984e5999d --- /dev/null +++ b/packages/opencode/src/file/ripgrep/io.ts @@ -0,0 +1,24 @@ +export async function* streamLines(stdout: ReadableStream): AsyncGenerator { + const reader = stdout.getReader() + const decoder = new TextDecoder() + let buffer = "" + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() || "" + + for (const line of lines) { + if (line) yield line + } + } + + if (buffer) yield buffer + } finally { + reader.releaseLock() + } +} From 9d786c586add6e796192c4d94245b4668023c476 Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 9 Jan 2026 10:56:47 +0200 Subject: [PATCH 06/13] refactor(ripgrep): rename filepath to rg and simplify code --- packages/opencode/src/file/ripgrep/binary.ts | 10 +++++----- packages/opencode/src/file/ripgrep/index.ts | 18 ++++++++---------- packages/opencode/src/tool/grep.ts | 2 +- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/file/ripgrep/binary.ts b/packages/opencode/src/file/ripgrep/binary.ts index f4c5b2de1d4b..7d90c590ac99 100644 --- a/packages/opencode/src/file/ripgrep/binary.ts +++ b/packages/opencode/src/file/ripgrep/binary.ts @@ -118,21 +118,21 @@ async function extractWindows(archivePath: string, binRgPath: string): Promise { +export async function rgBin(): Promise { const existing = await findExisting() if (existing) return existing - const binRgPath = path.join(Global.Path.bin, `rg${Global.Platform.binExt}`) + const rgBinPath = path.join(Global.Path.bin, `rg${Global.Platform.binExt}`) const config = getPlatformConfig() const archivePath = await downloadRg(config) if (Global.Platform.isWindows) { - await extractWindows(archivePath, binRgPath) + await extractWindows(archivePath, rgBinPath) } else { - await extractPosix(archivePath, binRgPath) + await extractPosix(archivePath, rgBinPath) } await fs.unlink(archivePath) - return binRgPath + return rgBinPath } diff --git a/packages/opencode/src/file/ripgrep/index.ts b/packages/opencode/src/file/ripgrep/index.ts index a5c1d8b06fc1..afafd2b5b553 100644 --- a/packages/opencode/src/file/ripgrep/index.ts +++ b/packages/opencode/src/file/ripgrep/index.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import { $ } from "bun" import { Log } from "@/util/log" -import { rg } from "./binary" +import { rgBin } from "./binary" import { lazy } from "../../util/lazy" import { Result as _Result, @@ -34,7 +34,7 @@ export namespace Ripgrep { maxDepth?: number } - export const filepath = lazy(rg) + export const rg = lazy(rgBin) // Re-export from schema.ts export const Result = _Result @@ -62,7 +62,7 @@ export namespace Ripgrep { if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) for (const g of input.glob ?? []) args.push(`--glob=${g}`) - const proc = Bun.spawn([await filepath(), ...args], { + const proc = Bun.spawn([await rg(), ...args], { cwd: input.cwd, stdout: "pipe", stderr: "ignore", @@ -76,20 +76,18 @@ export namespace Ripgrep { export async function tree(input: { cwd: string; limit?: number }) { log.info("tree", input) - const fileList = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd })) - const root = buildTree(fileList) + const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd })) + const root = buildTree(files) sortTreeInPlace(root) const truncated = truncateBFS(root, input.limit ?? DEFAULT_TREE_LIMIT) return renderTree(truncated) } export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) { - const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] + const args = [`${await rg()}`, "--json", "--hidden", "--glob='!.git/*'"] - if (input.glob) { - for (const g of input.glob) { - args.push(`--glob=${g}`) - } + for (const g of input.glob ?? []) { + args.push(`--glob=${g}`) } if (input.limit) { diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 4cbc5347f57d..8dd39a96d97d 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -32,7 +32,7 @@ export const GrepTool = Tool.define("grep", { const searchPath = params.path || Instance.directory - const rgPath = await Ripgrep.filepath() + const rgPath = await Ripgrep.rg() const args = ["-nH", "--field-match-separator=|", "--regexp", params.pattern] if (params.include) { args.push("--glob", params.include) From aca280d66174ebff46348d505abbaa1a1f217065 Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 9 Jan 2026 11:14:15 +0200 Subject: [PATCH 07/13] refactor(ripgrep): inline schemas into index.ts --- packages/opencode/src/file/ripgrep/index.ts | 102 ++++++++++++++----- packages/opencode/src/file/ripgrep/schema.ts | 78 -------------- 2 files changed, 79 insertions(+), 101 deletions(-) delete mode 100644 packages/opencode/src/file/ripgrep/schema.ts diff --git a/packages/opencode/src/file/ripgrep/index.ts b/packages/opencode/src/file/ripgrep/index.ts index afafd2b5b553..3c6d5c5f2245 100644 --- a/packages/opencode/src/file/ripgrep/index.ts +++ b/packages/opencode/src/file/ripgrep/index.ts @@ -1,23 +1,10 @@ import fs from "fs/promises" import { $ } from "bun" +import z from "zod" import { Log } from "@/util/log" import { rgBin } from "./binary" import { lazy } from "../../util/lazy" -import { - Result as _Result, - Match as _Match, - Begin as _Begin, - End as _End, - Summary as _Summary, -} from "./schema" -import type { - Result as _ResultType, - Match as _MatchType, - Begin as _BeginType, - End as _EndType, - Summary as _SummaryType, -} from "./schema" import { DEFAULT_TREE_LIMIT, buildTree, sortTreeInPlace, truncateBFS, renderTree } from "./tree" import { streamLines } from "./io" @@ -34,16 +21,85 @@ export namespace Ripgrep { maxDepth?: number } - export const rg = lazy(rgBin) + // Schemas for parsing ripgrep JSON output + const Stats = z.object({ + elapsed: z.object({ + secs: z.number(), + nanos: z.number(), + human: z.string(), + }), + searches: z.number(), + searches_with_match: z.number(), + bytes_searched: z.number(), + bytes_printed: z.number(), + matched_lines: z.number(), + matches: z.number(), + }) + + const Begin = z.object({ + type: z.literal("begin"), + data: z.object({ + path: z.object({ + text: z.string(), + }), + }), + }) + + export const Match = z.object({ + type: z.literal("match"), + data: z.object({ + path: z.object({ + text: z.string(), + }), + lines: z.object({ + text: z.string(), + }), + line_number: z.number(), + absolute_offset: z.number(), + submatches: z.array( + z.object({ + match: z.object({ + text: z.string(), + }), + start: z.number(), + end: z.number(), + }), + ), + }), + }) + + const End = z.object({ + type: z.literal("end"), + data: z.object({ + path: z.object({ + text: z.string(), + }), + binary_offset: z.number().nullable(), + stats: Stats, + }), + }) + + const Summary = z.object({ + type: z.literal("summary"), + data: z.object({ + elapsed_total: z.object({ + human: z.string(), + nanos: z.number(), + secs: z.number(), + }), + stats: Stats, + }), + }) + + export const Result = z.union([Begin, Match, End, Summary]) + + export type Result = z.infer + export type Match = z.infer + export type Begin = z.infer + export type End = z.infer + export type Summary = z.infer - // Re-export from schema.ts - export const Result = _Result - export const Match = _Match - export type Result = _ResultType - export type Match = _MatchType - export type Begin = _BeginType - export type End = _EndType - export type Summary = _SummaryType + export const rg = lazy(rgBin) export async function* files(input: FilesInput) { // Bun.spawn should throw this, but it incorrectly reports that the executable does not exist. diff --git a/packages/opencode/src/file/ripgrep/schema.ts b/packages/opencode/src/file/ripgrep/schema.ts deleted file mode 100644 index 4cefec328648..000000000000 --- a/packages/opencode/src/file/ripgrep/schema.ts +++ /dev/null @@ -1,78 +0,0 @@ -import z from "zod" - -export const Stats = z.object({ - elapsed: z.object({ - secs: z.number(), - nanos: z.number(), - human: z.string(), - }), - searches: z.number(), - searches_with_match: z.number(), - bytes_searched: z.number(), - bytes_printed: z.number(), - matched_lines: z.number(), - matches: z.number(), -}) - -export const Begin = z.object({ - type: z.literal("begin"), - data: z.object({ - path: z.object({ - text: z.string(), - }), - }), -}) - -export const Match = z.object({ - type: z.literal("match"), - data: z.object({ - path: z.object({ - text: z.string(), - }), - lines: z.object({ - text: z.string(), - }), - line_number: z.number(), - absolute_offset: z.number(), - submatches: z.array( - z.object({ - match: z.object({ - text: z.string(), - }), - start: z.number(), - end: z.number(), - }), - ), - }), -}) - -export const End = z.object({ - type: z.literal("end"), - data: z.object({ - path: z.object({ - text: z.string(), - }), - binary_offset: z.number().nullable(), - stats: Stats, - }), -}) - -export const Summary = z.object({ - type: z.literal("summary"), - data: z.object({ - elapsed_total: z.object({ - human: z.string(), - nanos: z.number(), - secs: z.number(), - }), - stats: Stats, - }), -}) - -export const Result = z.union([Begin, Match, End, Summary]) - -export type Result = z.infer -export type Match = z.infer -export type Begin = z.infer -export type End = z.infer -export type Summary = z.infer From 1e19fbee0ef3f0f181cf3e3f10e049a2fe1579ca Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 9 Jan 2026 11:18:51 +0200 Subject: [PATCH 08/13] refactor(ripgrep): reorder imports --- packages/opencode/src/file/ripgrep/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/file/ripgrep/index.ts b/packages/opencode/src/file/ripgrep/index.ts index 3c6d5c5f2245..4650868c65ed 100644 --- a/packages/opencode/src/file/ripgrep/index.ts +++ b/packages/opencode/src/file/ripgrep/index.ts @@ -1,12 +1,13 @@ import fs from "fs/promises" import { $ } from "bun" import z from "zod" + +import { lazy } from "@/util/lazy.ts" import { Log } from "@/util/log" import { rgBin } from "./binary" -import { lazy } from "../../util/lazy" -import { DEFAULT_TREE_LIMIT, buildTree, sortTreeInPlace, truncateBFS, renderTree } from "./tree" import { streamLines } from "./io" +import { DEFAULT_TREE_LIMIT, buildTree, sortTreeInPlace, truncateBFS, renderTree } from "./tree" export namespace Ripgrep { const log = Log.create({ service: "ripgrep" }) From 935ac0c357de429330e902e7023021f17b36a053 Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 9 Jan 2026 11:23:14 +0200 Subject: [PATCH 09/13] test(ripgrep): add integration tests for files, tree, and search --- packages/opencode/test/file/ripgrep.test.ts | 185 ++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 packages/opencode/test/file/ripgrep.test.ts diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts new file mode 100644 index 000000000000..7fa13c5f0891 --- /dev/null +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -0,0 +1,185 @@ +import { test, expect, describe, beforeAll, afterAll } from "bun:test" +import { Ripgrep } from "../../src/file/ripgrep" +import fs from "fs/promises" +import path from "path" +import os from "os" + +describe("Ripgrep", () => { + let testDir: string + + beforeAll(async () => { + // Create a temporary test directory with known structure + testDir = await fs.mkdtemp(path.join(os.tmpdir(), "ripgrep-test-")) + + // Create test file structure: + // testDir/ + // src/ + // index.ts + // utils.ts + // test/ + // index.test.ts + // README.md + // package.json + + await fs.mkdir(path.join(testDir, "src")) + await fs.mkdir(path.join(testDir, "test")) + + await fs.writeFile(path.join(testDir, "src", "index.ts"), 'export const hello = "world"\n') + await fs.writeFile(path.join(testDir, "src", "utils.ts"), 'export function add(a: number, b: number) { return a + b }\n') + await fs.writeFile(path.join(testDir, "test", "index.test.ts"), 'import { hello } from "../src/index"\n') + await fs.writeFile(path.join(testDir, "README.md"), "# Test Project\n") + await fs.writeFile(path.join(testDir, "package.json"), '{"name": "test"}\n') + }) + + afterAll(async () => { + // Clean up test directory + await fs.rm(testDir, { recursive: true, force: true }) + }) + + describe("filepath", () => { + test("returns path to rg binary", async () => { + const rgPath = await Ripgrep.filepath() + expect(rgPath).toBeString() + expect(rgPath.length).toBeGreaterThan(0) + // Should end with 'rg' or 'rg.exe' + expect(rgPath.endsWith("rg") || rgPath.endsWith("rg.exe")).toBe(true) + }) + + test("binary exists at returned path", async () => { + const rgPath = await Ripgrep.filepath() + const exists = await fs.stat(rgPath).then(() => true).catch(() => false) + expect(exists).toBe(true) + }) + }) + + describe("files", () => { + test("lists all files in directory", async () => { + const files = await Array.fromAsync(Ripgrep.files({ cwd: testDir })) + + expect(files).toContain("src/index.ts") + expect(files).toContain("src/utils.ts") + expect(files).toContain("test/index.test.ts") + expect(files).toContain("README.md") + expect(files).toContain("package.json") + }) + + test("filters files by glob pattern", async () => { + const tsFiles = await Array.fromAsync(Ripgrep.files({ cwd: testDir, glob: ["*.ts"] })) + + expect(tsFiles).toContain("src/index.ts") + expect(tsFiles).toContain("src/utils.ts") + expect(tsFiles).toContain("test/index.test.ts") + expect(tsFiles).not.toContain("README.md") + expect(tsFiles).not.toContain("package.json") + }) + + test("respects maxDepth option", async () => { + const rootFiles = await Array.fromAsync(Ripgrep.files({ cwd: testDir, maxDepth: 1 })) + + expect(rootFiles).toContain("README.md") + expect(rootFiles).toContain("package.json") + expect(rootFiles).not.toContain("src/index.ts") + expect(rootFiles).not.toContain("test/index.test.ts") + }) + + test("throws ENOENT for non-existent directory", async () => { + const nonExistent = path.join(testDir, "does-not-exist") + + await expect(Array.fromAsync(Ripgrep.files({ cwd: nonExistent }))).rejects.toMatchObject({ + code: "ENOENT", + }) + }) + }) + + describe("tree", () => { + test("returns tree structure of files", async () => { + const tree = await Ripgrep.tree({ cwd: testDir }) + + expect(tree).toBeString() + expect(tree).toContain("src/") + expect(tree).toContain("test/") + }) + + test("respects limit option", async () => { + const tree = await Ripgrep.tree({ cwd: testDir, limit: 3 }) + + // With limit 3, should have truncation markers + const lines = tree.split("\n").filter(Boolean) + expect(lines.length).toBeLessThanOrEqual(10) // Some reasonable upper bound + }) + + test("sorts directories before files", async () => { + const tree = await Ripgrep.tree({ cwd: testDir, limit: 100 }) + const lines = tree.split("\n").filter(Boolean) + + // Find indices of directories and files at root level + const srcIndex = lines.findIndex((l) => l === "src/") + const testIndex = lines.findIndex((l) => l === "test/") + const readmeIndex = lines.findIndex((l) => l === "README.md") + const packageIndex = lines.findIndex((l) => l === "package.json") + + // Directories should come before files + if (srcIndex !== -1 && readmeIndex !== -1) { + expect(srcIndex).toBeLessThan(readmeIndex) + } + if (testIndex !== -1 && packageIndex !== -1) { + expect(testIndex).toBeLessThan(packageIndex) + } + }) + }) + + describe("search", () => { + test("finds matches for pattern", async () => { + const results = await Ripgrep.search({ cwd: testDir, pattern: "hello" }) + + expect(results.length).toBeGreaterThan(0) + expect(results.some((r) => r.path.text.includes("index.ts"))).toBe(true) + }) + + test("returns empty array for no matches", async () => { + const results = await Ripgrep.search({ cwd: testDir, pattern: "nonexistentpattern12345" }) + + expect(results).toEqual([]) + }) + + test("filters by glob pattern", async () => { + // Exclude .ts files - should not find "hello" in any remaining files + const results = await Ripgrep.search({ cwd: testDir, pattern: "hello", glob: ["!**/*.ts"] }) + + // "hello" is only in .ts files, so excluding them should return empty + expect(results.every((r) => !r.path.text.endsWith(".ts"))).toBe(true) + }) + + test("respects limit option", async () => { + // Create a file with multiple matches + await fs.writeFile( + path.join(testDir, "many-matches.txt"), + "hello\nhello\nhello\nhello\nhello\n" + ) + + // limit is max-count per file, so we should get at most 2 from this file + const results = await Ripgrep.search({ cwd: testDir, pattern: "hello", glob: ["many-matches.txt"], limit: 2 }) + + expect(results.length).toBeLessThanOrEqual(2) + + // Clean up + await fs.unlink(path.join(testDir, "many-matches.txt")) + }) + + test("returns correct match structure", async () => { + const results = await Ripgrep.search({ cwd: testDir, pattern: "hello" }) + + expect(results.length).toBeGreaterThan(0) + const match = results[0] + + expect(match).toHaveProperty("path") + expect(match).toHaveProperty("lines") + expect(match).toHaveProperty("line_number") + expect(match).toHaveProperty("submatches") + expect(match.path).toHaveProperty("text") + expect(match.lines).toHaveProperty("text") + expect(typeof match.line_number).toBe("number") + expect(Array.isArray(match.submatches)).toBe(true) + }) + }) +}) From 1b3475883414b1db616d1ee017f48ce2af79d78f Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 9 Jan 2026 11:29:44 +0200 Subject: [PATCH 10/13] test(ripgrep): update tests for renamed rg function --- packages/opencode/test/file/ripgrep.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index 7fa13c5f0891..c2000b01c69c 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -36,9 +36,9 @@ describe("Ripgrep", () => { await fs.rm(testDir, { recursive: true, force: true }) }) - describe("filepath", () => { + describe("rg", () => { test("returns path to rg binary", async () => { - const rgPath = await Ripgrep.filepath() + const rgPath = await Ripgrep.rg() expect(rgPath).toBeString() expect(rgPath.length).toBeGreaterThan(0) // Should end with 'rg' or 'rg.exe' @@ -46,7 +46,7 @@ describe("Ripgrep", () => { }) test("binary exists at returned path", async () => { - const rgPath = await Ripgrep.filepath() + const rgPath = await Ripgrep.rg() const exists = await fs.stat(rgPath).then(() => true).catch(() => false) expect(exists).toBe(true) }) From 79dc9e2042af39d6e3136e1b8124b43b088c6f9f Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 9 Jan 2026 11:38:36 +0200 Subject: [PATCH 11/13] refactor(ripgrep): organize imports --- packages/opencode/src/file/ripgrep/binary.ts | 7 +++++-- packages/opencode/src/file/ripgrep/index.ts | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/file/ripgrep/binary.ts b/packages/opencode/src/file/ripgrep/binary.ts index 7d90c590ac99..b98b80fbed0e 100644 --- a/packages/opencode/src/file/ripgrep/binary.ts +++ b/packages/opencode/src/file/ripgrep/binary.ts @@ -1,10 +1,13 @@ import path from "path" import fs from "fs/promises" + import z from "zod" -import { Global } from "../../global" -import { NamedError } from "@opencode-ai/util/error" import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js" +import { NamedError } from "@opencode-ai/util/error" + +import { Global } from "@/global" + const RG_VERSION = "14.1.1" const PLATFORMS = { diff --git a/packages/opencode/src/file/ripgrep/index.ts b/packages/opencode/src/file/ripgrep/index.ts index 4650868c65ed..114500ff5eb0 100644 --- a/packages/opencode/src/file/ripgrep/index.ts +++ b/packages/opencode/src/file/ripgrep/index.ts @@ -1,4 +1,5 @@ import fs from "fs/promises" + import { $ } from "bun" import z from "zod" From c83cd5aea875eaf729d6842ebb840ea3b17cae3b Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 9 Jan 2026 11:40:11 +0200 Subject: [PATCH 12/13] test(ripgrep): add file verification before search tests --- packages/opencode/test/file/ripgrep.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index c2000b01c69c..782e2e10320c 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -130,6 +130,10 @@ describe("Ripgrep", () => { describe("search", () => { test("finds matches for pattern", async () => { + // Verify test file exists and has expected content + const content = await fs.readFile(path.join(testDir, "src", "index.ts"), "utf-8") + expect(content).toContain("hello") + const results = await Ripgrep.search({ cwd: testDir, pattern: "hello" }) expect(results.length).toBeGreaterThan(0) @@ -167,6 +171,10 @@ describe("Ripgrep", () => { }) test("returns correct match structure", async () => { + // Verify test file exists + const content = await fs.readFile(path.join(testDir, "src", "index.ts"), "utf-8") + expect(content).toContain("hello") + const results = await Ripgrep.search({ cwd: testDir, pattern: "hello" }) expect(results.length).toBeGreaterThan(0) From 5f467a63615199d1712e0689cbda28bf0d63d813 Mon Sep 17 00:00:00 2001 From: Vlad Temian Date: Fri, 9 Jan 2026 11:45:56 +0200 Subject: [PATCH 13/13] fix(ripgrep): use Bun.spawn instead of shell for search Replace shell command execution with Bun.spawn to avoid cross-platform shell quoting issues. The previous approach used embedded single quotes in glob patterns which behaved differently on macOS vs Ubuntu. --- packages/opencode/src/file/ripgrep/index.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/file/ripgrep/index.ts b/packages/opencode/src/file/ripgrep/index.ts index 114500ff5eb0..15db48a8ba5f 100644 --- a/packages/opencode/src/file/ripgrep/index.ts +++ b/packages/opencode/src/file/ripgrep/index.ts @@ -1,6 +1,5 @@ import fs from "fs/promises" -import { $ } from "bun" import z from "zod" import { lazy } from "@/util/lazy.ts" @@ -142,7 +141,7 @@ export namespace Ripgrep { } export async function search(input: { cwd: string; pattern: string; glob?: string[]; limit?: number }) { - const args = [`${await rg()}`, "--json", "--hidden", "--glob='!.git/*'"] + const args = ["--json", "--hidden", "--glob=!.git/*"] for (const g of input.glob ?? []) { args.push(`--glob=${g}`) @@ -155,14 +154,22 @@ export namespace Ripgrep { args.push("--") args.push(input.pattern) - const command = args.join(" ") - const searchResult = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow() - if (searchResult.exitCode !== 0) { + const proc = Bun.spawn([await rg(), ...args], { + cwd: input.cwd, + stdout: "pipe", + stderr: "ignore", + maxBuffer: MAX_BUFFER_BYTES, + }) + + const output = await new Response(proc.stdout).text() + await proc.exited + + if (proc.exitCode !== 0) { return [] } // Handle both Unix (\n) and Windows (\r\n) line endings - const lines = searchResult.text().trim().split(/\r?\n/).filter(Boolean) + const lines = output.trim().split(/\r?\n/).filter(Boolean) return lines .map((line) => JSON.parse(line))