Skip to content

fix(rust): a call on a local reaches the method of the type it is declared with - #2715

Merged
colbymchenry merged 1 commit into
mainfrom
claude/rust-receiver-types
Oct 20, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/rust-receiver-types

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Problem

A Rust method call on a local variable or a parameter (opts.build()) was matched by the method's name unless one of two line patterns read its declaration. Those patterns scanned back through the function without block scope, and the let x = … pattern needed = right after the name, so on main it only matched annotated lets and parameters. Anything else went to the name strategies:

  • dioxus (fix(extraction): a build folder inside src/ is indexed for its Rust files too #2695's A/B): Commands::Build(opts) => opts.build() lost its link once the build module's BuildRequest::build shared the name. opts is the variant's field, CommandWithPlatformOverrides<build::BuildArgs>. Nine of the 17 calls in that match were unresolved and eight were linked by unique name.
  • Calls on std or dependency values linked to project namesakes. In tokio, 57 buf.put_slice(…) on a BytesMut went to ReadBuf::put_slice, 50 temp.path() on a NamedTempFile to DirEntry::path, and 37 cx.waker() on a &mut Context to CachedParkThread::waker. Elsewhere, dioxus's bundle_root.join(…) on a PathBuf went to Eval::join.
  • A wrapped or generic receiver linked nothing: server: Arc<Server>, r: impl Render and serializer: S with S: Serializer.
  • The unscoped scan took the type of a declaration that an inner binding shadows: fn f(srv: Server) { if let Some(srv) = spare() { srv.run() } }.

Fix

matchMethodCall types the receiver from the binding the call sees (matchRustTypedReceiverCall) and resolves the method on that type the way Rust does.

Bindings. The new src/resolution/rust-scopes.ts reads them from the file's text, with comments and string or char contents blanked and brackets paired. It covers parameters, closure parameters, let, if let / while let, let … else, for and match arms, each with its block scope. Every name a pattern can bind is bound, typed or not, so an inner binding hides the outer one.

Types. A binding's type is one of:

  • its written type: a parameter's, or a let's annotation;
  • the value it is built from: Type { … }, Type(…), Enum::Variant { … }, Type::f(…) read from f's signature (Self, Result<Self> through ? or .unwrap()), a free function's return type, Box/Rc/Arc::new(…), x.clone(), another local, or a literal;
  • the field a variant or struct pattern takes: Commands::Build(opts), Point { x, .. }, or Some/Ok/Err of a typed scrutinee.

Reading a type:

  • &, Box, Rc, Arc, Pin, Cow and the lock guards are looked through. Each one's own methods stay its own, from the one list fix(rust): a call through a Pin, Rc or Box self reaches the method Rust picks, inherent first #2701 started: arc.clone() is Arc's, pin.set(…) is Pin's, while Arc<SetOnce>'s set is SetOnce's.
  • A project impl on a std wrapper itself answers before what the wrapper holds, because Rust tries the wrapper first: impl Schedule for Arc<Handle>, or tokio's impl<P> AsyncWrite for Pin<P> for is_write_vectored(&self). Only the held type's methods that take self through that wrapper come before it (fn poll_flush(self: Pin<&mut Self>, …)). This is fix(rust): a call through a Pin, Rc or Box self reaches the method Rust picks, inherent first #2701's rule for self, applied to locals.
  • impl Trait, dyn Trait and a bounded generic (fn's and impl's generics and where clauses) are known by their traits.
  • Self is the enclosing impl's type.
  • A path, or a use of the name, picks among types sharing it. Where the project's modules can be walked along the path, fix(rust): a name a use brings in is the item of the module its path walks #2702's module walk names the item: tokio::runtime::Handle is the one runtime/mod.rs re-exports. Elsewhere the path's module names decide, and from a crate root the nearest module wins. use bindings are block-scoped: ripgrep's flag definitions each use … as Separator a different type. Otherwise the one type with the method wins: tokio's sync::Semaphore has try_acquire_owned, its batch_semaphore namesake does not.

The method, in Rust's order:

  1. The type's inherent method. Impls of specific instantiations (impl Overrides<BuildArgs>) are matched against the receiver's arguments, and an alias's impls are keyed by the alias's name (gleam's impl TypedStatement).
  2. A trait impl for the type. If several traits have the method, the one in scope at the call wins, and From<A>/From<B> pairs stay ambiguous.
  3. An impl of an alias of the type, for the instantiation it names: gleam's impl TypedConstant { fn type_() } for a Constant<Type> that a generic enum holds.
  4. A trait's provided method. A blanket impl applies only where the receiver meets its bounds, read against the type's files.
  5. Deref.

Trait-typed receivers reach the trait's own method, a supertrait's, or a blanket extension trait's.

Outside the project. A std, dependency or primitive type has no project methods, except an impl of a project trait in scope. The impl for &T wins on a reference. An impl whose body hands the call on to the type's own function of that name is skipped: axum's impl Listener for tokio::net::TcpListener calls Self::local_addr(self), and Rust picks tokio's inherent method first. A primitive's impls, which the index cannot address by type, reach the trait's method: gleam's impl Documentable for usize.

Unreadable bindings (a method-call value, a for variable, an untyped closure argument) leave the call to the name strategies, as before. Those strategies no longer take a project type from another declaration of the name, but a non-project type read there still rules a call out, as it did before.

Visibility. A trait's own methods carry no pub, so isVisibleAcrossFiles vetoed them outside the trait's module. It now lets them through only for refs the typed path resolved, so name guesses onto trait methods stay vetoed: letting them through generally linked axum's chained .with_state(…) calls to Handler::with_state.

Verification

  • __tests__/rust-typed-receivers.test.ts: 20 cases. On main's resolver 15 fail. The 5 that pass there guard regressions found in the A/B: the fallback and an outside-crate trait object left to the name strategies, the alias-keyed impl, a trait and a struct sharing a name, namesakes told apart by the method, and Arc<SetOnce>.
  • __tests__/rust-wrapped-self-calls.test.ts (fix(rust): a call through a Pin, Rc or Box self reaches the method Rust picks, inherent first #2701): one expectation changes. read.poll_read(…) on read: Pin<&mut R> with R: AsyncRead now links to AsyncRead::poll_read, the bound's method, where fix(rust): a call through a Pin, Rc or Box self reaches the method Rust picks, inherent first #2701 linked nothing. The test still checks that it is never the blanket impl AsyncRead for Pin<P>'s. Its is_write_vectored case, the blanket impl's &self method, is what the wrapper-first rule above keeps.
  • tsc --noEmit is clean.
  • Full suite (kernel built with scripts/build-kernel.sh, CODEGRAPH_KERNEL_EXPECT=1, --maxWorkers=4 --minWorkers=1): 659 files passed and 6 skipped; 7,885 tests passed and 82 skipped, none failed.

A/B on real repos

The arms are main f5158fa and this branch on it, with the same kernel. Each repo was indexed fresh with codegraph init -y, and calls edges were paired by source node, line, column and ref name. The triage ran on 505c355. #1921 and #2712 landed after it, and on each new base the branch changes the same call sites the same way. On f5158fa (#2713, #2714) the glob walk moved 28 more, all checked: dioxus's autofmt now types its AttributeValue variants through use dioxus_rsx::*, and tokio's test modules their Wheel and the async RwLock through use super::*. It changes no edge other than calls.

Repo added removed retargeted confidence only
axum 29 49 6 208
serde 289 8 90 115
ripgrep 516 19 56 271
clap 103 8 39 319
dioxus 391 236 54 1,029
tokio 875 416 158 910
gleam 2,442 151 56 817

The controls are byte-identical: gin (Go), serilog (C#), flask (Python) and express (JS). A copy of the branch that logged every decision gave byte-identical graphs, and each changed site was joined to its decision for the triage.

Removed (887), every one categorized:

  • 558 are calls on a named std or dependency type: Vec, PathBuf, HashMap, syn's ParseStream, tar::Header, web_sys elements, std's File/Barrier/mpsc::Sender/DirBuilder/FileTimes/OpenOptions inside tokio's own wrappers, backtrace::BacktraceSymbol beside tokio's same-file struct, and tokio's TcpListener in axum's examples.
  • 190 are on a project type that lacks the method. These are derived Clone, or methods inside cfg_*! macros the index doesn't hold, such as tokio's AsyncWriteExt/AsyncReadExt (main had file.write(…) on tokio's File linked to MockFile::write) and runtime::Handle::dump (main linked a scheduler's Handle::dump). Some are namesakes of std types, like cx: &mut std::task::Context.
  • 93 are on tuples, slices, primitives and closures.
  • 28 are a wrapper's own method (clone on an Arc).
  • 3 are on trait-typed receivers with no such method.
  • 15 are fallbacks where main read another declaration of the name. After gleam's let tuple = self.infer(tuple)?;, the value is a TypedExpr, but main linked UntypedExpr::location from the parameter.

The removals whose old target was a method of the declared type's own name were all checked one by one, 42 in total. Each is a namesake of a std or dependency type (web_sys::Event, lsp_server::Request), a derived Clone, a method a cfg_*! macro hides from the index, or a delegating impl as above.

The rebase onto #2701 and #2702 changed about 60 sites from the previous round, and each was checked. Two removals were wrong and are fixed: tokio's me.schedule(…) on me: &Arc<Handle> stays on impl Schedule for Arc<Handle>, and io.is_write_vectored() on Pin<&mut T> stays on the Pin impl. The module walk added 36 tokio::sync::Semaphore calls in tests, ripgrep's ignore::DirEntry::path in an example, and clap's Inner::as_os_str in an inline module.

Self-loops: 76 new calls self-loops, in gleam (72) and ripgrep (4), are all recursion on a field or variant of the same type: ClauseGuard::Block { value, .. } => value.referenced_variables(), and ignore's Error::WithPath { err, .. } => err.depth() with err: Box<Error>. The 14 that go were wrong: tokio's runtime::Handle::name now reaches current_thread::Handle::name through the variant's Arc, and the rest were calls on redb, web_sys or syn values.

Added and retargeted:

  • Dioxus's dispatch match now links all of its opts.<cmd>() calls: opts.build() → CommandWithPlatformOverrides::build, build.anonymized() → CommandWithPlatformOverrides::anonymized.
  • serde: serializer: S with S: Serializer → Serializer::serialize_*, instead of TaggedSerializer's.
  • gleam: builder: &mut impl ErlangBuilder<Output> → the trait's methods, and let fs = InMemoryFileSystem::new(); fs.write(…).
  • tokio: LocalSet::spawn_local, the async sync::Barrier, UdpSocket::bind(…).await? locals, and tokio-test's Spawn::poll/enter.
  • ripgrep: builders typed by their constructors, and use grep_matcher::Match as Range aliases.
  • dioxus's let mut builder = AppServer::new(…).await?; builder.open(…) now reaches AppServer::open. fix(extraction): a build folder inside src/ is indexed for its Rust files too #2695 had read it as AppBuilder::open.

For every typed link, a check compared the new target's owner with the declared type. All the mismatches are explained: aliases, provided trait methods (WritableExt::set on a Signal), Deref (tokio's Reaper → W: Wait) and supertraits (&dyn HasMouseData → PointerInteraction).

Cost

CPU profiles of every thread, main vs branch, interleaved, two rounds each. The new code's self time is about 0.6 s on tokio (~2% of busy CPU), 0.9 s on dioxus (~2.3%) and 0.9 s on gleam (~3.4%). The module walk it now calls adds under 30 ms. Busy CPU stayed within the rounds' noise on tokio and dioxus, and rose 1.3–1.6 s on gleam, which also gains about 2,400 edges. Index walls are within noise.

Overlap

Follow-ups

  • The type of a method call's value, e.g. let req = req.map(Body::new) and let x = foo.bar()?. This is the largest class of bindings that write no type.
  • for loop variables: for x in &vec with vec: Vec<Foo>.
  • self.field.m() through the same reader. matchRustSelfFieldCall still reads only plain field types.

🤖 Generated with Claude Code

…lared with

A Rust method call on a local variable or a parameter (`opts.build()`) was
matched by the method's name whenever the receiver's declaration wasn't one
of two unscoped line patterns. dioxus's `Commands::Build(opts) =>
opts.build()` lost its link once the build module's `BuildRequest::build`
shared the name, and calls on std or dependency values linked to project
namesakes: `cx.waker()` on a `Context` to tokio's `CachedParkThread::waker`.

The receiver is now typed by the binding the call sees. The new
src/resolution/rust-scopes.ts reads each file's parameters, closure
parameters, lets, if-let / while-let / let-else, for and match-arm bindings
with block scope. A binding's type is its written one, the value it is
built from, or the variant or struct field its pattern takes. The method is
the one Rust picks on that type: inherent first, then a trait impl whose
trait is in scope, then a trait's provided method (a blanket impl only where
its bounds are met), then Deref. `&`, Box, Rc, Arc, Pin, Cow and the lock
guards are looked through, their own methods kept (one list with the
self-call path's), and a project impl on the wrapper itself answers before
what the wrapper holds, as it does for `self`. `impl Trait`, `dyn Trait` and
bounded generics reach the trait's method or a supertrait's. Among types
sharing a name, the one its path names through the project's modules wins,
the path written or bound by a block-scoped `use`, else the one that has
the method. A std, dependency or primitive type reaches only a project trait
impl in scope, and not one that hands the call on to the type's own method
of that name.

A binding that writes no type leaves the call to the name strategies, which
no longer take a project type from another declaration of the name. A
trait's own methods carry no `pub`: isVisibleAcrossFiles lets them through
for the calls this path resolved, and name guesses stay out as before.

A/B with fresh indexes against main on axum, serde, ripgrep, clap, dioxus,
tokio and gleam, every changed edge triaged: 4,645 calls linked that were
not, 887 removed (calls on std or dependency values, project types without
the method, a wrapper's own methods), 459 moved to the declared type's
method, 3,669 kept at a higher confidence. gin, serilog, flask and express
are byte-identical. The new code costs 0.6 to 0.9 s of CPU per full index
of tokio, dioxus or gleam.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant