In Rust, a method call whose receiver is a typed field can resolve to a method of the same name on an unrelated type — including the calling method itself, producing a self-recursive edge that does not exist in the source. It also happens when the receiver's type is not a local one at all (a std container, an external crate, a generic parameter): the call is attached to a same-named local method.
The same structure in C++ resolves correctly (Outer::run -> Inner::run), so this looks specific to Rust.
Minimal reproduction
# Cargo.toml
[package]
name = "repro"
version = "0.1.0"
edition = "2021"
// src/lib.rs
pub mod inner;
pub mod outer;
// src/inner.rs
pub struct Inner {
pub n: usize,
}
impl Inner {
pub fn run(&mut self) {
self.n += 1;
}
}
// src/outer.rs
use crate::inner::Inner;
pub struct Outer {
pub inner: Inner,
}
impl Outer {
pub fn run(&mut self) {
self.inner.run();
}
}
codegraph init && codegraph index .
SELECT s.qualified_name || ' -> ' || t.qualified_name, e.line
FROM edges e JOIN nodes s ON e.source = s.id JOIN nodes t ON e.target = t.id
WHERE e.kind = 'calls';
Actual:
Outer::run -> Outer::run line 9
Outer::run does not call itself. self.inner has its type declared on the struct, so Outer::run -> Inner::run is what the source says. Resolving it correctly would be ideal; leaving the ref in unresolved_refs would at least be safe — as it stands unresolved_refs holds no entry for this call, so a consumer has no way to tell the edge was a guess.
Impact
The shape is common in idiomatic Rust: a wrapper or delegating struct holds a field of another type and forwards a method of the same name, or the field is a std container / external crate type while a local method happens to share its name.
Three real examples from ripgrep — in all three the receiver is not self, yet each is recorded as a self-recursive edge:
// crates/ignore/src/walk.rs:824 ig_builder: IgnoreBuilder
self.ig_builder.add_custom_ignore_filename(file_name);
// crates/ignore/src/walk.rs:1195 its: std::vec::IntoIter<...>
match self.its.next() {
// crates/globset/src/lib.rs:983 matcher: Regex
self.matcher.is_match(candidate.path.as_bytes())
There are a few hundred such non-existent self-edges in ripgrep and more in tokio (SELECT count(*) FROM edges WHERE kind = 'calls' AND source = target, then checking the receiver at each call site). Self-edges are only the subset that is easy to falsify — a wrong pick that lands on some other method cannot be spotted this way, so the affected set is larger.
A few self-edges are genuine recursion (e.g. ripgrep's Error::is_partial unwrapping a nested error), and are not counted above.
Environment
Reproduced both on the v1.5.0 release (npm @colbymchenry/codegraph@1.5.0) and on a local build of current main (81e1f4a). macOS arm64, Node v24.7.0.
In Rust, a method call whose receiver is a typed field can resolve to a method of the same name on an unrelated type — including the calling method itself, producing a self-recursive edge that does not exist in the source. It also happens when the receiver's type is not a local one at all (a std container, an external crate, a generic parameter): the call is attached to a same-named local method.
The same structure in C++ resolves correctly (
Outer::run -> Inner::run), so this looks specific to Rust.Minimal reproduction
Actual:
Outer::rundoes not call itself.self.innerhas its type declared on the struct, soOuter::run -> Inner::runis what the source says. Resolving it correctly would be ideal; leaving the ref inunresolved_refswould at least be safe — as it standsunresolved_refsholds no entry for this call, so a consumer has no way to tell the edge was a guess.Impact
The shape is common in idiomatic Rust: a wrapper or delegating struct holds a field of another type and forwards a method of the same name, or the field is a std container / external crate type while a local method happens to share its name.
Three real examples from ripgrep — in all three the receiver is not
self, yet each is recorded as a self-recursive edge:There are a few hundred such non-existent self-edges in ripgrep and more in tokio (
SELECT count(*) FROM edges WHERE kind = 'calls' AND source = target, then checking the receiver at each call site). Self-edges are only the subset that is easy to falsify — a wrong pick that lands on some other method cannot be spotted this way, so the affected set is larger.A few self-edges are genuine recursion (e.g. ripgrep's
Error::is_partialunwrapping a nested error), and are not counted above.Environment
Reproduced both on the v1.5.0 release (npm
@colbymchenry/codegraph@1.5.0) and on a local build of currentmain(81e1f4a). macOS arm64, Node v24.7.0.