| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150 |
- import z from "zod"
- import { Tool } from "./tool"
- import { Ripgrep } from "../file/ripgrep"
- import DESCRIPTION from "./grep.txt"
- import { Instance } from "../project/instance"
- import path from "path"
- import { assertExternalDirectory } from "./external-directory"
- const MAX_LINE_LENGTH = 2000
- export const GrepTool = Tool.define("grep", {
- description: DESCRIPTION,
- parameters: z.object({
- pattern: z.string().describe("The regex pattern to search for in file contents"),
- path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
- include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
- }),
- async execute(params, ctx) {
- if (!params.pattern) {
- throw new Error("pattern is required")
- }
- await ctx.ask({
- permission: "grep",
- patterns: [params.pattern],
- always: ["*"],
- metadata: {
- pattern: params.pattern,
- path: params.path,
- include: params.include,
- },
- })
- let searchPath = params.path ?? Instance.directory
- searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
- await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
- const rgPath = await Ripgrep.filepath()
- const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
- if (params.include) {
- args.push("--glob", params.include)
- }
- args.push(searchPath)
- const proc = Bun.spawn([rgPath, ...args], {
- stdout: "pipe",
- stderr: "pipe",
- signal: ctx.abort,
- })
- const output = await new Response(proc.stdout).text()
- const errorOutput = await new Response(proc.stderr).text()
- const exitCode = await proc.exited
- // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
- // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
- // Only fail if exit code is 2 AND no output was produced
- if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
- return {
- title: params.pattern,
- metadata: { matches: 0, truncated: false },
- output: "No files found",
- }
- }
- if (exitCode !== 0 && exitCode !== 2) {
- throw new Error(`ripgrep failed: ${errorOutput}`)
- }
- const hasErrors = exitCode === 2
- // Handle both Unix (\n) and Windows (\r\n) line endings
- const lines = output.trim().split(/\r?\n/)
- const matches = []
- for (const line of lines) {
- if (!line) continue
- const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
- if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
- const lineNum = parseInt(lineNumStr, 10)
- const lineText = lineTextParts.join("|")
- const file = Bun.file(filePath)
- const stats = await file.stat().catch(() => null)
- if (!stats) continue
- matches.push({
- path: filePath,
- modTime: stats.mtime.getTime(),
- lineNum,
- lineText,
- })
- }
- matches.sort((a, b) => b.modTime - a.modTime)
- const limit = 100
- const truncated = matches.length > limit
- const finalMatches = truncated ? matches.slice(0, limit) : matches
- if (finalMatches.length === 0) {
- return {
- title: params.pattern,
- metadata: { matches: 0, truncated: false },
- output: "No files found",
- }
- }
- const totalMatches = matches.length
- const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
- let currentFile = ""
- for (const match of finalMatches) {
- if (currentFile !== match.path) {
- if (currentFile !== "") {
- outputLines.push("")
- }
- currentFile = match.path
- outputLines.push(`${match.path}:`)
- }
- const truncatedLineText =
- match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
- outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
- }
- if (truncated) {
- outputLines.push("")
- outputLines.push(
- `(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
- )
- }
- if (hasErrors) {
- outputLines.push("")
- outputLines.push("(Some paths were inaccessible and skipped)")
- }
- return {
- title: params.pattern,
- metadata: {
- matches: totalMatches,
- truncated,
- },
- output: outputLines.join("\n"),
- }
- },
- })
|