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
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:
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()orString::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 sharedT. impl Traitanddyn 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
Alexander Panasenko (2026-10-18). The caller nobody edited. https://prod.codes/blog/the-caller-nobody-edited/