# The diff that was empty

> Source: <https://prod.codes/blog/the-diff-that-was-empty/>
> Published: 2026-10-21 00:00:00+00:00

# 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](https://prod.codes/blog/the-receiver-that-did-something) 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`](https://github.com/alex09x/prod-code/blob/745effa9de471714c9c3727a1ce9104c70ea2e1c/crates/prod-code-mcp/src/to_method.rs), merged as [PR #126](https://github.com/alex09x/prod-code/pull/126) on September 23, 2026 from the request in [issue #120](https://github.com/alex09x/prod-code/issues/120), and the fix for the empty diff at revision [`2cbb101b`](https://github.com/alex09x/prod-code/blob/2cbb101bdb39c416c41e1914ab9b3cd3a5a08eec/crates/prod-code-mcp/src/refactor.rs), merged the same day as [PR #127](https://github.com/alex09x/prod-code/pull/127) for [issue #122](https://github.com/alex09x/prod-code/issues/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](https://github.com/alex09x/prod-code/pull/444) of September 26, 2026 reworked how workspace edits are applied and undone, and [PR #703](https://github.com/alex09x/prod-code/pull/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)`):

``` bash
$ 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:

``` bash
$ 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:

``` js
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`:

``` php
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](https://github.com/alex09x/prod-code/pull/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

```
Alexander Panasenko (2026-10-21). The diff that was empty. https://prod.codes/blog/the-diff-that-was-empty/
```


