Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

KindCauseFix
non_convergent_replacementThe replacement still matches the pattern. 'Outcome' -> 'ReadOutcome'Add --word, or narrow the pattern by hand
non_convergent_contextThe replacement is clean; the rewrite pulls surrounding text into a new match. 'ab' -> 'a' over aabbWord 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.