notes · · 5 min

The refactoring that is mostly imports

Moving a function to another module is cut and paste and then an unbounded amount of fixing every file that imported it. Building the tool that does both, and the two bugs that made it quietly skip the second.

On this page · 5 sections
  1. One item, three kinds of file
  2. What it refuses, and why that is the useful part
  3. Two bugs that made it quietly do half the job
  4. What it does not do
  5. What it costs

Changing what a function takes was the refactoring no tool owns. Here is the other one. Ask rust-analyzer what it can do at a function declaration and it offers to inline the function, or to generate a type alias for it:

$ prod-code assists crates/prod-code-mcp/src/fixture.rs 73 8
inline_into_callers  [RefactorInline]  Inline into all callers
generate_fn_type_alias_named  [Generate]  Generate a type alias for function with named params
generate_fn_type_alias_unnamed  [Generate]  Generate a type alias for function with unnamed params

It does have moves - move_module_to_file, move_to_mod_rs, move_const_to_impl. They move a module between files, or a const into an impl. None of them takes this function and puts it in another module, which is what a person means by “move this”.

So it is done by hand. Cut the text, paste it, then find every file that mentioned the name and fix its use. The cut and paste is bounded work. The rest is not bounded by anything: it is however many files happened to import it, minus the ones you forget, which you find out about from the compiler three steps later.

One item, three kinds of file

code_move takes the declaration and the module it should end up in:

$ prod-code move snake_case --to crates/prod-code-mcp/src/lang.rs
`snake_case` moved

- from: crates/prod-code-mcp/src/fixture.rs (prod_code_mcp::fixture)
- to:   crates/prod-code-mcp/src/lang.rs (prod_code_mcp::lang)
- callers now import: `prod_code_mcp::lang::snake_case`
- 15 line(s) moved

32 changed line(s) in 2 file(s)

--- a/crates/prod-code-mcp/src/fixture.rs
-/// `SliceReport` -> `slice_report`.
-pub fn snake_case(name: &str) -> String {
…
+use crate::lang::snake_case;

imports:
  crates/prod-code-mcp/src/fixture.rs: added `use crate::lang::snake_case;`

the analyzer accepts the result: 0 errors

nothing was written; pass `apply: true` to make these edits

Fifteen lines moved, and the interesting part is the one line added back. The file the function left still calls it six times, so that file now imports it - which is exactly the edit a person forgets, because the item used to be right there.

One declaration at the top, leaving its module with its doc comment and attributes. Three boxes below it: the module it left, which now imports it for its own calls; the new home, which receives the item plus the imports the item spells; and every other user, where a bare name gains the new import and a path-qualified reference is requalified. Underneath, one wide box: all of it type-checked in one overlay before anything is written, and an item reaching for something private to the module it left is reported rather than written
Three kinds of file, four rules between them. The analyzer says where the symbol is used; the text decides what to write; the type check decides whether it may be written.

The three kinds of file are the whole design:

The new home gets the item whole - signature, body, doc comment, attributes - and the use statements the item actually spells, narrowed to the names it needs. A function that mentions Path and Result brings use std::path::Path; and use anyhow::Result;, and does not bring the Context that sat in the same use group. An import the target gains and does not use is a warning somebody has to delete later.

The module it left keeps everything else and, if it still calls the item, imports it.

Every other user is one of two cases, and they are not the same edit. A file that spelled the name bare - snake_case(x), with a use at the top - has its import rewritten, and a grouped import keeps its other names: use a::{B, snake_case, C}; becomes use a::{B, C}; plus the new line. A file that always qualified it - crate::fixture::snake_case(x) - has the path rewritten in place and needs no import at all. Adding one there would be the warning again.

What it refuses, and why that is the useful part

The item that moves is rarely self-contained. It calls a private helper, or names a private type, and both of those stay behind. That is not a failure of the tool; it is the shape of the problem, and the only question is whether you find out now or from a build:

$ prod-code move parse_shape --to crates/prod-code-mcp/src/lang.rs
…
the analyzer rejects the result:
  no such value in this scope [E0425] (crates/prod-code-mcp/src/lang.rs:60:25)
  type annotations needed [E0282] (crates/prod-code-mcp/src/lang.rs:62:22)
  no such value in this scope [E0425] (crates/prod-code-mcp/src/lang.rs:62:26)
  …

names it cannot see from `prod_code_mcp::lang`: the item used something private to
`prod_code_mcp::fixture`. Move that too, or widen it to `pub(crate)`, and run this again.

nothing was written; pass `apply: true` to make these edits

parse_shape calls strip_visibility and split_top_level, both private to fixture. The analyzer produces ten diagnostics for that, which are ten ways of saying one thing, so the report says the one thing underneath them.

The check itself is the same overlay every write tool in this series uses: the changed files are judged together, in memory, before anything is written, and --apply on a result that does not compile refuses.

Two bugs that made it quietly do half the job

Both were found by running it on the repository that contains it, and both are the kind that produce a confident, wrong, plausible answer rather than an error.

The cut tidied up. Removing an item from between two blank lines leaves two blank lines where there was one, so the first version collapsed them. It looked like good manners. But every position the analyzer reported was measured against the file before the cut, and the code adjusts them by the number of lines the item occupied - and now the number of lines removed was one more than that. Every reference below the hole was looked for one line too low, matched nothing, and the tool reported a move with no imports at all. It compiled nowhere, and it said so only because the type check ran afterwards.

The fix is to cut exactly the item’s lines and leave the blank line for the formatter. The lesson is narrower than “be careful”: a function whose result another function does arithmetic on cannot also be the function that tidies up. The doc comment on it now says so.

An import inside a function body counted as one of the file’s imports. The scan for use statements checked that the statement began a line, after trimming the whitespace - and a use std::io::Write; inside a function body begins its line too. So the new import was inserted after the last use the scan found, which was inside that body:

) -> Result<()> {
    use std::io::Write;
use prod_code_mcp::lang::uri_to_path;      // ← where it landed
    let cwd = env::current_dir()?;

Requiring column zero fixes it. What is worth keeping is why the bug was invisible until it was in a diff: the tool’s own report showed the import as added, in the right file, with the right path. Every fact in the report was true. Only the position was wrong, and a report has no position.

What it does not do

  • It does not create the target module. The file must exist and be declared by its parent. Creating a module means writing a mod line into a file the move was never asked to touch.
  • Only the ordinary crate layout. src/a.rs and src/a/mod.rs are both the module a; a file reached through #[path] is named as left alone rather than guessed at.
  • An item at a time, not a file, not a module, and not a method onto another type - that last one is a different refactoring wearing the same verb.
  • Rust only, and re-run your formatter afterwards: the item arrives at the end of its new file, which is where it goes, not where it belongs.

What it costs

One documentSymbol, one references, and one overlay type check covering every changed file - the same overlay as change signature, though not the same bill: that one spends most of its time in a structural replace, and this one runs none. The move above takes 0.6-0.9 s against a warm gateway.

The comparison worth making is not against doing it by hand quickly. It is against doing it by hand and being wrong in one file, which costs a build, a diagnostic, a context switch, and the small tax of wondering what else you missed.

Cite this article
Citation
Alexander Panasenko (2026-10-03). The refactoring that is mostly imports. https://prod.codes/blog/the-refactoring-that-is-mostly-imports/