| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- // 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 {
- const log = Log.create({ service: "ripgrep" })
- 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,
- }),
- })
- 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>
- 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" },
- } as const
- 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(),
- }),
- )
- 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
- }
- }
- 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(filepath, await rgBlob.arrayBuffer())
- await zipFileReader.close()
- }
- }
- await fs.unlink(archivePath)
- if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
- }
- return {
- filepath,
- }
- })
- export async function filepath() {
- const { filepath } = await state()
- return filepath
- }
- export async function* files(input: { cwd: string; glob?: string[] }) {
- const args = [await filepath(), "--files", "--follow", "--hidden", "--glob=!.git/*"]
- if (input.glob) {
- for (const g of input.glob) {
- args.push(`--glob=${g}`)
- }
- }
- // 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(args, {
- cwd: input.cwd,
- stdout: "pipe",
- stderr: "ignore",
- maxBuffer: 1024 * 1024 * 20,
- })
- const reader = proc.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("\n")
- buffer = lines.pop() || ""
- for (const line of lines) {
- if (line) yield line
- }
- }
- if (buffer) yield buffer
- } finally {
- reader.releaseLock()
- 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 }))
- 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 files) {
- 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 result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
- if (result.exitCode !== 0) {
- return []
- }
- const lines = result.text().trim().split("\n").filter(Boolean)
- // Parse JSON lines from ripgrep output
- return lines
- .map((line) => JSON.parse(line))
- .map((parsed) => Result.parse(parsed))
- .filter((r) => r.type === "match")
- .map((r) => r.data)
- }
- }
|