The refactor no tool owns
Rename is everywhere; changing what a function takes is nowhere. Building it meant letting the declaration write the rule, checking the result against the analyzer's own reference list, and refusing to write anything that does not compile.

On this page · 4 sections
The structural codemod opened by calling this the refactor no tool wants to own; this is the one that owns it. Every editor renames. Point at a symbol, type a new name, and the analyzer rewrites every use of it across the project, correctly, in a second. It has worked this well for a decade.
Now change what the function takes — swap two parameters, drop one, add one — and the same editor has nothing. Ask rust-analyzer what it can do at a function declaration:
$ prod-code assists crates/prod-code-mcp/src/diagnostics.rs 287 14
inline_into_callers [RefactorInline] Inline into all callers
generate_fn_type_alias_named [Generate] Generate a type alias for function with named params
generate_fn_type_alias_unnamed [Generate] Generate a type alias for function with unnamed params
(The CLI addresses code actions by position; over MCP the same tool takes symbol, as
symbol addressing describes.)
Three assists, none of which touches the parameter list. IntelliJ has had Change Signature for Java since before some of its users were born; the Rust world has a rename and a shrug.
So the agent does it by hand: edit the declaration, find the call sites, rewrite each argument list. Or it writes a structural rule, which means inventing a placeholder per argument, remembering the arity, and spelling the path the way the call sites spell it. Every one of those is a chance to produce something that looks finished and quietly changed less than it should have.
Let the declaration write the rule
code_change_signature takes the parameter list the function should end up with. Names keep a
parameter, name: Type = expression adds one with the expression to pass at every call site,
and anything not listed is removed:
$ prod-code change-signature LspSession::open_text --param text --param file
`open_text` (crates/prod-code-mcp/src/session.rs)
- was: (&mut self, file: &Path, text: &str)
- now: (&mut self, text: &str, file: &Path)
- call sites: `$recv.open_text($a0, $a1) ==>> $recv.open_text($a1, $a0)`
12 changed line(s) in 3 file(s)
--- a/crates/prod-code-mcp/src/diagnostics.rs
- let uri = session.open_text(file, text).await?;
+ let uri = session.open_text(text, file).await?;
--- a/crates/prod-code-mcp/src/session.rs
- self.open_text(file, text).await?;
+ self.open_text(text, file).await?;
- pub async fn open_text(&mut self, file: &Path, text: &str) -> Result<String> {
+ pub async fn open_text(&mut self, text: &str, file: &Path) -> Result<String> {
…
the analyzer accepts the result: 0 errors
nothing was written; pass `apply: true` to make these edits
The call sites line is the interesting one, and the diff above is trimmed to its first
hunks. The rule was not written by a human: the arity, the
order and the receiver come from the declaration, so $a0 and $a1 are bound to whatever the
call site actually passes — a variable, a method chain, a closure spanning three lines. It is
the same structural engine as the codemod, pointed at itself.
Adding a parameter is the same shape, with the new argument spelled out:
$ prod-code change-signature fixture::snake_case --param name --param "upper: bool = false"
- was: (name: &str)
- now: (name: &str, upper: bool)
- call sites: `snake_case($a0) ==>> snake_case($a0, false)`
Three ways to be honest
A refactoring tool that rewrites twelve lines and says “done” is asking to be trusted. These are the three places this one refuses to.
It names the call sites it could not match. After the rewrite, what was touched is compared
against textDocument/references for the same symbol — each reference matched against the lines
of its own call, from the callee’s name to the closing parenthesis. Put the function somewhere
structural search cannot see it, and the report says so:
# somewhere in the file: let as_a_value: fn(&str) -> String = snake_case;
not rewritten (1 reference(s) the rule did not match — a call through a function pointer,
a macro, or a spelling structural search cannot see):
crates/prod-code-mcp/src/fixture.rs:655:46
the analyzer rejects the result:
expected fn(&str) -> String, found fn snake_case(&str, bool) -> String [E0308]
(crates/prod-code-mcp/src/fixture.rs:655:46)
The function is used as a value, not called, so there is no argument list to rewrite. Two independent mechanisms noticed — the reconciliation by counting, the type check by type-checking — and both point at the same line.
An earlier version matched references by line proximity, and that case slipped through it: the
reference sat one line above an assert_eq! that was rewritten, so “something near here
changed” read as done. Proximity is not a mechanism, it is a hope.
It will not drop a parameter the body still uses. Not with a warning — with the usages:
$ prod-code change-signature LspSession::open_text --param file
Error: these parameters are still used by the body:
`text` is used 2 time(s): crates/prod-code-mcp/src/session.rs:226:51,
crates/prod-code-mcp/src/session.rs:237:29
pass `force: true` to remove them anyway and fix the body afterwards
It will not write something that does not compile. The declaration and every rewritten file
are type-checked together, because a change like this is exactly the kind that is valid file by
file and wrong as a whole. Add a parameter whose call-site expression has the wrong type, and
every rewritten call is now passing an i32 where the new signature wants a bool:
$ prod-code change-signature fixture::snake_case --param name --param "upper: bool = 0" --apply
Error: the change does not compile (6 error(s)); nothing was written. Fix the request, or pass
`force: true` to write it anyway:
expected bool, found i32 [E0308] (crates/prod-code-mcp/src/fixture.rs:621:32)
expected bool, found i32 [E0308] (crates/prod-code-mcp/src/fixture.rs:623:32)
…
--apply was on. Nothing was written.
What it does not do
- Rust only, because the structural engine is rust-analyzer’s.
- Renaming a parameter is
code_rename, which already does it correctly including the body. Two tools, one job each. - The return type is out of scope. Changing it means changing what every caller does with the value, which is a different problem wearing the same hat.
- One function per call. Chaining two signature changes means two calls, and each pays the usage search again.
- Formatting is left to the formatter. The new list keeps the old one’s shape — one line, or
one parameter per line with its indentation — which is close enough that
cargo fmthas little to do, and far enough that you should still run it.
What it costs
Wall clock from a laptop against a warm gateway:
| add a parameter — 1 file, 6 call sites | 0.38 s |
| reorder a method’s parameters — 3 files | 1.2 s |
| reorder a free function’s parameters — 18 lines in 6 files | 4.2 s |
The last row is worth explaining rather than hiding. The gateway’s own metrics for that session
say where it goes: prodCode/structuralReplace ran 19 times, p50 398 ms, p95 1 320 ms;
the overlay type check (diagnostic) ran 56 times, p50 113 ms, p95 1 436 ms. The type
check runs three times as often as the search because everything else in the same session —
validated edits, generated fixtures — goes through the same overlay, and because within a
signature change it is one call per changed file. Six changed files at the median check is about
seven tenths of those 4.2 seconds; the rest is the search and the reference walk.
Both are the price of the two guarantees — call sites matched on the syntax tree with their paths resolved rather than on the text, and a result known to compile before it is written — so the cost scales with how big the changed files are, not with how many parameters moved. A tool that skipped both would answer instantly and leave you to find out later.
Cite this article
Alexander Panasenko (2026-09-30). The refactor no tool owns. https://prod.codes/blog/the-refactor-no-tool-owns/