notes · · 5 min

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
  1. The easier half
  2. Zero changed lines
  3. The fix, applied with the tool
  4. How both are pinned
  5. What it does not do

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:

Four shapes. fn bump(c: &mut Counter, by: u32) becomes fn bump(&mut self, by: u32): the parameter is the receiver. Counter::bump(&mut c, 2) becomes c.bump(2): method syntax takes the borrow. let f = Counter::peek; is unchanged: a method is still reachable by its path. Counter::bump(c, by - 10) inside bump becomes Counter::bump(self, by - 10): a call inside the function itself stays a path call. Nothing a caller evaluates is dropped or reordered
One function made a method; each reference keeps what it did.

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 is code_move’s job.
  • Receivers of other types. fn foo(bar: &Bar) in impl Counter does not become a method of Bar. 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_method for TypeScript and JavaScript, Python, C++, Swift and Go; what it does there is not covered here.
Cite this article
Citation
Alexander Panasenko (2026-10-21). The diff that was empty. https://prod.codes/blog/the-diff-that-was-empty/