// Copyright (C) 2026 Rob Colbert // Licensed under the Apache License, Version 2.0 import fs from "node:fs/promises"; import path from "node:path"; import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; import { DroneTool } from "../tool.ts"; import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts"; const MAX_MATCHES = 500; const MAX_FILE_SIZE = 1024 * 1024; // TODO: Consider broadening this list or making it user-configurable // Currently limited to common source/doc extensions for performance. const SEARCH_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt"]); export class GrepTool extends DroneTool { get name(): string { return "grep"; } get category(): string { return "system"; } public definition: IToolDefinition = { type: "function", function: { name: this.name, description: "Search project file contents using regular expressions. Returns matching lines with file paths and line numbers. Supports case-insensitive matching and context lines.", parameters: { type: "object", properties: { pattern: { type: "string", description: "Regular expression pattern to search for. Use standard JavaScript regex syntax.", }, path: { type: "string", description: "File or directory path to search in, relative to the project root.", }, caseInsensitive: { type: "boolean", description: "Case insensitive search (default: false).", }, contextBefore: { type: "number", description: "Number of lines to show before each match (default: 0).", }, contextAfter: { type: "number", description: "Number of lines to show after each match (default: 0).", }, }, required: ["pattern", "path"], }, }, }; public async execute(args: IToolArguments, logger: IAiLogger): Promise { const pattern = typeof args.pattern === "string" ? args.pattern : undefined; const searchPath = typeof args.path === "string" ? args.path : undefined; const caseInsensitive = args.caseInsensitive === true; const contextBefore = typeof args.contextBefore === "number" ? args.contextBefore : 0; const contextAfter = typeof args.contextAfter === "number" ? args.contextAfter : 0; if (!pattern || pattern.trim().length === 0) { return toolError({ code: "MISSING_PARAMETER", message: "pattern is required.", parameter: "pattern", recoveryHint: "Provide a regex pattern to search for.", }); } if (!searchPath || searchPath.trim().length === 0) { return toolError({ code: "MISSING_PARAMETER", message: "path is required.", parameter: "path", recoveryHint: "Provide a file or directory path to search in.", }); } const resolved = resolveProjectPath(this.toolbox, searchPath); if (typeof resolved === "string") return resolved; let regex: RegExp; try { regex = new RegExp(pattern, caseInsensitive ? "i" : ""); } catch (error) { return toolError({ code: "INVALID_PARAMETER", message: `Invalid regex pattern: ${error instanceof Error ? error.message : String(error)}`, parameter: "pattern", }); } const projectRoot = getProjectRoot(this.toolbox); try { const stat = await fs.stat(resolved.absolutePath); const matches: Array<{ file: string; line: number; content: string }> = []; if (stat.isFile()) { const fileMatches = await this.searchFile(resolved.absolutePath, regex, contextBefore, contextAfter); matches.push(...fileMatches); } else if (stat.isDirectory()) { await this.searchDirectory(resolved.absolutePath, regex, matches, contextBefore, contextAfter); } const limited = matches.slice(0, MAX_MATCHES); if (matches.length === 0) { return `No matches found for "${pattern}" in ${resolved.displayPath}`; } const truncated = matches.length > MAX_MATCHES; const lines: string[] = [ `Found ${matches.length} match(es) for "${pattern}" in ${resolved.displayPath}${truncated ? ` (showing first ${MAX_MATCHES})` : ""}:`, "", ]; for (const match of limited) { const displayFile = projectRoot ? path.relative(projectRoot, match.file) : match.file; lines.push(`${displayFile}:${match.line}: ${match.content}`); } if (truncated) { lines.push("", `... and ${matches.length - MAX_MATCHES} more matches`); } return lines.join("\n"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("failed to search", { path: resolved.displayPath, pattern, error: errorMessage }); return toolError({ code: "OPERATION_FAILED", message: `Failed to search: ${errorMessage}`, }); } } private async searchFile( filePath: string, regex: RegExp, contextBefore: number, contextAfter: number, ): Promise> { const matches: Array<{ file: string; line: number; content: string }> = []; try { const stat = await fs.stat(filePath); if (stat.size > MAX_FILE_SIZE) return matches; } catch { return matches; } let content: string; try { content = await fs.readFile(filePath, "utf-8"); } catch { return matches; } const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line !== undefined && regex.test(line)) { const startLine = Math.max(0, i - contextBefore); const endLine = Math.min(lines.length - 1, i + contextAfter); for (let j = startLine; j <= endLine; j++) { const contextLine = lines[j]; if (contextLine !== undefined) { matches.push({ file: filePath, line: j + 1, content: contextLine }); } } regex.lastIndex = 0; } } return matches; } private async searchDirectory( dir: string, regex: RegExp, matches: Array<{ file: string; line: number; content: string }>, contextBefore: number, contextAfter: number, ): Promise { let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name !== "node_modules" && entry.name !== ".git" && entry.name !== "dist" && entry.name !== "build") { await this.searchDirectory(fullPath, regex, matches, contextBefore, contextAfter); } } else if (entry.isFile()) { const ext = path.extname(entry.name); if (SEARCH_EXTENSIONS.has(ext)) { const fileMatches = await this.searchFile(fullPath, regex, contextBefore, contextAfter); matches.push(...fileMatches); } } if (matches.length >= MAX_MATCHES * 2) return; } } }