notes · · 5 min

The receiver that did something

Removing an unused receiver can still remove an evaluation at the call site. A September prod-code implementation blocked selected receiver shapes; its textual rule shows why type checks alone cannot establish that a rewrite preserves behaviour.

On this page · 4 sections
  1. A receiver is an expression
  2. What the check does not see
  3. What it costs, and how it is pinned
  4. What it does not do

A method whose body never reads self can look like a candidate for an associated function. settings.default_port() reads as if its result depended on settings, while Settings::default_port() does not take that argument. But changing the signature also changes how the receiver is evaluated, borrowed or moved. An unused parameter is not a proof that removing it preserves behaviour.

This post follows the Rust implementation in PR #112 and its immediate trait-implementation correction in #114, both merged on September 23, 2026. The code discussed is pinned at c69c647b. The development output and timings are historical reports, not executions repeated for this post.

The probe recorded in PR #108 found no receiver-removal assist at the tested method positions. That is a result for those probes, not a claim about every rust-analyzer version or possible trigger. So code_make_static implemented a separate operation. Its declaration check looks for self as a whole identifier in the body text, then removes the receiver parameter if it finds no such mention. This textual check is not a semantic dependency analysis.

The two proposed call-site transformations look simple:

s.uses_self()            →  S::uses_self()
S::twice(s, 2)           →  S::twice(2)

The part that needs care is the expression being removed.

A receiver is an expression

In the simple borrowed-receiver fixture, s is a local name. A receiver can also be a more complicated expression, whose evaluation does work:

make().uses_self()
config.load()?.default_port()

Before the rewrite, make() runs and then the method is called. After a textual rewrite, S::uses_self() runs and make() never does. If make opens a file, increments a counter or takes a lock, or if load()? would have returned early, removing that evaluation changes the program. The rewritten call may still type-check: a successful type check alone does not establish that these effects were preserved.

Three receiver shapes classified by the September implementation. The name s passes the textual filter in both method-call and path-call syntax, so a rewrite is proposed. The expression make() is flagged and left unchanged because its text contains parentheses. These classifications are not a proof of effect-free evaluation
The September text filter proposes two rewrites and flags the call expression. Passing that filter does not prove preservation of behaviour.

For each reference it recognizes as a call, this revision tests the receiver’s text for (, ?, !, .await or [. It flags those strings and proposes removal otherwise. This blocks some suspicious forms conservatively, including parentheses that need not denote an effectful call. It does not analyze all possible effects.

The following historical report is abridged: the changed-line count, diff headers and final apply instruction are omitted.

`S::uses_self` (src/lib.rs)

- the receiver `&self` is removed: it was never used
- 1 call site(s) now call `S::uses_self`

-    pub fn uses_self(&self) -> u32 {
+    pub fn uses_self() -> u32 {
-    s.uses_self() + S::no_self(2) + plain()
+    S::uses_self() + S::no_self(2) + plain()

1 call site(s) would drop a receiver that does something when it is evaluated; bind it to a variable first, or keep the method:
  src/lib.rs:54:12 `make()` is evaluated for what it does

the analyzer rejects the result:
  no method `uses_self` on type `S`, but an associated function with a similar name exists [E0599] (src/lib.rs:54:12)

nothing was written

PR #112 reports that the only analyzer error in this example concerned the untouched call. The excerpt is a dry run, so no write would occur even with an empty error list. On an apply request, the historical implementation refuses if its blocked list or remaining analyzer errors are nonempty, unless force is set. Its overlay check supplies analyzer diagnostics; verify: "compile" was a separate compiler-check option. Neither establishes behavioural equivalence.

An index such as items[0] is flagged: out-of-bounds indexing can panic, and removing that panic would change behaviour. But absence of the listed characters is not enough to establish that a receiver is harmless. Rust method calls can involve automatic dereferencing or borrowing. Removing a by-value receiver can also change ownership and destructor timing, even if the method body never reads self. The character test does not model those cases.

Binding make() to a local variable before the call is one possible manual edit, not a universal fix: the binding can change how long the value lives. The reviewer has to preserve the intended evaluation and lifetime, rather than only make the type error disappear.

What the check does not see

One thing the rewrite leaves behind is not an error: let s = S; above s.uses_self() may now be unused. With ordinary lint settings that may produce a warning; a project can make such warnings errors. The tool leaves the binding. Removing its initializer would raise the same evaluation question again, one line up.

The report is also limited by reference discovery. In this revision a failed reference query becomes an empty list. References that cannot be read as calls, including method values, are listed as unmatched. That list alone does not block an apply request; an empty diagnostic result is not proof that every call site was found or repaired.

What it costs, and how it is pinned

PR #112 reports two warm dry runs on a scratch crate taking 0.24 and 0.20 seconds. These two reported observations are not a general latency guarantee. Its orchestration test builds a fixture with the listed shapes - a method call, a path call with the receiver as its first argument, a receiver with effects (load().twice(3)), the method used as a value, and a second method that does use self - and asserts the proposed text and classifications. The test uses a scripted gateway that supplies references and no analyzer diagnostics; it is not an independent language-server execution. The PR reports a mutation check: making the effects rule answer “no” for everything caused the blocked-call assertion to fail because load() was dropped. We inspected the test source but did not rerun that check.

What it does not do

  • The other direction was added later that morning. The initial PR lacked convert_to_method; PR #126 added it at 04:36 UTC on September 23, 2026.
  • Trait methods need a separate boundary. Issue #113 reports finding the missing trait-implementation check while writing the original post. PR #114 then added a check for for in the enclosing impl header and a test for the demonstrated case. That is the specific textual check in this revision, not a full trait analysis.
  • Rust-only describes the historical implementation. PR #703 later added other languages. The October 4–5 UTC changes 30d4354d and 18091d24 revised call resolution and refusal on unmatched references. Those later implementations are outside this historical account’s correctness assessment.
Cite this article
Citation
Alexander Panasenko (2026-10-14). The receiver that did something. https://prod.codes/blog/the-receiver-that-did-something/