fun-refactor

The recipe language

A refactoring, written down

A recipe says what to find, what to do to it, and what must be true afterwards. Review it, run it again next month, and watch it fail loudly when the tree has moved.

Every command in this tool acts on one target. That suits a person at a terminal and fails the work people really have: "retire this flag, delete what it was guarding, and tidy the imports that leaves behind" is three commands in order, each depending on the last. Write it as a shell loop and the refusals scroll past, the ordering stays implicit, and no reviewer can read what you did. A recipe makes the plan the artifact, and the diff comes out of it.

Generated  Every report and every diff below records what fr recipe did, captured by cargo test --test site_data. That test fails when the page and the tool disagree.

The shape of one

schema 1

recipe name {
  description "why this exists"

  requires symbol "USE_LEGACY_AUTH"

  operation where predicate…  modifier…

  expect changed > 0 files
}

requires is the guard. A recipe run against the wrong tree stops there instead of part way through. For a settled rename, requires any symbol "before" "after" accepts either name and rejects a tree with neither.

Layout means nothing here, and no statement takes a terminator. A statement ends when the parser meets a token that can only begin a new one. Step keywords are reserved and no predicate shares a name with one, an invariant a test enforces rather than hopes for. So a where clause runs across as many lines as it needs with no punctuation.

It is not a programming language. No loops, no arithmetic, no conditionals. The moment a recipe needs those it should be a program calling the CLI. And it does not extend what the tool can do: if a step could not be typed as an fr command, it is not a step.

The operations

StepTakesSelector
rename to "x"a namerequired
deletenothingrequired
move to "path"a destinationrequired
importsnothingrequired
inline variable / inline callwhichrequired
signature "add:1:x: int:0"the changerequired
rewrite guard-clausethe transformationrequired, and it chooses files
translate to pythonthe target languagerequired, and it chooses files
extract function at "…" as "…"a range and a namerejected: nothing selects a range
remove-flag "F" = falsethe flag and its valuerejected: it acts on the whole workspace
restructure go 'a($X)' => 'b($X)'a shape and a templaterejected: the pattern is the selector

“Rejected” means an error naming the operation and why, never a silent ignore. A grammar that reads operation , [selector] , {modifier} cannot say that rewrite needs a name or that remove-flag has no use for a where clause, so a signature table checks it immediately after the parse.

The predicates

PredicateMatches
name="x" / name~"pre_*"exactly, or by glob
kind=functionfunction, method, class, key, selector, …
lang=pythonone of the eighteen
file~"*_test.go" / in="src/adapters/"by path
annotated-with="test"#[test], @Test, @pytest.mark
exported / !exportedis or is not
unusednothing reaches it, from fr unused
duplicatedpart of a copy-paste class, from fr duplicates
changedthis recipe already touched it
calls="x" / called-by="x"an edge in the call graph, from fr callers / fr callees
implements="Sink"a concrete answer to an abstraction, from fr implementations
matches='log($X)'a structural shape, from fr restructure. Needs lang= beside it

Each of the last five reuses an existing analysis, and each runs only when a predicate asks for it. Building a call graph over a large repository for a selector that says name="x" is work nobody asked for. matches= needs a lang= beside it, because the same text parses into a different tree in every language, and no language-free answer says where a shape occurs.

A selector that matches nothing stops the recipe. Doing nothing quietly is the failure this most wants to avoid, because it looks just like success. Write allow-empty when a step is genuinely conditional.

Refusals, transactions, expectations

on-refusal defaults to stop: abandon the run and write nothing. report applies the rest and exits non-zero; allow applies the rest and exits zero, because a person decided these refusals were acceptable. No ignore exists, by design: every refusal reaches the report, and the only question is what it costs.

A recipe file runs as one transaction. Either every recipe's edits land or none do, and each step and recipe sees the workspace as the previous one left it, which takes a re-index between them. It dry runs by default, as everything here does.

Five languages, five things it teaches

Writing one language as another

A translation makes a different promise from a refactoring. The signature is the contract, and anything with no counterpart travels into the output verbatim, and counted.