162 lines
6.2 KiB
TypeScript
162 lines
6.2 KiB
TypeScript
|
|
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");
|
||
|
|
},
|
||
|
|
};
|