notes · · 5 min

The caller nobody edited

A declaration edit can leave call text unchanged while breaking type inference. The original generify report illustrates why callers still need checking, and why failed caller discovery must stop the plan.

On this page · 4 sections
  1. The body is checked first
  2. The callers do not change, and are checked anyway
  3. What it costs, and how it is pinned
  4. Limits of that revision

A function that takes &Vec<u32> and only reads it as a slice is asking for more than it needs. fn total<T: AsRef<[u32]>>(v: &T) takes a Vec, an array, a boxed slice - anything that can lend a [u32]. The refactoring entered this project’s catalog as generify in PR #118, merged on September 23, 2026. In the probe recorded in issue #117, rust-analyzer offered documentation templates, signature type aliases and inlining at the function’s name. At the type it offered unwrap_type_to_generic_arg, which turns Vec<u32> into u32 - a different change. That is a result at the tested positions, not a claim about every rust-analyzer version or context.

This account follows the Rust implementation at revision 951b70e8. The run outcomes and timings below are reported in PR #118; this review checked source and test definitions, without independently authenticating or repeating those historical runs. The tool subsequently changed, including a correction to caller discovery described below.

The initial code_generify implementation takes the function, the parameter and the bound:

$ prod-code generify total --param v --bound 'AsRef<[u32]>'

The declaration is text: the parameter’s type becomes T, a reference in front of it stays (&Vec<u32> becomes &T, not T), and <T: AsRef<[u32]>> goes after the name - or at the end of the generics the function already has. A name already in use is refused, and so is a parameter whose type is abstract to begin with. These illustrative examples use the refusal messages from the pinned source; the pick and show sessions are not backed by a separate execution record in the material reviewed:

$ prod-code generify pick --param b --bound 'Into<String>'
Error: `pick` already has a generic parameter `T`; pass another `type_param`
$ prod-code generify pick --param b --bound 'Into<String>' --as U
…
- now: `fn pick<T: Clone, U: Into<String>>(a: T, b: U)`
$ prod-code generify show --param x --bound Clone
Error: `x: impl std::fmt::Display` is already abstract

The bound is not guessed: which trait a function should ask for is a design decision, and the text of its body does not say.

The body is checked first

PR #118 reports a scratch-crate run where total was still written for a Vec. The following is an abridged illustration of that report, with the renderer’s explanatory sentence expanded from the pinned source:

`total` (src/lib.rs)

- was: `fn total(v: &Vec<u32>)`
- now: `fn total<T: AsRef<[u32]>>(v: &T)`
- 1 file(s) that call it checked against the new signature; their calls do not change, the type argument is inferred

the analyzer rejects the result — the body uses something the bound does not promise, or a caller passes a type that does not satisfy it or can no longer be inferred:
  no method `iter` on type `&T` [E0599] (src/lib.rs:4:7)

nothing was written; pass `apply: true` to make this edit

v.iter() worked because v was a Vec. T promises only as_ref(), so the body becomes v.as_ref().iter().sum(); PR #118 reports acceptance after that edit. That half is expected. The other half is the reason this post exists.

The callers do not change, and are checked anyway

The declaration edit leaves call sites untouched: total(&days) stays total(&days), and the compiler works out T from what is passed. So a tool could rewrite the declaration, check the one file it touched, and stop. It would be wrong about one kind of caller:

Illustration of label(name: String) becoming label<D: Display>(name: D): an explicit String still supplies D, formatting uses Display, and the PR reports an inference error for label("all".into()). Only discovered reference files enter the overlay; the early revision could skip them after a failed lookup
Illustrative cases from PR #118. Call text stays unchanged, but inference can fail. The early implementation checked discovered reference files; a failed lookup could silently skip caller checking.

label(name: String) becoming label<D: Display>(name: D) - its body only formats the name - is the same change. The call label(week.len().to_string()) is fine: D is String. The call label("all".into()) is not. It compiled because .into() took its target type from the parameter: String. Under D there is no target; &str converts into many things, and nothing says which. PR #118 reports the following refusal for the unedited call (abridged):

Error: the change does not compile (1 error(s)); nothing was written. …
  type annotations needed [E0282] (src/report.rs:6:45)

The report attributes that diagnostic to the analyzer. In the pinned code, files returned by the references query go into the same overlay as the new declaration for diagnostics. There was a gap: references(...).await.unwrap_or_default() treated a failed query as an empty caller list and continued. A clean result could therefore omit the callers entirely. Issue #446 documented this failure mode; PR #468, merged September 27, changed this path to refuse when the required query fails. That repairs error handling; it does not prove that a language server discovers every reference.

PR #118 also reports forcing the original change through and running cargo check on a build node. According to that account, the compiler rejected the call with error[E0283]: type annotations needed: cannot infer type of the type parameter `D` declared on the function `label`, at the start of the same call.

PR #118 reports the opposite case too: let days = vec![3, 5, 8]; still compiled when passed to total(&days) after the parameter became generic. The concrete Vec<u32> parameter was gone, but T: AsRef<[u32]> still named the required slice element type. According to the PR, days was inferred as Vec<u32> and both the analyzer and cargo check accepted it. This is an author-reported check result, not a new compiler run. The contrast explains why caller validation matters even when the edit only changes a declaration - and why that validation depends on finding the relevant files.

What it costs, and how it is pinned

PR #118’s Measurements section reports 9.99 seconds for the first dry run on the scratch crate and 0.20 to 0.29 seconds for the next four. It describes the first run as the node’s first sight of the crate. Those are reported timings, not an independently verified benchmark or a measurement that isolates mirroring, loading and analysis costs.

The orchestration test covers a reference parameter, existing generics, a name already in use, a missing parameter, and a caller whose file is clean on disk but rejected in the proposal. Its gateway supplies scripted references and diagnostics, including the caller error. The assertions check that the error is reported and the declaration remains unchanged; this fixture does not itself prove rust-analyzer’s real inference behavior.

The PR’s Sabotage check section says that emptying the additional caller-file list made the test fail: the write went through with applied: true. That is the author’s mutation-test report. The feature and this test arrived in the same feature commit; their presence in source is not a separate receipt for the reported successful run or sabotage experiment.

Limits of that revision

  • Choose the bound. It is given. The methods used in a body may suggest a bound, but do not decide the API contract the function should expose.
  • Edit callers. A caller that no longer compiles is reported, not fixed: "all".into() could become "all".to_string() or String::from("all"), and neither is the tool’s to pick.
  • One parameter at a time. Two parameters of the same type become two runs, the second with --as U, and two type parameters, not one shared T.
  • impl Trait and dyn Trait. A parameter whose type is already abstract is refused, as above.
  • Rust only at this revision. PR #715 added implementations for other languages on September 29. Later changes, including 20700937, changed caller-file validation and C/C++ refusal rules in that path. This post describes the initial Rust design, not the current cross-language support contract.
Cite this article
Citation
Alexander Panasenko (2026-10-18). The caller nobody edited. https://prod.codes/blog/the-caller-nobody-edited/