diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts deleted file mode 100644 index 834cbee1ed1..00000000000 --- a/packages/opencode/src/file/ripgrep.ts +++ /dev/null @@ -1,409 +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 { - 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 - export type Match = z.infer - 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" }, - } 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[] - 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}`) - } - } - - // 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 }) - // 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 - } - } - - 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 - follow?: boolean - }) { - const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"] - if (input.follow !== false) args.push("--follow") - - 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 [] - } - - // 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 - - 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 00000000000..b98b80fbed0 --- /dev/null +++ b/packages/opencode/src/file/ripgrep/binary.ts @@ -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 { + 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 rgBin(): Promise { + 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 +} diff --git a/packages/opencode/src/file/ripgrep/index.ts b/packages/opencode/src/file/ripgrep/index.ts new file mode 100644 index 00000000000..885eb313f14 --- /dev/null +++ b/packages/opencode/src/file/ripgrep/index.ts @@ -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 + export type Match = z.infer + export type Begin = z.infer + export type End = z.infer + export type Summary = z.infer + + 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) + } +} diff --git a/packages/opencode/src/file/ripgrep/io.ts b/packages/opencode/src/file/ripgrep/io.ts new file mode 100644 index 00000000000..ab8984e5999 --- /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() + } +} diff --git a/packages/opencode/src/file/ripgrep/tree.ts b/packages/opencode/src/file/ripgrep/tree.ts new file mode 100644 index 00000000000..f3fb2a7bb2e --- /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") +} diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 253b9663db4..39f4a0c7b7f 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() { diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index ad62621e072..d46302f8aaf 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -36,7 +36,7 @@ export const GrepTool = Tool.define("grep", { searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath) await assertExternalDirectory(ctx, searchPath, { kind: "directory" }) - const rgPath = await Ripgrep.filepath() + const rgPath = await Ripgrep.rg() const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern] if (params.include) { args.push("--glob", params.include) diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts new file mode 100644 index 00000000000..782e2e10320 --- /dev/null +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -0,0 +1,193 @@ +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("rg", () => { + test("returns path to rg binary", async () => { + const rgPath = await Ripgrep.rg() + 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.rg() + 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 () => { + // 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) + 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 () => { + // 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) + 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) + }) + }) +})