cd /news/developer-tools/the-diff-that-was-empty · home › topics › developer-tools › article
[ARTICLE · art-165216] src=prod.codes ↗ pub= topic=developer-tools verified=true sentiment=· neutral

The diff that was empty

A Rust refactoring tool in the prod-code project gained a `convert_to_method` transformation, merged as PR #126 on September 23, 2026 at revision 745effa9 in response to issue #120, that rewrites a function's first parameter into `self`, `&self` or `&mut self` and converts calls like `Counter::bump(&mut c, 2)` into `c.bump(2)`. The same work surfaced a bug in which every write tool in the project reported an empty diff after applying an edit because the old text was read after being overwritten; the fix shipped at revision 2cbb101b as PR #127 the same day for issue #122. Both parts were later changed by PR #444 on September 26, 2026, which reworked how workspace edits are applied and undone, and PR #703 on September 29, 2026, which extended the refactoring to other languages.

by read7 min views1 publishedOct 21, 2026
The diff that was empty
Image: source

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 is code_move ’s job.
  • Receivers of other types.fn foo(bar: &Bar) inimpl Counter does 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 addedconvert_to_method for 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/
── more in #developer-tools 4 stories · sorted by recency
── more on @prod-code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-diff-that-was-em…] indexed:0 read:7min 2026-10-21 · —