Regex mode
Default mode. Powered by the regex crate, so
the syntax is Perl-compatible minus lookaround (catastrophic backtracking
is gone too — regex is linear-time).
Multi-line by default
. matches \n by default (implicit (?s)); --single-line (-s)
turns that off. This matches what LLMs usually expect.
Capture interpolation
recast 'fn (\w+)_old\b' 'fn ${1}_new' src/
$1, ${name} interpolated. To treat the pattern and replacement as
literal text, pass --literal (-L).
Whole words
--word (-w) matches only whole words:
recast --apply --word 'foo' 'X' src/ # rewrites `foo`, leaves `foobar` alone
It wraps the pattern as \b{start-half}(?:PATTERN)\b{end-half}, the same
semantics as rg --word-regexp. Those are half boundaries: each one
needs only a non-word character (or the edge of the file) on its own
side. Plain \b(?:PATTERN)\b is not the same thing — it needs a word
character on the outside too, which makes a pattern whose own edges are
punctuation impossible to match:
printf 'x -foo- y\n' > t.txt
recast --search --literal --word -- '-foo-' t.txt # 1 match
recast --search --at-least 0 '\b(?:-foo-)\b' t.txt # 0 matches
--word is applied after --literal escaping, so the two combine.
That combination is the usual fix for a rewrite rejected as
non_convergent_replacement:
recast --literal 'Outcome' 'ReadOutcome' src/ # non_convergent_replacement
recast --literal --word 'Outcome' 'ReadOutcome' src/ # fine — `ReadOutcome` no longer re-matches
--word is rejected with --lang; structural mode does not go through
the regex pipeline. It does apply to --search and --stdin.
Case-insensitive
recast -i 'todo' 'TODO' .
Match-count guard
--at-least N (default 1) and --at-most N (default unbounded) bracket
the total matches across all files. Violations exit 2.
recast --at-least 5 'foo' 'bar' src/ # require ≥5 matches
recast --at-most 0 'TODO' 'FIXME' src/ # CI gate: there must be zero TODOs
recast --at-least 0 'maybe' 'def' src/ # allow zero matches (no guard)
Idempotency check
The plan step reapplies the pattern to its own post-image. If any file
would change again, recast aborts — the rewrite isn’t safe to run twice.
Override with --allow-non-convergent if you know what you’re doing.
A rewrite can fail to converge for two different reasons, and they need different fixes, so they get different error kinds:
| Kind | Cause | Fix |
|---|---|---|
non_convergent_replacement | The replacement still matches the pattern. 'Outcome' -> 'ReadOutcome' | Add --word, or narrow the pattern by hand |
non_convergent_context | The replacement is clean; the rewrite pulls surrounding text into a new match. 'ab' -> 'a' over aabb | Word boundaries won’t help. The pattern overlaps itself; rewrite it |
A third kind, non_convergent_script, covers --script runs. The
script computes its replacement per match, so recast can’t say which of
the two causes applies.
Examples of patterns recast rejects:
'a' -> 'aa'(grows on every run)'foo' -> 'foofoo'
Examples it accepts:
'old' -> 'new''fn (\w+)_old' -> 'fn ${1}_new'
Filters
recast -t rust 'Old' 'New' . # only Rust files
recast -T markdown 'Old' 'New' . # everything except Markdown
recast -g '!vendor/**' 'Old' 'New' . # exclude vendor dir
recast --no-ignore 'Old' 'New' . # bypass .gitignore
recast --hidden 'Old' 'New' . # include dot-files
recast --max-bytes 102400 'Old' 'New' . # skip files > 100KiB
recast --max-files 50 'Old' 'New' . # cap total file count
-t / -T accept the same shorthand vocabulary as ripgrep
(rust, js, py, markdown, …). -g accepts ripgrep-style
include/exclude globs.
Stdin mode
echo 'fn old_name() {}' | recast --stdin 'old_name' 'new_name'
# fn new_name() {}
Read one buffer, rewrite once, write to stdout. Skips the walker and commit phases. The match-count guard still applies.