notes · · 5 min

The callers a return-type assist leaves behind

A September rust-analyzer probe wrapped a function’s return type without updating its callers. The first prod-code implementation added propagation candidates and a report of unresolved calls. Its limits show why the caller side needs a separate check.

On this page · 4 sections
  1. What the assist does
  2. One question per caller
  3. Positions that moved
  4. Cost and limits

This post describes the Rust implementation introduced on September 23, 2026 in PR #110, at source revision 0517151c. The development checks and timings below are reported in the linked PRs; they were not independently reproduced for this post. Later versions changed this tool.

The roadmap for this project listed five refactorings as “probed and not offered”: point rust-analyzer at the obvious place and nothing comes back. It also said the probe might have missed a trigger. PR #108 records a second probe, with one example of each shape in a scratch crate: two of the five were offered. Selected output follows; other assists and probe cases are omitted, and the labels are shortened:

== line 2 col 5 (an `if let` that ends the function)
convert_to_guarded_return  [RefactorRewrite]  Convert to guarded return
== line 21 col 18 (the return type of `pub fn plain() -> u32`)
wrap_return_type_in_option  [RefactorRewrite]  Wrap return type in Option
wrap_return_type_in_result  [RefactorRewrite]  Wrap return type in Result

The first was recorded as reachable through the assists tool. The second was half done in that probe, and it is the more instructive half to look at.

What the assist does

-pub fn plain() -> u32 {
-    5
+pub fn plain() -> Option<u32> {
+    Some(5)

In the reported example, the signature and returned value change, but the callers do not. A caller adding the result of plain to a u32 now has a type error: it gets an Option<u32>. A caller that discards the result need not break; the assist leaving callers untouched is the problem. The Result version is worse in a small way - it writes Result<u32, _>, a placeholder for an error type nobody has chosen yet.

On the left, rust-analyzer's assist rewrites fn plain returning u32 into fn plain returning Option of u32 and wraps the returned value in Some, leaving every caller as it was. On the right, the callers one by one: fn maybe, which returns an Option, gets plain()? ; fn caller, which returns a u32, is reported, not rewritten; a recursive call inside the function itself is left for a person. Written wrapper names select candidates for a question mark; this is a heuristic that still needs review
The September implementation proposes changes for selected callers. Wrapper-name matching is a heuristic; closures and unmatched references need review.

One question per caller

For each call found in the analyzer’s reference list, the implementation examines the calling function’s written return type.

If the written type name ends in Option for an Option wrapper, or Result for a Result wrapper, the tool proposes a ? after the call. This is a textual test, not full type resolution. In the simple example, ? unwraps Some or Ok; None or an error propagates from the enclosing function. That does not prove preservation of every program’s behaviour. For a Result, the caller’s error type must support conversion from the new error type. The tool sends proposed texts to an analyzer overlay and reports remaining error diagnostics; it does not establish that every invalid conversion will be detected. Analyzer acceptance is not compiler proof. The shared verify: "compile" gate was a separate option.

If it returns anything else, there is no mechanical answer. The call used to produce a u32; now it may produce nothing, and what the caller should do then - use a default, panic, return early from something that cannot return early, change its own signature too - is exactly the decision the wrapping was meant to force. So code_wrap_return does not choose that recovery policy. It names the caller, the type it returns, and the line. This abridged historical example omits diff headers, the changed-line count and the final apply instruction:

`plain` (src/lib.rs)

- returned: `u32`
- now returns: `Option<u32>`
- 1 call site(s) propagate with `?`

-    Some(plain() + 1)
+    Some(plain()? + 1)

1 call site(s) cannot propagate: the calling function does not return an `Option`. Each needs a decision - unwrap, match, or wrap that caller too:
  src/lib.rs:42:37 the caller returns `u32`: `s.uses_self() + S::no_self(2) + plain()`

the analyzer rejects the result:
  the trait bound `u32: Add<Option<u32>>` is not satisfied [E0277] (src/lib.rs:42:5)

nothing was written

PR #110 reports that the analyzer’s complaints in this example concerned the blocked caller. A dry run writes nothing regardless of diagnostics. On an apply request, this revision refuses if its blocked-caller list or remaining analyzer errors are nonempty, unless force is set; forcing permits the write despite those findings. For Result, the _ in the signature becomes the error type the caller of the tool gives, and without one the tool asks for it instead of writing a placeholder into the code.

Positions that moved

The implementation starts from positions returned by the analyzer. Callers in the file that declares the function are harder, because the assist has just rewritten that file: every position after the function has moved by however much the rewrite added. The rewrite only touches the function’s own span, so a position before it means the same thing it did, and a position after it moves by the difference in length.

For a reference to the selected function, a position inside its own span is treated as a recursive call. PR #110 reports that the tool’s test caught it being handled wrongly on its first run; the available source commit contains the feature and test together, not a separate execution record establishing that chronology. The test’s function calls itself - count(n - 1) + 1 - and the tool reported that call as a caller that “returns u32”, which was true of the file on disk and false of the file being written, where count now returns an Option. The recursive call sits inside the span the assist rewrote, so it is now reported as what it is: a call inside the function, for a person to finish. The PR also reports that removing the shift for positions after the function makes the same test fail with the ? in the wrong place. That mutation-check result is author-reported; we have not rerun it.

Cost and limits

PR #110 reports two warm dry runs on the scratch crate taking 0.25 and 0.23 seconds. The cost that matters is elsewhere: every caller the tool cannot rewrite is a decision someone has to make, and the report is built to make that list short to read - one line per caller, with the type that stands in the way.

The limits below belong to that September revision:

  • It does not choose for a caller. No unwrap, no unwrap_or_default, no match written on anyone’s behalf.
  • Closures are not modeled separately. A call inside a closure is judged by the enclosing function’s header. A proposed ? there may be wrong; the overlay may report an error, but this heuristic does not itself prevent an incorrect proposal.
  • The reference list is not a completeness guarantee. This revision turns a failed reference query into an empty list. Recursive calls and non-call references are reported as unmatched; that list alone does not block an apply request. They need manual review even if diagnostics are empty.
  • Rust only at that revision. Later changes added other languages (PR #713) and custom envelopes (#727); empty-reference fallback was later revised in #985 and #1027. This post is not a current capability or safety assessment of those later implementations.
Cite this article
Citation
Alexander Panasenko (2026-10-13). The callers a return-type assist leaves behind. https://prod.codes/blog/the-half-that-breaks-every-caller/