recast
recast is a CLI for safe, atomic, transparent multi-file text rewrites.
Pure Rust. Tuned for LLM coding agents driving mechanical edits; equally
usable by humans for mechanical refactors.
Why it exists
LLM agents and shell scripts that rewrite code typically fall back to
sed, sd, or a Python heredoc. All three share two silent failure
modes:
- Silent no-match. The pattern misses, the tool exits
0, the agent moves on assuming success. - Non-idempotent re-runs. Re-running a rewrite on already-rewritten text either no-ops (looks like the first run failed) or, worse, compounds the rewrite.
recast makes both impossible by default. A missing match is a non-zero
exit; a non-convergent rewrite is rejected outright.
What it does
| Capability | sed / sd | Python heredoc | recast |
|---|---|---|---|
| Multi-file rewrite | manual / yes | yes | yes |
| Match-required guard | no | no | yes |
| Idempotency check | no | no | yes |
| Atomic two-phase apply | no | no | yes |
| Diff preview by default | no | no | yes |
| Crash-recovery sweep | no | no | yes |
| Agent-friendly JSON | no | no | yes |
| Regex pattern | yes | yes | yes |
| Script pattern (Rhai) | no | yes (Python) | yes |
| Structural (AST) | no | no | yes (tree-sitter) |
Status
Alpha. Tracked in PLAN.md;
release notes in CHANGELOG.md.
Install
Pre-built binary
Grab the matching artifact from the Releases page.
| Platform | Artifact |
|---|---|
| Linux x86_64 (glibc) | recast-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz |
| Linux x86_64 (musl) | recast-vX.Y.Z-x86_64-unknown-linux-musl.tar.gz |
| Linux aarch64 (glibc) | recast-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz |
| Linux aarch64 (musl) | recast-vX.Y.Z-aarch64-unknown-linux-musl.tar.gz |
| macOS Intel | recast-vX.Y.Z-x86_64-apple-darwin.tar.gz |
| macOS Apple Silicon | recast-vX.Y.Z-aarch64-apple-darwin.tar.gz |
The musl builds are statically linked — drop into Alpine, distroless,
or scratch containers without a glibc dependency.
Each archive ships with a .sha256 sidecar; verify before extracting:
shasum -a 256 -c recast-v0.1.3-x86_64-unknown-linux-gnu.tar.gz.sha256
tar -xzf recast-v0.1.3-x86_64-unknown-linux-gnu.tar.gz
sudo install -m 0755 recast /usr/local/bin/
Cargo install (crates.io)
cargo install recast-cli
The crate is published as recast-cli on crates.io (the bare
recast name was already claimed by an unrelated serialization
library). The installed binary is still called recast — every
recast … command in these docs works as written.
The stock install ships every grammar, the Rhai script engine, and JSON
output. Slim it down with --no-default-features and pick only the
features you actually use — see Cargo features.
cargo install recast-cli --no-default-features --features lang-rust
MCP server
The Model Context Protocol server is a separate crate and a separate binary, so an agent host can run it without the CLI:
cargo install recast-mcp
Three crates ship to crates.io — recast-cli (the recast binary),
recast-mcp (the recast-mcp server binary), and recast-core (the
engine both link against). See
crates.io/recast-mcp for the
client configuration snippet.
From source
git clone https://github.com/Stoica-Mihai/recast
cd recast
cargo install --path crates/recast
Shell completions
recast --completions bash > /etc/bash_completion.d/recast
recast --completions zsh > ~/.config/zsh/completions/_recast
recast --completions fish > ~/.config/fish/completions/recast.fish
Also supported: elvish, powershell.
Verify
recast --version
# recast 0.1.3
First rewrite
The three-step flow agents and humans both use.
1. Preview
recast 'OldName' 'NewName' src/
Output:
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1 +1 @@
-fn OldName() {}
+fn NewName() {}
recast: 1 file(s) would change, 1 match(es) across 8 scanned.
No writes happen until you pass --apply.
2. Apply
recast --apply 'OldName' 'NewName' src/
Output:
recast: applying 1 file(s), 1 match(es).
Under the hood: every file is staged in a sibling .recast.tmp.N
(written + fsync’d), then renamed into place via a per-file
original → .recast.bak.N / temp → original swap. A failure at any
step reverse-renames every committed file from its backup, leaving the
tree bit-identical to the pre-image. See Safety guarantees.
3. Re-run
recast --apply 'OldName' 'NewName' src/
# recast: match-count guard violated: found 0, required at least 1
# exit 2
The second run finds nothing to match, and by default that is a guard violation — the same answer you get for a mistyped pattern, because the two are indistinguishable from the outside.
For a retry loop in CI or from an LLM agent, say so explicitly:
recast --apply --at-least 0 'OldName' 'NewName' src/
# recast: already applied; no changes needed.
# exit 0
If the pattern is non-convergent (e.g. 'a' -> 'aa'), recast refuses
before writing anything, with a non_convergent_replacement,
non_convergent_context, or non_convergent_script error depending on
why. See Safety guarantees.
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.
Rename mode (--rename)
Apply N renames in one pass.
recast --apply --rename 'Foo=Bar' --rename 'Bar=Baz' src/
The failure this exists to prevent
Run those two renames as separate commands and they eat each other:
recast --apply '\bFoo\b' 'Bar' src/ # exit 0
recast --apply '\bBar\b' 'Baz' src/ # exit 0
#![allow(unused)]
fn main() {
// before // after
use Foo; use Baz;
use Bar; use Baz;
}
The original Foo and the original Bar are now the same name, and
nothing complained. Both runs matched, both were convergent on their
own, both exited 0. No guard in recast looks across invocations.
One --rename pass keeps them apart:
#![allow(unused)]
fn main() {
use Bar;
use Baz;
}
What a map may be
Keys match as whole words and are literal — this is a map over identifiers, not a regex pipeline. That restriction is what makes the check below decidable.
| Shape | Example | Verdict |
|---|---|---|
| Independent | Foo=Qux, Bar=Quux | Accepted, re-runnable |
| Chain | Foo=Bar, Bar=Baz | Accepted, correct exactly once |
| Permutation | Foo=Bar, Bar=Foo | Accepted, correct exactly once |
| Self-feeding | Foo=Foo Bar | Refused |
“Correct exactly once” means a replacement reuses a name the map also
renames, so a second run keeps rewriting. recast says so on stderr, and
the recast_rename MCP tool returns the same sentence alongside the
plan:
recast: this map is correct exactly once — a replacement reuses a name
the map also renames, so running it again would keep rewriting.
That is a statement about the map, not a warning about this run. The run itself is correct.
How the check works
recast re-applies the map to its own replacements until the text stops changing or repeats a string it has already seen.
- Settles immediately → no replacement contains a key → re-runnable.
- Settles after a few rounds, or returns to a string already seen → a chain or a cycle. Bounded, so correct once.
- Never settles → the map feeds itself. Refused with
rename_map_diverges.
recast --rename 'Foo=Foo Bar' src/
# recast: rename map does not settle: re-applying it to its own
# replacement of `Foo` -> `Foo Bar` kept producing new text after 64
# rounds, so the map feeds itself and grows.
This is a different question from the ordinary idempotency check. That one asks “is this rewrite a no-op the second time”; a permutation is not, and would be rejected. This one asks “does this map terminate”, which a permutation does.
Key order does not matter
Keys are sorted longest-first before they are compiled into one
alternation, because the regex crate matches alternation
leftmost-first. Without the sort, Foo=A listed before Foo-Bar=B
would rewrite Foo-Bar into A-Bar.
recast --apply --rename 'Foo=A' --rename 'Foo-Bar=B' src/ # Foo-Bar → B
recast --apply --rename 'Foo-Bar=B' --rename 'Foo=A' src/ # same
Notes
OLD=NEWsplits on the first=, so=may appear in NEW.--renameconflicts with--script,--lang,--search,--literal,--word,--ignore-case, and--single-line. The pattern-shaping flags are rejected rather than ignored: renames are always literal and always whole-word.- PATTERN and REPLACEMENT are not used; pass paths directly.
- Works with
--stdin,--check,--diff,--json, the match-count guard, and every filter flag.
Script mode (--script)
When regex’s mad-libs template ($1, ${name}) can’t compute the
replacement, drop in a Rhai script callback.
Quick example: version bump
cat > bump.rhai <<'RHAI'
(parse_int(captures[1]) + 1).to_string()
RHAI
echo "version 3" | recast --stdin --script bump.rhai '(\d+)' ''
# version 4
API the script sees
| Binding | Type | Meaning |
|---|---|---|
captures | Array | captures[0] is the full match, captures[1..] are the named/numbered groups in order |
whole | String | Alias for captures[0] (match is a Rhai keyword) |
The return value is coerced to string and used as the replacement.
Conditional rewrites
if captures[1] == "old" {
"new"
} else {
captures[1] // keep as-is
}
Uppercase a capture
captures[1].to_upper()
Mode notes
- The positional
REPLACEMENTargument is still required when using--script(pass""); its value is ignored. - Scripted scans run sequentially — the Rhai engine isn’t
Sync. That’s usually fine since--scriptruns are dominated by per-script work, not file I/O. Use plain regex mode for big trees. - Sandbox limits: 1 M operations, 1 MiB strings, 1024 array entries, expression depth 64.
- Match-count guard, idempotency check, atomic apply, and recovery all still apply.
Structural mode (--lang)
Tree-sitter-backed AST matching. Pick a language with --lang, then
pass either a friendly --ast pattern or a raw tree-sitter --query.
Supported languages
| Language | CLI name | Cargo feature |
|---|---|---|
| Rust | rust, rs | lang-rust |
| TypeScript | typescript, ts | lang-ts |
| TSX | tsx | lang-ts |
| JavaScript | javascript, js, jsx | lang-js |
| Python | python, py | lang-python |
| Bash | bash, sh, shell | lang-bash |
| Go | go, golang | lang-go |
| JSON | json | lang-json |
| Markdown | markdown, md | lang-md |
YAML / TOML are pending — the upstream tree-sitter grammars don’t yet target the v0.25 ABI recast uses.
Friendly --ast patterns
Write the pattern in the target language with $NAME (single-node) and
$$$NAME (variable-shape subtree) metavars:
recast --lang rust --apply \
--ast 'fn $NAME($$$ARGS) { $$$BODY }' \
'' 'fn ${NAME}_v2$ARGS $BODY' \
src/
Matches every function regardless of signature or body shape; renames it and keeps the original args + body verbatim.
Metavar rules
$NAMEmatches a single AST node at the placeholder’s position.$$$NAMEmatches whatever the surrounding node contains (any number of statements, params, fields, etc.). The capture text is the whole wrapper node:{ $$$BODY }captures{ ... }, not just the inside. Templates therefore should not re-add the wrapper: write$BODY, not{ $BODY }.- Literal identifiers in the pattern (anything that isn’t a metavar) must
match exactly —
fn old_name() {}only matchesfn old_name() {}, not every nullary empty-body function.
Template substitution
Templates use the same $NAME / ${NAME} substitution rules. ${NAME}
is needed when the name is followed by _ / alphanumeric characters that
would otherwise extend the identifier:
fn ${NAME}_v2 # explicit boundary
fn $NAME_v2 # error: no capture named `NAME_v2`
Raw --query patterns
Pass a tree-sitter S-expression query directly. Use this when you need
predicates (#eq?, #match?, …) or want to scope a match to a specific
node kind:
recast --lang rust --apply \
--query '((identifier) @id (#eq? @id "old_name"))' \
'' 'new_name' src/
The capture named @root (or, absent that, the outermost capture in
each match) defines the byte range to replace. Templates can reference
any captured node by name ($id, ${id}).
Deleting items with their attributes (--include-leading-attrs)
Deleting a function with a plain match replaces only the
function_item node — its #[test] / #[cfg(...)] attributes and
/// doc comments are siblings, so they survive as orphans:
# leaves an orphaned `#[test]` behind
recast --lang rust --apply --ast 'fn drop_me() {}' '' '' src/
--include-leading-attrs extends each match backward over the
contiguous run of preceding attribute_item / doc-comment siblings, so
the attributes and docs go with the item:
recast --lang rust --apply --include-leading-attrs \
--ast 'fn drop_me() {}' '' '' src/
A blank line ends the run (an attribute separated from the item by an
empty line is treated as detached and left in place), and plain // /
/* */ comments are never swallowed — only doc comments (///, //!,
/**, /*!). The node kinds are Rust’s; languages without
attribute_item simply never extend. MCP: include_leading_attrs: true
on recast_structural.
Error messages
When a query fails to compile, recast surfaces a line/column-pinned error:
recast: structural: query error: tree-sitter query unknown node
type error at line 1, column 2: zzz
| (zzz) @x
| ^
When a friendly --ast pattern fails to parse, the error mentions which
grammar choked and what the legal positions for metavars are.
When to use what
- Identifier-level renames across a tree →
--query '((identifier) @id (#eq? @id "X"))'. - Whole-construct rewrites that need shape →
--ast. - Cross-cutting transforms that need predicates or alternatives →
raw
--querywith#eq?,#match?, etc.
For anything more complex than what tree-sitter Query expresses, ast-grep or comby are richer. recast’s structural mode is intentionally minimal — it trades expressiveness for the same atomicity / idempotency / JSON-output guarantees the other modes have.
Agent rules snippet
If you’ve installed recast-mcp for an AI agent (Claude Code, Cursor,
Continue, Claude Desktop, Cline, …), the agent gets the tools but not
always the judgment about when to reach for them. Default LLM
behavior is to fall back to Edit / write_file / sed because
those are top-of-mind and recast feels new.
Paste the block below into your project’s agent-instructions file — whichever your runtime picks up:
| Runtime | File |
|---|---|
| Claude Code | AGENTS.md (or CLAUDE.md) |
| Cursor | .cursor/rules/recast.md or .cursorrules |
| Continue | .continue/rules.md |
| Cline | .clinerules |
| Aider | CONVENTIONS.md |
| Generic / other | any markdown file your agent reads at session start |
The snippet doubles up with the recast-mcp server’s own
instructions field (which the client injects into the system prompt
during MCP handshake). Two prompts hitting the same nail — the agent
hears the decision rule from both sides.
Copy-paste block
## Multi-file rewrites — use `recast` MCP tools
For ANY edit that touches the same syntactic shape in 3+ files
(renames, struct-field additions, fn-signature changes, enum-variant
reshapes, version bumps, dependency migrations), call
`recast_preview` first, inspect the diff, then `recast_apply`.
Do NOT default to:
- `Edit` / `write_file` loops over N files
- `sed -i` across the tree
- per-file copy-paste of the same transform
Those silently fail on zero matches, can't roll back mid-failure,
and blast-radius across unintended sites. The recast engine catches
all three: typed `too_few_matches` error on zero hits, atomic
two-phase commit with rollback per file, and a convergence check
that refuses non-idempotent patterns (`a` → `aa`) before any write.
### Decision flow
1. About to make the same change in **3+ files** → `recast_preview`.
2. About to make a **shape-sensitive** change (struct literal, enum
variant, fn signature, AST node) → `recast_structural` with
`ast_pattern` instead of regex.
3. Renaming **two or more names that touch each other** (`Foo`→`Bar`
and `Bar`→`Baz`, or a swap) → `recast_rename` with every entry in
ONE call. Two separate calls turn the original `Foo` and the
original `Bar` into the same `Baz`, and both calls exit 0.
4. Preview returned 0 matches → **iterate the pattern**, do NOT fall
back to per-file `Edit`.
5. Preview looks right → `recast_apply` with identical args.
6. A prior apply was killed mid-run → `recast_recover`.
### Concrete triggers
- "Rename `OldName` to `NewName` across the codebase"
- "Add field `direction: None` to every `ClientMessage::SplitPane { ... }` literal"
- "Update the N call sites of `pane_title(state)` to `pane_title(state, slot)`"
- "Bump every `1.2.3` to `1.2.4` in the workspace"
- "Migrate `.unwrap()` to `.expect("…")` everywhere"
### Tool pick
- `recast_apply` — regex / literal / Rhai script. Works on any
language; text-level.
- `recast_rename` — N whole-word renames in one pass. Use whenever
the renames touch each other; that is the case it exists for.
- `recast_structural` — tree-sitter `ast_pattern`. Use when the
change is shape-sensitive. Supported langs: rust, ts, tsx, js,
python, bash, go, json, markdown.
- `recast_search` — match locations only, no rewriting.
- `recast_recover` — only after a crash mid-apply.
Why this is needed (the survey)
A Claude Code session was given recast-mcp access and asked to do a
multi-file refactor. The agent defaulted to Edit for every change
even though recast_apply would have done the whole sweep in one
call. Post-session debrief:
“Edit was top-of-mind every time. Each individual change felt small enough to justify staying in the familiar tool. The compound cost of ‘small Edit × 50’ sneaked past me.”
“I usually saw ‘this is a repeated transform across N sites’ only AFTER hitting the third or fourth site. By then I’d already done the manual edits and finishing felt cheaper than switching tools.”
The fix: tell the agent the decision rule directly. The system prompt beats latent tool-ranker preferences every time.
CLI flags
recast [OPTIONS] <PATTERN> <REPLACEMENT> [PATHS]...
Modes
| Flag | Effect |
|---|---|
| (default) | Diff preview to stdout |
--apply | Atomic two-phase commit |
--check | Exit 1 if any file would change; no output, no writes |
--stdin | Read one buffer from stdin → stdout |
--recover | Reconcile leftover .recast.bak.* / .recast.tmp.* siblings |
Match guards
| Flag | Default | Effect |
|---|---|---|
--at-least N | 1 | Require at least N matches across all files |
--at-most N | ∞ | Require at most N matches |
--allow-non-convergent | off | Skip the idempotency check |
--allow-syntax-errors | off | Skip the syntax-regression guard (see Safety) |
Filters
| Flag | Effect |
|---|---|
-t LANG, --type LANG | Include only files of this type (rust, js, py, …) |
-T LANG, --type-not LANG | Exclude this file type |
-g PAT, --glob PAT | Include / exclude glob (!pat to exclude) |
--hidden | Include dot-files |
--no-ignore | Bypass .gitignore filtering |
--max-bytes N | Refuse files larger than N bytes (default 10 MiB) |
--max-files N | Refuse runs touching more than N files (default 1000) |
Regex options
| Flag | Effect |
|---|---|
-L, --literal | Treat pattern + replacement as literal text |
-w, --word | Whole words only, same semantics as rg --word-regexp. Rejected with --lang |
--rename OLD=NEW | Whole-word literal rename, repeatable, all in one pass. See Rename mode |
-i, --ignore-case | Case-insensitive |
-s, --single-line | Disable implicit (?s) — . no longer matches \n |
Script mode
| Flag | Effect |
|---|---|
--script PATH | Rhai script file run per match; return value = replacement |
Structural mode
| Flag | Effect |
|---|---|
--lang LANG | Tree-sitter grammar (rust, ts, python, …) |
--query QUERY | Raw tree-sitter S-expression query |
--ast PATTERN | Friendly source-shaped pattern with $NAME metavars |
--include-leading-attrs | Extend each match backward over leading #[attr] / doc-comment lines (see Structural mode) |
Output
| Flag | Effect |
|---|---|
--json | Emit single-line JSON on stdout |
--quiet | Suppress diff body; print only the summary |
-v, --verbose | Per-file timing and counters |
Misc
| Flag | Effect |
|---|---|
--threads N | Worker threads (default = num CPUs) |
--force | Bypass the workspace lock check |
--completions SHELL | Print shell completion script to stdout |
--help | Help summary |
--version | Version string |
JSON output schema
--json emits exactly one line of compact JSON on stdout per
invocation. Snapshot-locked in crates/recast-core/src/snapshots/ —
changing field names or order is a breaking change.
Errors go to stdout too (not stderr) so agents have a single stream to parse.
Common shape
Every report carries a kind discriminator:
kind ∈ "plan" | "apply" | "check" | "error"
Non-error reports share outcome, files_scanned, and total_matches
as a header that appears in that order; the mode-specific count
(files_changed / files_written / files_would_change) follows.
plan (default mode)
{
"kind": "plan",
"outcome": "changes" | "already_applied",
"files_scanned": 5,
"total_matches": 3,
"files_changed": 2,
"changes": [
{ "path": "src/a.rs", "matches": 2 },
{ "path": "src/b.rs", "matches": 1 }
]
}
apply
{
"kind": "apply",
"outcome": "changes" | "already_applied",
"files_scanned": 5,
"total_matches": 3,
"files_written": 2
}
check
{
"kind": "check",
"outcome": "changes" | "already_applied",
"files_scanned": 5,
"total_matches": 3,
"files_would_change": 2
}
error
{
"kind": "error",
"error":
"too_few_matches"
| "too_many_matches"
| "non_convergent_replacement"
| "non_convergent_context"
| "non_convergent_script"
| "invalid_rename_map"
| "rename_map_diverges"
| "too_many_files"
| "file_too_large"
| "invalid_regex"
| "invalid_glob"
| "walk"
| "io"
| "script_parse"
| "script_runtime"
| "unknown_language"
| "structural_query"
| "structural_template"
| "structural_parse"
| "locked"
| "invalid_threads"
| "thread_pool",
"message": "human-readable description",
"exit_code": 2 | 3,
"remedies": [
"at_least" | "at_most" | "max_bytes" | "max_files"
| "word" | "allow_non_convergent" | "allow_syntax_errors"
| "force_lock" | "threads"
]
}
The exit_code field mirrors the process exit code so agents can branch
on kind: "error" without re-reading $?.
remedies lists the knobs that could clear this error, most useful
first, and is empty when the fix is to change the pattern or the tree
instead. Branch on it rather than reading message, which is prose.
recast-core names the knob, not the flag, because it does not know
whether it is serving the CLI or the MCP server — so the same
non_convergent_replacement reports ["word", "allow_non_convergent"]
here, prints see --word / --allow-non-convergent on the CLI, and says
set word or allow_non_convergent over MCP.
One knob is deliberately CLI-only. force_lock appears in this list but
has no MCP argument: a crashed holder releases its flock on exit,
so a held lock always means a live process is mid-apply, and the MCP
locked error returns "remedies": []. Wait and retry, or stop and
ask — there is nothing to set.
Stability
Every shape above has an insta snapshot test. Any PR that changes
field names, drops a field, or reorders them shows up as a snapshot
diff in review — there’s no quiet schema drift.
Exit codes
| Code | Meaning |
|---|---|
0 | Success, or “no changes needed” |
1 | --check set and at least one file would change |
2 | Match-count guard violated (--at-least / --at-most) |
3 | Internal error (regex / glob parse, I/O, non-convergent pattern, syntax regression, script error, structural query error, workspace lock held, …) |
Agents can branch on these without parsing stdout. Combined with --json,
exit-code 2 always pairs with kind: "error" + error: "too_few_matches"
or "too_many_matches", and exit-code 3 with one of the remaining
error discriminants.
Examples
recast --check --at-least 0 'TODO' 'FIXME' .
echo "exit=$?"
# exit=0 → no files would change (clean)
# exit=1 → at least one file would change (CI gate fail)
recast --at-least 5 'foo' 'bar' src/
# exit=2 → fewer than 5 matches; nothing applied
A --check gate needs --at-least 0. Without it, a clean tree is a
zero-match run and exits 2, not 0 — the guard fires before --check
classifies anything. That is deliberate: the guard cannot tell a clean
tree from a pattern you mistyped, so it makes you say which one you
meant. A CI gate that greps for something it expects to be absent wants
--at-least 0; one that expects matches and asserts they all got
rewritten wants the default.
Safety guarantees
The six things that make recast safer than sed / sd / a Python
heredoc.
1. Match-required guard
Default --at-least 1 makes a silent zero-match exit impossible. An
agent that types the wrong pattern gets an immediate non-zero exit
instead of “looks like it worked”.
Override with --at-least 0 if you really do want to allow no-op runs.
2. Idempotency / convergence
Before any write, recast re-applies the rewrite to its own post-image.
If any file would change again, the run is aborted with one of three
errors, split by cause because the fixes differ:
non_convergent_replacement (the replacement still matches the pattern
— narrow it, usually with word boundaries), non_convergent_context
(the replacement is clean but the rewrite pulls surrounding text into a
new match — word boundaries will not help), and
non_convergent_script (a --script run, where the replacement is
computed per match so the cause is not knowable up front). All three
name --allow-non-convergent as the override.
Examples that pass:
'old' -> 'new''fn (\w+)_old' -> 'fn ${1}_new'
Examples that get rejected:
'a' -> 'aa'(grows on every run)'foo' -> 'foofoo'
A successful first run followed by a re-run has nothing left to match,
so it is the guard in section 1 that decides the outcome, not
convergence. Re-run with --at-least 0 to get already_applied and
exit 0; the default --at-least 1 exits 2, because from the planner’s
side an already-converted tree and a mistyped pattern are the same
observation.
3. Syntax-regression guard
For every changed file whose extension maps to a compiled tree-sitter
grammar (.rs, .ts, .tsx, .js/.mjs/.cjs/.jsx, .py,
.sh/.bash, .go, .json, .md), recast re-parses the post-image
and counts parse errors. If the rewrite introduces new errors
relative to the pre-image, the run is aborted with a
syntax_regression error before anything is written.
The comparison is a count delta, so a file that was already unparsable (mid-refactor, exotic macro) stays acceptable as long as the rewrite doesn’t make it worse. This catches a greedy regex that strands a brace or truncates an expression.
# regex deletes the `fn open(` line but leaves the body + closing brace
recast --apply 'fn open\(\) \{\n' '' src/ # → syntax_regression, nothing written
Limitation — syntactic, not semantic. The guard sees parse errors,
not compiler errors. Deleting a function body while leaving its
#[test] attribute behind produces valid syntax (the attribute just
binds to the next item); tree-sitter does not flag it, so the guard does
not fire. That class is a job for cargo check or, better, for
structural mode, which removes the attribute along
with the item.
Override per run with --allow-syntax-errors (CLI) /
allow_syntax_errors: true (MCP). Files with no compiled grammar — and
--no-default-features builds with every lang-* feature off — skip the
guard and pass through unchecked.
4. Two-phase atomic apply
Phase A (stage) per file: write sibling .recast.tmp, fsync, copy mode
Phase B (commit) per file: rename original→.recast.bak, rename .tmp→original
Phase C (cleanup) per dir: delete backups, fsync parent dir
Any failure in Phase B walks the rename log in reverse — every already-renamed file is restored from its backup, leaving the tree bit-identical to the pre-image. Failure in Phase A just deletes the staged temps; originals are never touched.
This applies to regex, script, and structural modes.
5. Crash-recovery sweep
If the process dies mid-commit (SIGKILL, panic, power loss), the tree
may be left with leftover .foo.recast.bak.N / .foo.recast.tmp.N
siblings. Reconcile with:
recast --recover src/
- Target exists + stale backup/temp → delete leftovers
- Target missing + backup present → rename newest backup back to target
- Target missing + only temps → leave untouched (can’t safely decide)
6. Workspace lock
--apply and --recover take an exclusive non-blocking lock so two
concurrent rewrites against the same tree don’t interleave. The second
invocation gets an immediate locked error with exit 3 instead of
corrupting the tree. The same lock now covers the MCP recast_apply,
recast_structural (with apply), and recast_recover tools.
What the lock is keyed on. The enclosing VCS checkout — the nearest
ancestor holding .git, .hg, .jj, or .svn — not the paths a given
invocation happens to name. So recast --apply … src/ and
recast --apply … src/sub/ contend with each other, as do two runs from
different working directories.
Where the file lives. $XDG_RUNTIME_DIR/recast/<hash>.lock, or the
system temp directory when XDG_RUNTIME_DIR is unset. Never inside your
working tree, so a rewrite leaves no untracked file behind. The name is
a stable hash of the canonical root; the error message names the tree so
you don’t have to decode it.
The file is never deleted, on purpose. Unlinking a lockfile on release breaks mutual exclusion: a process still holding a descriptor on the old inode and a process creating a fresh file at the same path end up locking two different objects, and both succeed. The leftover files are zero bytes and live in a directory the system clears.
Limits worth knowing.
- The lock is advisory and per-user.
$XDG_RUNTIME_DIRis mode0700, so two different users rewriting one shared tree will not see each other’s lock. - A tree with no VCS marker has no principled root, so it falls back to
the common ancestor of the path arguments. For those trees the
src/versussrc/sub/gap above still applies. - Atomicity is per-invocation, not cross-invocation. The lock stops interleaving; it is not a transaction across separate runs.
--force bypasses the lock for cases you genuinely understand (e.g.,
the previous holder crashed and you’ve already run --recover).
--check and --diff skip the lock since they don’t write.
Cargo features
recast (the binary) and recast-core (the library) expose a matching
set of cargo features so users can opt out of grammars or modes they
don’t need.
recast binary
| Feature | Default | Pulls in |
|---|---|---|
script | ✅ | recast-core/script (Rhai callback) |
lang-rust | ✅ | tree-sitter + tree-sitter-rust |
lang-ts | ✅ | tree-sitter + tree-sitter-typescript |
lang-js | ✅ | tree-sitter + tree-sitter-javascript |
lang-python | ✅ | tree-sitter + tree-sitter-python |
lang-bash | ✅ | tree-sitter + tree-sitter-bash |
lang-go | ✅ | tree-sitter + tree-sitter-go |
lang-json | ✅ | tree-sitter + tree-sitter-json |
lang-md | ✅ | tree-sitter + tree-sitter-md |
lang-all | ✅ | Meta — enables every lang-* above |
Stock install:
cargo install --path crates/recast
Slim install — Rust grammar only, no script engine:
cargo install --path crates/recast \
--no-default-features \
--features lang-rust
Slim install — Python + TypeScript only:
cargo install --path crates/recast \
--no-default-features \
--features lang-python,lang-ts
recast-core library
The library exposes the same lang-* features plus serde (JSON output
schema). At least one lang-* must be enabled for structural mode to
compile in.
Binary size
Each grammar adds ~5–15 MB of compiled tables. The full --features lang-all binary is ~80 MB. A slim lang-rust-only build is ~25 MB.
Static musl builds are a few MB larger.
Building from source
git clone https://github.com/Stoica-Mihai/recast
cd recast
cargo build --release --workspace --all-features
The full test + lint matrix CI runs:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo doc --workspace --no-deps --all-features # RUSTDOCFLAGS=-D warnings in CI
Property tests run as part of cargo test; criterion benchmarks are
opt-in:
cargo bench --features lang-rust,script -p recast-core
HTML bench reports land under target/criterion/.
Cutting a release
-
Bump
versioninCargo.toml(workspace) and therecast-corepath-dep pin. -
Promote
## [Unreleased]→## [X.Y.Z] — YYYY-MM-DDinCHANGELOG.md. -
Commit, tag, push:
git commit -am "chore: bump to X.Y.Z" git tag -a vX.Y.Z -m "vX.Y.Z — short tagline" git push origin main git push origin vX.Y.Z -
.github/workflows/release.ymlcross-compiles seven targets, packages each, extracts the[X.Y.Z]CHANGELOG section as release notes, and attaches everything to the matching GitHub Release. Workflow_dispatch re-runsgh release edit --notes-fileagainst an existing tag.
Operating manual
The full operating manual for AI agents and human contributors lives in
AGENTS.md
(symlinked as CLAUDE.md for agent runtimes that look it up by that
name).
Highlights:
- TDD by default — every feature goes through
/tdd:tdd - DRY enforced — every feature/fix runs through
/engineering-principles:dry-principle - Tests live in their own files (
foo_tests.rsnext tofoo.rs) - Property tests via
proptestfor every public entry point - Snapshot tests via
instafor JSON + unified-diff output - Conventional commit prefixes; one concern per PR
- No
Co-Authored-Bytrailers - README + CHANGELOG + PLAN are documentation contracts — drift is a bug
- Release process is fully automated from tag push
See the canonical file for the full set of rules and the reasoning behind each one.