The diff that was empty
Turning an associated function into a method is the mirror of making a method static, and the easier half. Writing it turned up something else: every write tool in the project reported an empty diff once it had applied its edit, because the old text was read after it was overwritten.
On this page · 5 sections
Part 26 made a method static: a method that never uses
self loses its receiver, and s.zero() becomes S::zero(). That direction had one trap, a
receiver expression that does something when it is evaluated, and dropping it changes the
program. The catalog entry names both directions, and this post is about the other one:
convert_to_method. It is the easier half. It also turned up a bug in every write tool this
project has.
This account follows the Rust implementation at revision 745effa9, merged as PR #126 on September 23, 2026 from the request in issue #120, and the fix for the empty diff at revision 2cbb101b, merged the same day as PR #127 for issue #122. The tool output, cargo check results and timings below are the ones reported in those two PRs; none of them was repeated for this post. Both parts of the tool changed afterwards: PR #444 of September 26, 2026 reworked how workspace edits are applied and undone, and PR #703 of September 29, 2026 extended the refactoring to other languages.
The easier half
Counter::bump(&mut c, 2) becomes c.bump(2). Nothing a caller evaluates is dropped
here. The first argument
becomes the receiver, and a receiver is evaluated first, as the first argument was, so there is
no evaluation order to get wrong. In the probe recorded in issue #120, rust-analyzer offered
inlining and documentation and type-alias templates at the function’s name, and
destructure_struct_binding at the parameter, which is a different refactoring; neither performed
this conversion at those two positions. So code_convert_to_method does it, and every reference to
the function takes one of four shapes:
The declaration. The first parameter becomes the receiver, and its type decides which one.
c: Counter becomes self, &Counter becomes &self, &mut Counter becomes &mut self, and
the same holds for Self. A first parameter of any other type is refused with its name, and so
is a function in a trait impl, because the trait decides whether it takes self. A free
function is refused too: it has to be moved into an impl first.
The body. Each use of the parameter becomes self. The uses come from the analyzer’s
references, not from searching the text for c, which would also find the c in c_count, in a
string or in a shadowing let c.
The calls. Type::f(first, rest) becomes first.f(rest), with any path in front of it:
crate::m::Counter::, Self::. A leading & or &mut goes, because method syntax takes the
borrow itself: Counter::bump(&mut items[0], 3) becomes items[0].bump(3). Anything that is not
a path, an index or a call chain is parenthesized, so *boxed becomes (*boxed).
What stays. let f = Counter::peek; is the function used as a value. A method is still
reachable by its path, so it compiles as it is and is listed, not touched. A recursive call inside
the function itself stays a path call too, Counter::bump(self, by - 10). It is valid, and
rewriting it at the same time as its argument is renamed to self would be two edits at one
position.
On the scratch crate from issue #120, in the form the renderer prints it (PR #126 quotes the
applied report without its first lines, and notes that it said 0 changed line(s)):
$ prod-code convert-to-method src/lib.rs --line 11 --character 12 --verify compile --apply
`Counter::peek` (src/lib.rs)
- the parameter `c: &Counter` becomes the receiver `&self`; 1 use(s) of it in the body are now `self`
- 1 call site(s) now call it as a method
0 changed line(s) in 1 file(s)
left as they are, and still valid (1):
src/lib.rs:23:22 (the function used as a value: a method is still reachable by its path)
the analyzer accepts the result: 0 errors
[applied to 1 file(s)]
the compiler accepts the result too: `cargo check` in a shadow of the workspace, 75 ms
The timing is the one PR #126 reports for that run: one compile-gated apply, not a benchmark.
Read 0 changed line(s) again.
Zero changed lines
The file was changed. git diff showed peek(&self), self.n, and Counter::peek(&c) rewritten
to c.peek(). The report said nothing had changed, and showed no hunks.
The report builds its diff from two texts: the new text the tool computed, and the old text,
which it read from disk when the report was rendered. The report is rendered after apply, and
by then the file on disk holds the new text. So the diff compared the new text with itself. The
dry run, rendered before anything is written, was correct, which is why every test that looked
at a diff passed: they all looked at dry runs.
It was not this tool’s bug. The same line was in make_static, invert_boolean, wrap_return,
move, extract_field and the rest, twelve renderers in all, as issue #122 lists them:
$ prod-code make-static src/lib.rs --line 4 --character 12 --apply
- the receiver `&self` is removed: it was never used
- 1 call site(s) now call `S::zero`
0 changed line(s) in 1 file(s)
[applied to 1 file(s)]
This matters more than it looks. When an agent applies directly, the diff is how it learns what was actually written. Every applied refactoring this project has shipped told its agent that nothing had changed.
The fix, applied with the tool
Every write tool writes through one function, apply_workspace_edit. That function already
snapshots every file it touches before it writes, so it can put everything back if a write fails
halfway. Now it also keeps the snapshot after a successful write, together with the bytes it
wrote. text_before_apply(path) returns the old text while the file still holds exactly those
bytes. If anything has changed the file since, it returns what is on disk.
Moving the twelve renderers onto it was a structural replace, run with this project’s own
codemod. The first pattern was too wide:
std::fs::read_to_string($p).unwrap_or_default()
==>> crate::refactor::text_before_apply(std::path::Path::new($p))
It also matched a planning step that reads a file’s current text to compute an edit. That read
must see what is on disk. Pinning the pattern to the renderers’ let old_text = …; statement
matched eleven sites and nothing else, and one more run with let old = …; took the twelfth,
in schema.rs. After the fix, the same make-static --apply:
4 changed line(s) in 1 file(s)
- pub fn zero(&self) -> u32 {
+ pub fn zero() -> u32 {
- s.zero()
+ S::zero()
[applied to 1 file(s)]
How both are pinned
The test for convert_to_method has, in one file: a recursive call, a path call, a
crate::-qualified call on &mut items[0], the function used as a value, and the three
refusals. With the rule that leaves a call inside the function alone switched off, it fails on
the count: three calls rewritten, not two.
The diff is pinned in the invert_boolean test, which applies its edit. Since PR #127 it also
renders the report and looks for the old and new signatures in it. With text_before_apply made to
ignore its snapshot, that assertion fails. Before that change no test in the suite looked at a report
rendered after apply, which is why none of them caught this.
What it does not do
- Move a function into an
impl. A free function is refused. Moving it first iscode_move’s job. - Receivers of other types.
fn foo(bar: &Bar)inimpl Counterdoes not become a method ofBar. That is a move and a conversion at once, and it is not built. - Rust only, at this revision. PR #703 of
September 29, 2026 added
convert_to_methodfor TypeScript and JavaScript, Python, C++, Swift and Go; what it does there is not covered here.
Cite this article
Alexander Panasenko (2026-10-21). The diff that was empty. https://prod.codes/blog/the-diff-that-was-empty/