fun-refactor

Reference

Documentation

Enough to use it and to know when not to trust it. The tutorial walks a whole refactor on a real codebase. Read this page for the reference underneath it, or CLI.md for every command at once.

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.

FormWhenExample
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.

TierWhat proved itRewritten
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 --statsWhat is here, and what failed to parse
fr symbolsEvery definition
fr def · fr refsGo to definition; every use, with confidence
fr implementations · fr usagesWhat satisfies an abstraction; uses grouped by file
fr callers · fr callees · fr graphThe call graph, either direction or whole
fr flow back · fr flow fwdWhere a value came from; where it goes
fr impactEverything a change could affect
fr entrypointsWhere execution starts
fr stitchConfig → 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 unusedSymbols nothing appears to use. --internal drops the public API; --lang and --path narrow the report, never the index
fr duplicatesCode 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 renameRename a symbol and everything that points at it
fr extractAn expression into a binding, or statements into a function
fr inlineThe reverse of either
fr signatureAdd, remove or reorder parameters, updating every call
fr moveMove a symbol to another file, carrying its imports
fr deleteDelete it, refusing while anything uses it
fr importsDrop unused imports and sort the rest
fr rewriteLocal transformations: invert-if, guard-clause, de-morgan
fr restructurePattern rewriting: old($X) → new($X)
fr remove-flagRetire a feature flag and the branch that served it
fr translateWrite the file as another language, or port a route to FastAPI
fr recipeRun 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.

ModeWhat you getExample
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.jsFastAPI
export async function GET@router.get(…) on an async def
an exported interfacea Pydantic BaseModel
NextRequestRequest, with the same headers and the same await .json()
const id = context.params.iddropped, 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 elementWhere it lives in Next.jsCarried?
URL templatethe file pathyes
Catch-all segment[...path]yes, as {path:path}
Methodthe exported function's nameyes
Path parametersthe bracketed segmentyes, typed
Request body schemaa zod schema, or an interfaceonly from an interface
Response body schemausually nowhereno
Status codesthe returned objectinto 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.

PaneWhat 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.

URLWhat 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:

  1. Rejects overlaps. Two edits to the same bytes are a bug in the caller, not something to resolve by picking one.
  2. Applies in descending offset order, so earlier offsets stay valid as it goes.
  3. Reparses every changed file and refuses the whole edit if the file parses cleanly now and would not afterwards.
  4. 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.