{"slug": "the-diff-that-was-empty", "title": "The diff that was empty", "summary": "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.", "body_md": "# The diff that was empty\n\nTurning 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.\n\n## On this page · 5 sections\n\n[Part 26](https://prod.codes/blog/the-receiver-that-did-something) made a method static: a method that never uses\n`self` loses its receiver, and `s.zero()` becomes `S::zero()`. That direction had one trap, a\nreceiver expression that does something when it is evaluated, and dropping it changes the\nprogram. The catalog entry names both directions, and this post is about the other one:\n`convert_to_method`. It is the easier half. It also turned up a bug in every write tool this\nproject has.\n\nThis 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.\n\n## The easier half\n\n`Counter::bump(&mut c, 2)` becomes `c.bump(2)`. Nothing a caller evaluates is dropped\nhere. The first argument\nbecomes the receiver, and a receiver is evaluated first, as the first argument was, so there is\nno evaluation order to get wrong. In the probe recorded in issue #120, rust-analyzer offered\ninlining and documentation and type-alias templates at the function’s name, and\n`destructure_struct_binding` at the parameter, which is a different refactoring; neither performed\nthis conversion at those two positions. So `code_convert_to_method` does it, and every reference to\nthe function takes one of four shapes:\n\n**The declaration.** The first parameter becomes the receiver, and its type decides which one.\n`c: Counter` becomes `self`, `&Counter` becomes `&self`, `&mut Counter` becomes `&mut self`, and\nthe same holds for `Self`. A first parameter of any other type is refused with its name, and so\nis a function in a trait `impl`, because the trait decides whether it takes `self`. A free\nfunction is refused too: it has to be moved into an `impl` first.\n\n**The body.** Each use of the parameter becomes `self`. The uses come from the analyzer’s\nreferences, not from searching the text for `c`, which would also find the `c` in `c_count`, in a\nstring or in a shadowing `let c`.\n\n**The calls.** `Type::f(first, rest)` becomes `first.f(rest)`, with any path in front of it:\n`crate::m::Counter::`, `Self::`. A leading `&` or `&mut` goes, because method syntax takes the\nborrow itself: `Counter::bump(&mut items[0], 3)` becomes `items[0].bump(3)`. Anything that is not\na path, an index or a call chain is parenthesized, so `*boxed` becomes `(*boxed)`.\n\n**What stays.** `let f = Counter::peek;` is the function used as a value. A method is still\nreachable by its path, so it compiles as it is and is listed, not touched. A recursive call inside\nthe function itself stays a path call too, `Counter::bump(self, by - 10)`. It is valid, and\nrewriting it at the same time as its argument is renamed to `self` would be two edits at one\nposition.\n\nOn the scratch crate from issue #120, in the form the renderer prints it (PR #126 quotes the\napplied report without its first lines, and notes that it said `0 changed line(s)`):\n\n``` bash\n$ prod-code convert-to-method src/lib.rs --line 11 --character 12 --verify compile --apply\n`Counter::peek` (src/lib.rs)\n\n- the parameter `c: &Counter` becomes the receiver `&self`; 1 use(s) of it in the body are now `self`\n- 1 call site(s) now call it as a method\n\n0 changed line(s) in 1 file(s)\n\nleft as they are, and still valid (1):\n  src/lib.rs:23:22 (the function used as a value: a method is still reachable by its path)\n\nthe analyzer accepts the result: 0 errors\n\n[applied to 1 file(s)]\n\nthe compiler accepts the result too: `cargo check` in a shadow of the workspace, 75 ms\n```\n\nThe timing is the one PR #126 reports for that run: one compile-gated apply, not a benchmark.\nRead `0 changed line(s)` again.\n\n## Zero changed lines\n\nThe file was changed. `git diff` showed `peek(&self)`, `self.n`, and `Counter::peek(&c)` rewritten\nto `c.peek()`. The report said nothing had changed, and showed no hunks.\n\nThe report builds its diff from two texts: the new text the tool computed, and the old text,\nwhich it read from disk when the report was rendered. The report is rendered after `apply`, and\nby then the file on disk holds the new text. So the diff compared the new text with itself. The\ndry run, rendered before anything is written, was correct, which is why every test that looked\nat a diff passed: they all looked at dry runs.\n\nIt was not this tool’s bug. The same line was in `make_static`, `invert_boolean`, `wrap_return`,\n`move`, `extract_field` and the rest, twelve renderers in all, as issue #122 lists them:\n\n``` bash\n$ prod-code make-static src/lib.rs --line 4 --character 12 --apply\n- the receiver `&self` is removed: it was never used\n- 1 call site(s) now call `S::zero`\n0 changed line(s) in 1 file(s)\n[applied to 1 file(s)]\n```\n\nThis 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.\n\n## The fix, applied with the tool\n\nEvery write tool writes through one function, `apply_workspace_edit`. That function already\nsnapshots every file it touches before it writes, so it can put everything back if a write fails\nhalfway. Now it also keeps the snapshot after a successful write, together with the bytes it\nwrote. `text_before_apply(path)` returns the old text while the file still holds exactly those\nbytes. If anything has changed the file since, it returns what is on disk.\n\nMoving the twelve renderers onto it was a structural replace, run with this project’s own\n`codemod`. The first pattern was too wide:\n\n``` js\nstd::fs::read_to_string($p).unwrap_or_default()\n  ==>> crate::refactor::text_before_apply(std::path::Path::new($p))\n```\n\nIt also matched a planning step that reads a file’s current text to compute an edit. That read\nmust see what is on disk. Pinning the pattern to the renderers’ `let old_text = …;` statement\nmatched eleven sites and nothing else, and one more run with `let old = …;` took the twelfth,\nin `schema.rs`. After the fix, the same `make-static --apply`:\n\n``` php\n4 changed line(s) in 1 file(s)\n-    pub fn zero(&self) -> u32 {\n+    pub fn zero() -> u32 {\n-    s.zero()\n+    S::zero()\n[applied to 1 file(s)]\n```\n\n## How both are pinned\n\nThe test for `convert_to_method` has, in one file: a recursive call, a path call, a\n`crate::`-qualified call on `&mut items[0]`, the function used as a value, and the three\nrefusals. With the rule that leaves a call inside the function alone switched off, it fails on\nthe count: three calls rewritten, not two.\n\nThe diff is pinned in the `invert_boolean` test, which applies its edit. Since PR #127 it also\nrenders the report and looks for the old and new signatures in it. With `text_before_apply` made to\nignore its snapshot, that assertion fails. Before that change no test in the suite looked at a report\nrendered after `apply`, which is why none of them caught this.\n\n## What it does not do\n\n- **Move a function into an `impl`.** A free function is refused. Moving it first is` code_move` ’s job.\n- **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.\n- **Rust only, at this revision.**[PR #703](https://github.com/alex09x/prod-code/pull/703) of\nSeptember 29, 2026 added`convert_to_method` for TypeScript and JavaScript, Python, C++, Swift\nand Go; what it does there is not covered here.\n\n## Cite this article\n\n```\nAlexander Panasenko (2026-10-21). The diff that was empty. https://prod.codes/blog/the-diff-that-was-empty/\n```\n\n", "url": "https://wpnews.pro/news/the-diff-that-was-empty", "canonical_source": "https://prod.codes/blog/the-diff-that-was-empty/", "published_at": "2026-10-21 00:00:00+00:00", "updated_at": "2026-11-06 08:51:34.148305+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["prod-code", "rust-analyzer", "PR #126", "PR #127", "PR #444", "PR #703", "issue #120", "issue #122"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/the-diff-that-was-empty", "markdown": "https://wpnews.pro/news/the-diff-that-was-empty.md", "text": "https://wpnews.pro/news/the-diff-that-was-empty.txt", "jsonld": "https://wpnews.pro/news/the-diff-that-was-empty.jsonld"}}