fix(rust): bind an in-file call to the type it names, not to any same-named function

extract_rust bound every in-file call through one bare-name index (last one wins),
so `Matcher::new()` reached `Printer::new()` when the file defined both, `Arc::new(x)`
reached the file's own `fn new`, `self.go()` another type's `go`, `drop(x)` the
`impl Drop` method and `x.total()` a free `fn total`, all EXTRACTED.

`Type::f()` / `Self::f()` / `self.f()` now bind only to that type's method in the file
(type = last path segment without generics, on the call and on the impl; `i32` and
other primitives count as types). With none, `Enum::Variant(x)` still gets its
constructor reference and `self.f()` falls through to the cross-file self pass. A bare
`f()` binds only to a free function or a type, `x.f()` only to a method.

Against rust-analyzer: wrong edges ripgrep 1232 -> 532, bat 138 -> 36, fd 29 -> 9;
correct edges 1852 -> 2236, 602 -> 655, 148 -> 154.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit de9b728ac6)
This commit is contained in:
rohit-jsfreaky authored and safishamsi committed 2026-10-09 02:48:23 +01:00
1 parent 223b376e9a
commit ad1dbc1e9b
2 files changed
+208 -2

No files matched your search

+78 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
from pathlib import Path
from graphify.extractors.base import (
_LANGUAGE_BUILTIN_GLOBALS,
@@ -160,6 +161,44 @@ _RUST_TRAIT_METHOD_BLOCKLIST: frozenset[str] = frozenset({
"ok", "err", "some", "none", "send", "recv", "lock", "read", "write",
})
_RUST_PRIMITIVE_TYPES: frozenset[str] = frozenset({
"bool", "char", "str", "f32", "f64",
"i8", "i16", "i32", "i64", "i128", "isize",
"u8", "u16", "u32", "u64", "u128", "usize",
})
def _rust_qualifier_type(path_node, source: bytes) -> str | None:
"""The type a `Type::f()` call is qualified by, or None for a module path.
`Foo`, `crate::a::Foo`, `Foo::<T>`, `Vec::<u8>` and `i32` give their last
segment without generic arguments; `Self` is returned as is for the caller to
map to the enclosing impl type. Any other lowercase last segment is a module
(`fs::read`, `self::helper`), and `<T as Trait>` has no segment of its own:
both give None.
"""
if path_node is None:
return None
last = _rust_type_last_segment(_read_text(path_node, source))
return last if last[:1].isupper() or last in _RUST_PRIMITIVE_TYPES else None
def _rust_type_last_segment(text: str) -> str:
"""`crate::a::Foo<T>` -> `Foo`: the last path segment, generic arguments dropped.
A leading `&`, `&'a mut` or `dyn` (`impl Tr for &'a Foo`) is not part of it."""
text = re.sub(r"^(?:&\s*(?:'\w+\s+)?(?:mut\s+)?|dyn\s+)", "", text.strip())
depth, kept = 0, []
for ch in text:
if ch == "<":
depth += 1
elif ch == ">":
depth = max(depth - 1, 0)
elif depth == 0:
kept.append(ch)
segments = [s.strip() for s in "".join(kept).split("::") if s.strip()]
return segments[-1] if segments else ""
def extract_rust(path: Path) -> dict:
"""Extract functions, structs, enums, traits, impl methods, statics/consts, and use declarations from a .rs file."""
try:
@@ -210,6 +249,10 @@ def extract_rust(path: Path) -> dict:
# label_to_nid to avoid cross-binding.
macro_nids_by_name: dict[str, str] = {}
# (bare impl type, method name) -> the method nodes this file's `impl` blocks
# define, so an in-file `Type::f()` / `self.f()` binds to a method of that type.
impl_methods: dict[tuple[str, str], set[str]] = {}
def add_node(nid: str, label: str, line: int) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
@@ -361,6 +404,9 @@ def extract_rust(path: Path) -> dict:
func_nid = _make_id(parent_impl_nid, func_name)
add_node(func_nid, f".{func_name}()", line)
add_edge(parent_impl_nid, func_nid, "method", line)
if parent_impl_type:
owner = _rust_type_last_segment(parent_impl_type)
impl_methods.setdefault((owner, func_name), set()).add(func_nid)
else:
func_nid = _make_id(stem, func_name)
add_node(func_nid, f"{func_name}()", line)
@@ -657,11 +703,20 @@ def extract_rust(path: Path) -> dict:
walk(root)
# A bare `f()` can only reach a free function or a type (a tuple struct or
# variant constructor), and `x.f()` only a method, so each call shape looks up
# its own index: one shared name index let `drop(x)` bind to `impl Drop`'s
# `drop` and `x.len()` to a free `fn len`.
label_to_nid: dict[str, str] = {}
method_label_to_nid: dict[str, str] = {}
for n in nodes:
raw = n["label"]
normalised = raw.strip("()").lstrip(".")
label_to_nid[normalised] = n["id"]
if raw.startswith("."):
if raw.endswith(")"):
method_label_to_nid[normalised] = n["id"]
else:
label_to_nid[normalised] = n["id"]
# Nodes whose label has no `()` suffix are data definitions (structs, enums,
# traits, statics), not callables. In Rust `Foo(x)` / `Foo { .. }` onto one
@@ -715,6 +770,7 @@ def extract_rust(path: Path) -> dict:
is_member_call: bool = False
is_scoped_call: bool = False
is_self_call: bool = False
qualifier_type: str | None = None
if func_node:
if func_node.type == "identifier":
callee_name = _read_text(func_node, source)
@@ -734,12 +790,32 @@ def extract_rust(path: Path) -> dict:
name = func_node.child_by_field_name("name")
if name:
callee_name = _read_text(name, source)
qualifier_type = _rust_qualifier_type(func_node.child_by_field_name("path"), source)
if qualifier_type == "Self":
# A trait's default method has no impl type: "" matches
# no method, so `Self::f()` there binds nothing in-file.
qualifier_type = self_type or ""
if (
callee_name
and callee_name not in _LANGUAGE_BUILTIN_GLOBALS
and (callee_name not in _RUST_BUILTIN_TYPES or callee_name in local_types)
):
tgt_nid = label_to_nid.get(callee_name)
if qualifier_type is not None or (is_self_call and self_type):
# `Type::f()` / `Self::f()` / `self.f()` name the type whose
# method is called: bind only to that type's method in this
# file. Matching the bare name instead sent `Arc::new(x)` and
# `B::new()` to whichever `fn new` the file happened to define.
owner = _rust_type_last_segment(qualifier_type or self_type or "")
owned = impl_methods.get((owner, callee_name), set())
tgt_nid = next(iter(owned)) if len(owned) == 1 else None
if tgt_nid is None and qualifier_type is not None:
# `Enum::Variant(x)` constructs a value: the variant node.
named = label_to_nid.get(callee_name)
tgt_nid = named if named in type_nids else None
elif is_member_call:
tgt_nid = method_label_to_nid.get(callee_name)
else:
tgt_nid = label_to_nid.get(callee_name)
if tgt_nid and tgt_nid != caller_nid:
pair = (caller_nid, tgt_nid)
if pair not in seen_call_pairs:
+130
View File
@@ -0,0 +1,130 @@
"""Rust in-file call binding follows the call's shape.
A call was bound to whichever node in the same file carried the callee's bare
name, so `B::new()` reached `A::new()` when the file defined both, `Arc::new(x)`
reached the file's own `fn new`, and `drop(x)` reached `impl Drop`'s method.
`Type::f()`, `Self::f()` and `self.f()` now bind only to a method of that type,
a bare `f()` only to a free function or a constructor, and `x.f()` only to a
method.
"""
from __future__ import annotations
from pathlib import Path
from graphify.extract import extract
from graphify.extractors.rust import extract_rust
def _edges(tmp_path: Path, source: str, relation: str = "calls") -> set[tuple[str, str, str]]:
path = tmp_path / "lib.rs"
path.write_text(source, encoding="utf-8")
result = extract_rust(path)
nodes = {n["id"]: n for n in result["nodes"]}
return {
(nodes[e["source"]]["label"], nodes[e["target"]]["label"], nodes[e["target"]]["source_location"])
for e in result["edges"]
if e["relation"] == relation and e["source"] in nodes and e["target"] in nodes
}
def test_type_qualified_call_binds_to_that_types_method(tmp_path: Path):
calls = _edges(tmp_path, (
"struct A;\n"
"struct B;\n"
"impl A { fn new() -> A { A } }\n" # L3
"impl B { fn new() -> B { B } }\n" # L4
"fn make_a() -> A { A::new() }\n"
"fn make_b() -> B { crate::B::new() }\n"
"impl A { fn again() -> A { Self::new() } }\n"
))
assert ("make_a()", ".new()", "L3") in calls
assert ("make_b()", ".new()", "L4") in calls
assert (".again()", ".new()", "L3") in calls
assert ("make_a()", ".new()", "L4") not in calls
assert ("make_b()", ".new()", "L3") not in calls
def test_call_on_a_type_the_file_does_not_implement_gets_no_edge(tmp_path: Path):
calls = _edges(tmp_path, (
"use std::sync::Arc;\n"
"struct Builder;\n"
"impl Builder { fn new() -> Builder { Builder } fn default() -> Builder { Builder } }\n"
"fn shared() -> Arc<u8> { Arc::new(1) }\n"
"fn options() -> Vec<u8> { Vec::default() }\n"
))
assert not {c for c in calls if c[0] in ("shared()", "options()")}, calls
def test_self_call_binds_to_the_callers_own_type(tmp_path: Path):
calls = _edges(tmp_path, (
"struct A;\n"
"struct B;\n"
"impl A {\n"
" fn run(&self) { self.go(); self.stop(); }\n"
" fn go(&self) {}\n" # L5
"}\n"
"impl B {\n"
" fn go(&self) {}\n" # L8
" fn stop(&self) {}\n" # L9
"}\n"
))
assert (".run()", ".go()", "L5") in calls
assert (".run()", ".go()", "L8") not in calls
# A has no `stop`: B's is never the target (the cross-file `self` pass decides).
assert (".run()", ".stop()", "L9") not in calls
def test_bare_call_never_binds_to_a_method_and_member_call_never_to_a_free_fn(tmp_path: Path):
calls = _edges(tmp_path, (
"struct Child;\n"
"impl Drop for Child { fn drop(&mut self) {} }\n"
"fn total() -> usize { 0 }\n"
"fn close(c: Child) { drop(c); }\n"
"fn count(v: &Stats) -> usize { v.total() }\n"
))
assert not {c for c in calls if c[0] in ("close()", "count()")}, calls
def test_primitive_and_path_qualified_impl_types_bind(tmp_path: Path):
calls = _edges(tmp_path, (
"use std::io;\n"
"struct Code;\n"
"struct Failure;\n"
"impl From<Code> for i32 { fn from(c: Code) -> i32 { 0 } }\n" # L4
"impl From<Failure> for io::Error { fn from(f: Failure) -> io::Error { todo!() } }\n" # L5
"fn status(c: Code) -> i32 { i32::from(c) }\n"
"fn fail(f: Failure) -> io::Error { io::Error::from(f) }\n"
))
assert ("status()", ".from()", "L4") in calls
assert ("fail()", ".from()", "L5") in calls
def test_enum_variant_constructor_is_still_a_reference(tmp_path: Path):
refs = _edges(tmp_path, (
"enum Shape { Circle(u32), Square(u32) }\n"
"fn mk() -> Shape { Shape::Circle(1) }\n"
), relation="references")
assert ("mk()", "Circle", "L1") in refs
def test_self_call_to_another_files_impl_still_resolves(tmp_path: Path):
# The caller's file defines a same-named method on another type; the call
# must reach its own type's method in the other file, not the local one.
for name, body in {
"a.rs": "pub struct A;\nimpl A { pub fn launch(&self) {} }\n",
"b.rs": (
"use crate::a::A;\n"
"pub struct B;\n"
"impl B { pub fn launch(&self) {} }\n"
"impl A { pub fn run(&self) { self.launch(); } }\n"
),
}.items():
(tmp_path / name).write_text(body, encoding="utf-8")
result = extract([tmp_path / "a.rs", tmp_path / "b.rs"], cache_root=tmp_path / "graphify-out")
nodes = {n["id"]: n for n in result["nodes"]}
targets = {
Path(str(nodes[e["target"]]["source_file"])).name
for e in result["edges"]
if e["relation"] == "calls" and nodes.get(e["source"], {}).get("label") == ".run()"
}
assert targets == {"a.rs"}, targets