/** * `replace`: bulk literal text replacement or * full JavaScript regex with capture-group substitution. * * Distinct from the anchor-verified `edit`. `replace` is **location-blind**: * it matches `find` everywhere across the whole file and substitutes every * occurrence. Use it for renames and pattern-based transforms that would * otherwise need many individual edits. Inserting, deleting, or rewriting whole * lines belongs to `edit`. * * - Both modes match the shared LF view and restore original line separators. * - Each rule states `literal`: literal mode keeps `$` verbatim; regex mode expands * JavaScript replacement tokens. * - `flags` adds regex flags in either mode; `g` is always forced so every * occurrence is replaced. `i` (case-insensitive), `m` (per-line ^/$), * `s` (dotall), `u` (unicode) all work. * - `replacements` batches rules against one original snapshot; conflicting * ranges or a failing rule reject the entire batch before publication. * * Concurrency: read-modify-write is wrapped in Pi's withFileMutationQueue * (shared with `edit`), so a `replace` and an `edit` on the same file never * interleave. Regex batches run in a cancellable worker with a time limit (replace-regex). * * @module pi-hashline-edit/pi */ import type { Theme } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "typebox"; import { applyReplacements } from "../core/replace.ts"; import { checkRegexArguments } from "../core/regex.ts"; import { REPLACEMENTS_SCHEMA } from "../core/replace-schema.ts"; import { runRegexReplacements } from "./replace-regex.ts"; import { unwritableTextError } from "../core/text.ts"; import type { HashlineEditConfig } from "./config.ts"; import { diffCardRenderers, formatDiffCounts, formatHeaderPath, type DiffCounts, } from "./render.ts"; import { mutationToolBase, runTextMutation, type ActionFusionExecutor, type MutationTarget, } from "./mutation-runner.ts"; import { defaultOutputBudgets, type OutputBudgetProvider } from "./budgets.ts"; import { argumentItems, type ReportArgumentIssue } from "./argument-validation.ts"; const replaceSchema = Type.Object( { path: Type.String({ minLength: 1, description: "Path to the file (relative or absolute)", }), replacements: REPLACEMENTS_SCHEMA, }, { additionalProperties: false }, ); type ReplaceParams = Static; /** Call-header line: `replace path — N rules`, plus `+N -N` once diff counts are known. */ function replaceHeader(args: ReplaceParams, theme: Theme, counts?: DiffCounts): string { let t = theme.fg("toolTitle", theme.bold("replace ")); t += formatHeaderPath(args.path, theme); const count = args.replacements?.length ?? 0; t += theme.fg("dim", ` — ${count} rule${count === 1 ? "" : "s"}`); if (counts && (counts.added || counts.removed)) t += formatDiffCounts(counts, theme); return t; } /** Report unwritable replacement text, flags V8 rejects, and regex compile errors; Pi checks field shapes. */ function checkReplaceArguments(args: unknown, report: ReportArgumentIssue): void { const rules = argumentItems((args as { replacements?: unknown } | null)?.replacements); rules.forEach((rule, index) => { const { find, replace, literal, flags } = (rule ?? {}) as Record; const item = `replacements[${index}]`; const unwritable = typeof replace === "string" ? unwritableTextError(replace) : undefined; if (unwritable) report(`${item}.replace`, unwritable.message); if (flags !== undefined && typeof flags !== "string") return; checkRegexArguments( `${item}.flags`, flags, literal !== false, [[`${item}.find`, find]], report, ); }); } export function makeReplaceTool( cwd: string, config: HashlineEditConfig, fusion?: ActionFusionExecutor, budgets: OutputBudgetProvider = defaultOutputBudgets, ) { return { ...mutationToolBase({ tool: "replace", cwd, fusion, budgets, schema: replaceSchema, description: "Replace every match of one or more literal or JavaScript-regex rules in a file. Rules swap only their matched fragments in one call, without line anchors. Use edit instead for inserting, deleting, or rewriting whole lines. Overlapping matches or a rule with no match reject the whole call.", promptSnippet: "Replace matching text across a file", check: checkReplaceArguments, run: (params, target) => runReplace(target, params.replacements, config), }), ...diffCardRenderers("replace", "Replacing…", replaceHeader), }; } function runReplace( target: MutationTarget, rules: ReplaceParams["replacements"], config: HashlineEditConfig, ) { return runTextMutation<"replace">(target, async (currentText) => { // The regex worker reports its own cancellation; a literal batch runs synchronously. const { text, count } = rules.some((rule) => !rule.literal) ? await runRegexReplacements(currentText, rules, config.replace.regexTimeoutMs, target.signal) : applyReplacements(currentText, rules); return { text, facts: { matches: count } }; }); }