MagicCode/packages/ext-claudecode/src/tools/grep.ts

162 lines
6.2 KiB
TypeScript
Raw Normal View History

2026-10-11 00:11:20 +00:00
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<string | null> {
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<string> {
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<number, boolean>();
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");
},
};