Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
409 changes: 0 additions & 409 deletions packages/opencode/src/file/ripgrep.ts

This file was deleted.

141 changes: 141 additions & 0 deletions packages/opencode/src/file/ripgrep/binary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import path from "path"
import fs from "fs/promises"

import z from "zod"
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 = {
"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<string | null> {
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<string> {
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<void> {
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<void> {
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 rgBin(): Promise<string> {
const existing = await findExisting()
if (existing) return existing

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, rgBinPath)
} else {
await extractPosix(archivePath, rgBinPath)
}

await fs.unlink(archivePath)

return rgBinPath
}
187 changes: 187 additions & 0 deletions packages/opencode/src/file/ripgrep/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import fs from "fs/promises"

import z from "zod"

import { lazy } from "@/util/lazy.ts"
import { Log } from "@/util/log"

import { rgBin } from "./binary"
import { streamLines } from "./io"
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
}

// 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<typeof Result>
export type Match = z.infer<typeof Match>
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>

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.
// 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 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 rg(), ...args], {
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 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
follow?: boolean
}) {
const args = ["--json", "--hidden", "--glob=!.git/*"]
if (input.follow !== false) args.push("--follow")

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 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 = output.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)
}
}
24 changes: 24 additions & 0 deletions packages/opencode/src/file/ripgrep/io.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export async function* streamLines(stdout: ReadableStream<Uint8Array>): AsyncGenerator<string> {
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()
}
}
Loading