Install
git clone https://github.com/e6qu/fun-refactor cargo install --path fun-refactor
That installs the binary, which is called fr.
You need the Rust 2021 edition and a C compiler. Every tree-sitter grammar is C,
so a cold build takes a few minutes.
Naming a target
Give a target to any command that acts on a symbol, written one of two ways.
| Form | When | Example |
|---|---|---|
path:line:col |
Always works. Count from 1, and land on the identifier itself, at the definition or at any use of it. | fr refs pkg/action/action.go:725:6 |
| a bare name | When the name is unique in the workspace. Otherwise the tool lists the candidates and stops. | fr callers RunAll |
path:l:c-l:c |
fr extract, which acts on a range instead of a symbol. |
fr extract a.go:8:5-8:19 count |
path |
fr imports, and the destination argument of fr move. |
fr imports pkg/action/install.go |
The tool reads a relative path against the workspace root, which
is the current directory unless -C says otherwise. Your shell's
working directory never enters into it. A path that does not exist gives an error
naming the place the tool looked.
If you are not sure what to write, open the
playground. Its status bar shows the
path:line:col of whatever the cursor is on, and copies it when
clicked. That string is the one this table describes, so point at something in
the browser and act on it in a terminal.
Confidence
Every resolved reference carries the rule that resolved it. This is the tool's central idea: it would rather hand you a list to check than change a line it cannot justify.
| Tier | What proved it | Rewritten |
|---|---|---|
| exact | The lexical scope chain, the same file, or the enclosing package. | yes |
| import-qualified | An import binding in this file names the file it came from. | yes |
| field-based | The name is a member of something, and the tool does not track the receiver's type. | no, reported |
| name-only | Nothing but the name matched. | no, reported |
Refactorings also report textual occurrences, meaning the name in a string or a comment, and parse errors, because a file the tool could not read may hold references nobody can see.
Commands
Understanding
fr parse --stats | What is here, and what failed to parse |
fr symbols | Every definition |
fr def · fr refs | Go to definition; every use, with confidence |
fr implementations · fr usages | What satisfies an abstraction; uses grouped by file |
fr callers · fr callees · fr graph | The call graph, either direction or whole |
fr flow back · fr flow fwd | Where a value came from; where it goes |
fr impact | Everything a change could affect |
fr entrypoints | Where execution starts |
fr stitch | Config → code: which program reads this setting, runs this script, or declares this flag |
fr openapi [--yaml] | The contract a route tree declares, to diff a rewrite against. Next.js, FastAPI, Express, Flask, axum, gin and Spring; worked through a pet store |
Finding work
fr unused | Symbols nothing appears to use. --internal drops the public API; --lang and --path narrow the report, never the index |
fr duplicates | Code written more than once, compared structurally. --exact requires the names to match too |
Changing
All of these print a diff and write nothing until --write.
fr rename | Rename a symbol and everything that points at it |
fr extract | An expression into a binding, or statements into a function |
fr inline | The reverse of either |
fr signature | Add, remove or reorder parameters, updating every call |
fr move | Move a symbol to another file, carrying its imports |
fr delete | Delete it, refusing while anything uses it |
fr imports | Drop unused imports and sort the rest |
fr rewrite | Local transformations: invert-if, guard-clause, de-morgan |
fr restructure | Pattern rewriting: old($X) → new($X) |
fr remove-flag | Retire a feature flag and the branch that served it |
fr translate | Write the file as another language, or port a route to FastAPI |
fr recipe | Run a refactoring written down: find, do, and say what must hold after |
--json works on every command. fr capabilities prints
the full matrix of what each language supports, with the reason attached to every
cell that is not supported.
Writing a file as another language
fr translate has three modes, and they make three quite different
promises. Run it without a target to see which apply to a file, each with the
reason the others do not.
| Mode | What you get | Example |
|---|---|---|
| Containment | The same bytes under a different grammar. It rewrites nothing, so nothing can go missing. | fr translate app.css scss |
| Translation | A draft between Rust, Go, Java, Python, TypeScript, Zig and Bash. Signatures carry across intact; anything with no counterpart lands in the output verbatim, inside a comment, and counted. | fr translate sensors.py typescript |
| Framework port | A Next.js API route as a FastAPI module: routes, methods, path parameters, models and bodies. | fr translate 'app/api/users/[id]/route.ts' fastapi |
The signature is the contract. Every parameter crosses in order, with its type and the return type, and only the spelling changes. Where a type has no counterpart, the tool writes it through by name and counts it. Substituting a type quietly makes a signature stop meaning what it said.
Names take the target's convention
TypeScript writes userName, Python writes user_name, Go
says "exported" with a capital letter. A translated file adopts the target's
convention, under the same rule the refactorings follow: rename what the
file declares and nothing else. The tool re-spells functions, records,
fields, constants, parameters and locals at their declaration and at every use.
It leaves a name the module does not declare, such as db.users.find
or any library function, as written, because re-casing it would rename somebody
else's API.
Typed Python and typed TypeScript
The closest pair gets the closest translation: list[str] is
string[], dict[str, int] is
Record<string, number>, Optional[T] is
T | null, a @dataclass is an interface, an
f-string is a template literal, and [f(x) for x in xs if p(x)] is
xs.filter(p).map(f). Both directions carry zero constructs
verbatim, the strongest claim any pair in this tool makes.
Next.js to FastAPI
Reading the file alone cannot translate a Next.js API route, because
the URL is the path.
app/api/users/[id]/route.ts serves /api/users/{id}, and
nothing inside the file says so. [...path] is a catch-all, which
FastAPI spells {path:path}. A translation emitting
{path} would point silently at the wrong URLs.
| Next.js | FastAPI |
|---|---|
export async function GET | @router.get(…) on an async def |
an exported interface | a Pydantic BaseModel |
NextRequest | Request, with the same headers and the same await .json() |
const id = context.params.id | dropped, because FastAPI already supplies it |
return NextResponse.json(x) | return x |
NextResponse.json(x, { status: 404 }) | JSONResponse(x, 404) |
It refuses a .tsx file containing JSX, and says why: a React
component renders a user interface and a FastAPI endpoint answers HTTP. No
translation runs between them, and a file that pretended otherwise would be worse
than no file. Your own dependencies stay foreign too: the database calls and
helpers the route imported, which no translation could supply.
In the playground, press Rewrite. It lists every target with what that target would cost, grouped by which of the three promises it makes.
Measured against real projects
Whoever writes the assertion writes the fixture. So the same translations run
over two MIT-licensed projects, vendored unmodified and pinned:
fastapi/full-stack-fastapi-template and
shadcn-ui/taxonomy. Those runs assert three properties instead of
exact output: the result parses as what it claims to be, it adopts the target's
conventions, and nothing goes missing quietly.
That found what the fixture tests did not, including two silent wrong
answers: session?.user.id became None.id, and
params.postId as string became None. The tool now
carries both alongside the original. On the hardest route in the corpus the count
of constructs carried over as comments went from 15 to 3.
API contracts
A refactoring preserves behaviour. A translation preserves a
signature. Rewriting a service in another language has to preserve
something else again: the only thing anyone outside the repository can see, the
contract. A caller does not import your functions. It sends
PATCH /posts/42 and expects 204.
That makes it a strange kind of cross-language refactoring: the language, the framework, the internal structure and every function signature are all free to change, and the six things below are not.
| Contract element | Where it lives in Next.js | Carried? |
|---|---|---|
| URL template | the file path | yes |
| Catch-all segment | [...path] | yes, as {path:path} |
| Method | the exported function's name | yes |
| Path parameters | the bracketed segment | yes, typed |
| Request body schema | a zod schema, or an interface | only from an interface |
| Response body schema | usually nowhere | no |
| Status codes | the returned object | into the code, not the contract |
The URL is the path
app/api/posts/[postId]/route.ts serves /api/posts/{postId},
and nothing inside the file says so. No content-only translation
can recover that, however well it reads TypeScript. This one part of the job needs the tree instead of the text, and it carries most of the value.
[...path] is a catch-all: it matches across slashes, which FastAPI
spells {path:path}. A translation emitting {path} would
answer a strictly smaller set of URLs than the service it replaced, silently, and
only for the requests with a slash in them.
Why OpenAPI makes the two directions asymmetric
FastAPI derives OpenAPI from the code. The decorator gives the URL and method, the annotated parameters give the path parameters, and a Pydantic model gives the body schema. The contract projects the types instead of sitting in a document somebody maintains. Next.js has no built-in equivalent.
So Next.js → FastAPI gains a machine-readable contract where none existed, and there sits the trap. The generated OpenAPI covers what carried across and no more, and it does not look incomplete.
Right behaviour, wrong document
Here is the sharp edge, and the tool now reports it. A translated handler returns
204 and behaves correctly. FastAPI builds its OpenAPI from the
decorator, so a status on a returned Response changes what
the endpoint does without changing what it says it does:
@router.delete("/api/posts/{postId}")
async def delete(postId: str, req: Request):
return Response(status_code=204)
The decorator documents a 200 and the body returns a
204.
The rewrite preserves behaviour and shrinks the contract at once, and every test
you have will pass. The tool does not add status_code= to the
decorator. Which status counts as the success one is a judgement about
the endpoint instead of a fact about the syntax. It lists every status it saw and
says what happens if you leave them where they are.
How to check a rewrite
The tool preserves what it can see and reports the rest. It does not
verify the contract, and no amount of reading one side can. Export the contract
first, rewrite, export /openapi.json afterwards, and diff the two,
treating every difference as a change to review. fr openapi extracts
a declared baseline from supported route patterns. Runtime behavior needs its own checks.
The playground
The playground runs this analysis compiled to WebAssembly. No server takes part: the tab fetches a public repository from GitHub, holds it in memory, indexes it, and works out every answer and every edit in the browser. Nothing leaves the tab and nothing on GitHub changes. The diff is the artifact, and you can download the session as a patch.
It ships all thirty-eight of its methods and a bundled sixteen-language workspace, so you can try every one against every grammar without waiting for a download or hitting a rate limit. Ten preset repositories cover the same ground on real code.
| Pane | What it is for |
|---|---|
| Files | Every file loaded. A file in a language this build has no grammar for still appears, with a note beside it, instead of vanishing. |
| Outline | Every definition in the open file, from the index, matching the answer
fr symbols gives. |
| Structure | The parse tree. Every answer the tool gives is a claim about this tree,
so a surprising answer usually becomes obvious here. Whether a name is
an identifier or a field_identifier decides
what half the tool will do with it. Click a node to select the bytes it
covers, then use that range for extract. |
| Status bar | The coordinate, the symbol under the cursor, whether it is exported, where it is defined, and the innermost tree node. |
| Result | What the action returned. Expect a diff, a list with confidence tiers, a call tree, or the refusal and its reason. A refusal answers too: it names the rule that stopped the change. |
Navigation sits where an editor keeps it. Press F12 or
⌘/Ctrl-click to reach the definition. The arrows in the
bar walk back and forward through the jumps you made, and a right-click offers
every action that applies to what is under the cursor, with a reason on each one
that cannot run. Drag the columns, fold the sidebar sections, and the page
remembers the layout. Each refactoring is one transaction: Undo and Redo move
one checked transaction at a time, while Download Patch exports every transaction
still applied through the same Rust renderer as the terminal.
The browser cannot do the parts that need a filesystem. It keeps no fact cache, and a repository has to fit in a tab, so the loader caps the file count and says what it left out. Its bounded history is saved as one content-bound browser checkpoint after each completed transaction. Reload restores the last complete state; the browser history contract records the exact limits, compaction behavior and trust boundary.
Asking the page instead of reading it
The playground answers a program as readily as a person. A query parameter picks the rendering, and the two machine-readable ones never load the editor: about seven kilobytes of JavaScript against four megabytes.
| URL | What comes back |
|---|---|
playground/ |
The page. Same as ?render_as=html. |
playground/?render_as=json |
The same interface described and not drawn: the panes and what is in them, the toolbar, and every action with whether it can run and why not. |
playground/?render_as=json_data |
The analysis with no interface around it: what the tool indexed, the entry points, the call graph, the dead code, the duplicates, and the open file's symbols and parse tree. |
&repo=psf/requests |
Which workspace to answer about. repo=sample uses the
bundled sixteen-language one. |
&file=src/requests/api.py |
Which file the answer is about. |
The whole document body is the JSON, so read it with JSON.parse(document.body.textContent).
An unknown render_as earns a refusal listing the valid ones, instead of quietly rendering HTML.
How it works
Parsing, and where the trees live
tree-sitter parses each file, one grammar per language, into a tree that keeps every byte, comments and whitespace included. Losslessness follows: an edit splices one byte range, so everything outside the range survives by construction.
The tool stores no trees. A tree lives for one file's extraction and then goes. A much smaller set of facts survives it: symbols, references, scopes and imports, each carrying a byte span. Keeping 16,000 syntax trees in memory would cost more than re-parsing the few files any one command needs.
Positions are byte offsets everywhere except the command line. Line and column are a display format, and a tool that miscounts a column corrupts a file.
Helm gets one extra step. The tool masks each template action before
parsing, replacing it with filler of the same byte length, so the surrounding
YAML keeps a valid structure and every offset stays correct. It then parses the
actions separately, turning .Values.image.tag into a reference to a
values key that a rename can rewrite.
The cache
The cache addresses everything by content. A file's facts sit under a key made from the SHA-256 of its contents and a fingerprint of the query set that produced them. Change the file and the key changes; change a query and every key changes. No invalidation logic can go wrong, because nothing needs invalidating: no lookup ever reaches a stale entry.
fr cache location ~/Library/Caches/fun-refactor/v2-dbe4d237fed430ca size 3791 KiB fr cache --clear fr <command> --no-cache
Set FUN_REFACTOR_CACHE to override the location. The directory name
carries a schema version, so a release that changes what a fact is starts
a new namespace instead of reading old data with a new meaning.
The edit engine
Every refactoring returns a plan and touches nothing. The engine then:
- Rejects overlaps. Two edits to the same bytes are a bug in the caller, not something to resolve by picking one.
- Applies in descending offset order, so earlier offsets stay valid as it goes.
- Reparses every changed file and refuses the whole edit if the file parses cleanly now and would not afterwards.
- Commits with recovery. Native CLI writes keep persistent before/after snapshots. Checked undo, redo and interrupted-write recovery use transaction IDs through
fr history.
That reparse check is the net beneath the work rather than the work. It cannot catch a change that parses and means something else: hoisting a statement out from under the condition that guarded it, or dropping the brackets a de Morgan result needs. Only correct analysis catches those, so the confidence tiers exist and the tool refuses as much as it does.
Known limitations
BUGS.md lists
every one, each with the measurement that established it. In summary: source
cannot decide reachability under dynamic dispatch, so the tool reports it and
leaves the guessing alone. A workspace scan cannot see Helm values passed on a
command line unless you supply them with -f and --set.
CSS classes named inside TSX helper calls go unresolved, which measurably costs
about two-thirds of the reach of a stylesheet rename in a modern React codebase.
Two upstream grammar gaps remain, in SCSS and in Go's handling of a user-defined
new.