import { readFile, stat } from "node:fs/promises"; import { relative, sep } from "node:path"; import picomatch from "picomatch"; import type { Tool } from "@magiccode/core"; import { displayPath, optBool, optNum, optStr, resolvePath, str } from "./helpers"; import { walkFiles } from "./walk"; const MAX_FILE_BYTES = 2 * 1024 * 1024; const MAX_LINE_CHARS = 500; const DEFAULT_HEAD_LIMIT = 250; type Mode = "files_with_matches" | "content" | "count"; const MODES: Mode[] = ["files_with_matches", "content", "count"]; /** Retourne le texte, ou null pour un fichier trop gros, binaire ou illisible. */ async function readTextFile(abs: string): Promise { try { if ((await stat(abs)).size > MAX_FILE_BYTES) return null; const buf = await readFile(abs); if (buf.subarray(0, 8000).includes(0)) return null; return buf.toString("utf8"); } catch { return null; } } function splitLines(text: string): string[] { const lines = text.split("\n"); if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); return lines.map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l)); } const clip = (l: string): string => (l.length > MAX_LINE_CHARS ? `${l.slice(0, MAX_LINE_CHARS)}…` : l); export const grepTool: Tool = { spec: { name: "Grep", description: "Recherche une expression régulière (syntaxe JavaScript) dans le contenu des fichiers. " + "Ignore .git, node_modules, les fichiers exclus par .gitignore et les fichiers binaires. " + 'output_mode : "files_with_matches" (défaut, liste des fichiers), "content" (lignes correspondantes) ou "count".', inputSchema: { type: "object", properties: { pattern: { type: "string", description: "Expression régulière à rechercher" }, path: { type: "string", description: "Fichier ou dossier : chemin complet ou relatif au dossier de lancement (défaut : ce dossier)" }, glob: { type: "string", description: 'Filtre sur les fichiers, ex. "*.ts" ou "src/**/*.tsx"' }, output_mode: { type: "string", enum: MODES }, "-i": { type: "boolean", description: "Ignorer la casse" }, "-n": { type: "boolean", description: "Numéros de ligne (mode content, vrai par défaut)" }, "-A": { type: "number", description: "Lignes de contexte après chaque résultat (mode content)" }, "-B": { type: "number", description: "Lignes de contexte avant chaque résultat (mode content)" }, "-C": { type: "number", description: "Lignes de contexte avant et après (mode content)" }, head_limit: { type: "number", description: `Nombre maximum de lignes/entrées renvoyées (défaut ${DEFAULT_HEAD_LIMIT}, 0 = sans limite)`, }, }, required: ["pattern"], }, }, permission: "auto", describe: (i) => `${String(i.pattern ?? "")}${i.path ? ` dans ${String(i.path)}` : ""}`, async execute(input, ctx) { const pattern = str(input, "pattern"); const mode = (optStr(input, "output_mode") ?? "files_with_matches") as Mode; if (!MODES.includes(mode)) { throw new Error(`output_mode invalide : "${mode}" (attendu : ${MODES.join(", ")})`); } let regex: RegExp; try { regex = new RegExp(pattern, optBool(input, "-i") ? "i" : ""); } catch (err) { throw new Error(`Expression régulière invalide : ${(err as Error).message}`); } const showLineNumbers = optBool(input, "-n") ?? true; const both = optNum(input, "-C"); const after = Math.max(optNum(input, "-A") ?? both ?? 0, 0); const before = Math.max(optNum(input, "-B") ?? both ?? 0, 0); const headLimit = optNum(input, "head_limit"); const limit = headLimit === undefined ? DEFAULT_HEAD_LIMIT : headLimit <= 0 ? Infinity : headLimit; const target = resolvePath(ctx.cwd, optStr(input, "path") ?? "."); let targetIsFile: boolean; try { targetIsFile = (await stat(target)).isFile(); } catch { throw new Error(`Chemin introuvable : ${target}`); } const globFilter = optStr(input, "glob"); const matchGlob = globFilter ? picomatch(globFilter, { dot: true, basename: true }) : null; async function* candidates(): AsyncGenerator { if (targetIsFile) { yield target; return; } for await (const abs of walkFiles(target, ctx.signal)) { if (matchGlob && !matchGlob(relative(target, abs).split(sep).join("/"))) continue; yield abs; } } const out: string[] = []; let truncated = false; for await (const abs of candidates()) { if (ctx.signal?.aborted) break; const text = await readTextFile(abs); if (text === null) continue; const lines = splitLines(text); const hits: number[] = []; for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i] ?? "")) { hits.push(i); if (mode === "files_with_matches") break; } } if (hits.length === 0) continue; const name = displayPath(ctx.cwd, abs); if (mode === "files_with_matches") { out.push(name); } else if (mode === "count") { out.push(`${name}:${hits.length}`); } else { // index de ligne → true si c'est une ligne correspondante (sinon : ligne de contexte) const show = new Map(); for (const m of hits) { const from = Math.max(0, m - before); const to = Math.min(lines.length - 1, m + after); for (let j = from; j <= to; j++) show.set(j, (show.get(j) ?? false) || j === m); } let prev = -2; for (const j of [...show.keys()].sort((a, b) => a - b)) { if (prev >= 0 && j > prev + 1) out.push("--"); const sepChar = show.get(j) ? ":" : "-"; const num = showLineNumbers ? `${j + 1}${sepChar}` : ""; out.push(`${name}${sepChar}${num}${clip(lines[j] ?? "")}`); prev = j; } } if (out.length >= limit) { out.length = Math.min(out.length, limit); truncated = true; break; } } if (out.length === 0) return `Aucun résultat pour "${pattern}"`; return truncated ? `${out.join("\n")}\n(limite de ${limit} résultats atteinte : affinez la recherche ou augmentez head_limit)` : out.join("\n"); }, };