import z from "zod" import { Effect } from "effect" import * as Stream from "effect/Stream" import { Tool } from "./tool" import { Filesystem } from "../util/filesystem" import { Ripgrep } from "../file/ripgrep" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import DESCRIPTION from "./grep.txt" import { Instance } from "../project/instance" import path from "path" import { assertExternalDirectoryEffect } from "./external-directory" const MAX_LINE_LENGTH = 2000 export const GrepTool = Tool.defineEffect( "grep", Effect.gen(function* () { const spawner = yield* ChildProcessSpawner return { 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}")'), }), execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) => Effect.gen(function* () { if (!params.pattern) { throw new Error("pattern is required") } yield* Effect.promise(() => 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) yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) const rgPath = yield* Effect.promise(() => 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 result = yield* Effect.scoped( Effect.gen(function* () { const handle = yield* spawner.spawn( ChildProcess.make(rgPath, args, { stdin: "ignore", }), ) const [output, errorOutput] = yield* Effect.all( [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], { concurrency: 2 }, ) const exitCode = yield* handle.exitCode return { output, errorOutput, exitCode } }), ) const { output, errorOutput, exitCode } = result // 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 stats = Filesystem.stat(filePath) 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"), } }).pipe(Effect.orDie, Effect.runPromise), } }), )