diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 7173878fa..452758050 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -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::`, `Vec::` 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 `` 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` -> `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: diff --git a/tests/test_rust_in_file_call_binding.py b/tests/test_rust_in_file_call_binding.py new file mode 100644 index 000000000..422a55ce6 --- /dev/null +++ b/tests/test_rust_in_file_call_binding.py @@ -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 { Arc::new(1) }\n" + "fn options() -> Vec { 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 for i32 { fn from(c: Code) -> i32 { 0 } }\n" # L4 + "impl From 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