From 9cb9d5824fec544f1a0b56c4b02c97a9789cb403 Mon Sep 17 00:00:00 2001 From: Nicolas Cristobal Date: Thu, 23 Jul 2026 16:05:57 +0000 Subject: [PATCH 1/2] fix(rust): resolve module-qualified calls via qualifier-verified cross-file resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unresolved scoped calls (module::function()) were dropped at extraction, making the dominant idiomatic intra-crate call form invisible to the graph (a real production caller showed 0 dependents while grep found it). Keep the #908 bare-name guard, but stop discarding the qualifier — use it as the verification key. The qualifier is verified right-to-left against the candidate's file path: the last segment must BE the defining module (.rs or /mod.rs), every remaining segment must match the successive parent directory, stopping at crate/super/self. Candidates are restricted to Rust sources, and a qualifier bound by a use from outside the crate (use std::fs; fs::read()) is skipped at extraction via the file's use roots. Exactly one survivor or bail. Uppercase qualifiers (Type::method / Enum::Variant) keep their existing skipped behavior. Emitted as EXTRACTED (confidence 1.0): the module is named explicitly in source — the same reasoning as the qualified-class-method passes (#1446/#1533). The verification lives in one shared helper (rust_scoped_call_survivors) used by both cross-file passes, so scoped entries can never reach bare-name resolution in either. Real-world Rust crate (~200 files, 14k edges): +138 EXTRACTED, -388 INFERRED (the #908 class shrinking), no regressions on bare-name-called control symbols. Co-Authored-By: Claude Fable 5 --- graphify/extract.py | 39 +++++- graphify/extractors/rust.py | 24 +++- graphify/symbol_resolution.py | 99 +++++++++++++ tests/test_rust_scoped_call_resolution.py | 162 ++++++++++++++++++++++ 4 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 tests/test_rust_scoped_call_resolution.py diff --git a/graphify/extract.py b/graphify/extract.py index 3ec42cffd0..dd3e0b988e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -183,7 +183,7 @@ _workspace_globs, ) -from graphify.symbol_resolution import resolve_bash_source_edges # noqa: E402 +from graphify.symbol_resolution import resolve_bash_source_edges, rust_scoped_call_survivors # noqa: E402 from graphify.extractors.engine import PYTEST_FIXTURE_UNTYPED, REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_namespace_id, _csharp_namespace_name, _csharp_pick_overload, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401 @@ -11086,6 +11086,43 @@ def _looks_like_bash(result: object) -> bool: # `close` and `delete` are ordinary method names elsewhere (#2296). if rc.get("language") == "go" and callee in _GO_PREDECLARED_FUNCS: continue + # Rust module-qualified call (`module::function()`), enqueued by + # extractors/rust.py with its qualifier path. Historically these were + # dropped at extraction because BARE last-segment lookup across crate + # boundaries produced spurious INFERRED edges (#908) — which made the + # dominant idiomatic intra-crate call form invisible (a real caller of + # `scan_substrate::accumulate_observations` showed 0 dependents while + # grep found it). Resolve strictly by the qualifier instead — the + # candidate's file path must match it (see rust_scoped_call_survivors); + # require exactly one survivor or skip, and never fall through to the + # bare-name tie-breakers below, so the #908 guard is preserved. + # EXTRACTED because the module is named explicitly in source — same + # reasoning as the qualified-class-method passes (#1446/#1533). + if rc.get("is_scoped_call"): + _survivors = rust_scoped_call_survivors( + str(rc.get("scope_qualifier", "")).strip(), + global_label_to_nids.get(callee, []), + nid_to_source_file, + ) + if len(_survivors) != 1: + continue + _tgt = _survivors[0] + _caller = rc["caller_nid"] + if _tgt == _caller or (_caller, _tgt) in existing_pairs: + continue + existing_pairs.add((_caller, _tgt)) + all_edges.append({ + "source": _caller, + "target": _tgt, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": rc.get("source_file", ""), + "source_location": rc.get("source_location"), + "weight": 1.0, + }) + continue # Exact-case match first (case is semantic). Fold only when the CALLING # file's language is case-insensitive, and only against the folded index of # case-insensitive-language definitions — so a Python `Path()` call can never diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 9f59d102e3..8d9a5f5280 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -956,7 +956,29 @@ def walk_calls( "source_location": f"L{line}", "weight": 1.0, }) - elif not is_scoped_call and callee_name.lower() not in _RUST_TRAIT_METHOD_BLOCKLIST: + elif is_scoped_call: + # A module-qualified call (`crate::m::f()`, `m::f()`, `super::m::f()`) + # goes to the cross-file pass with its qualifier, which binds it only + # when the qualifier matches the candidate's module path and exactly + # one candidate survives. Bare last-segment lookup stays off (#908). + qual_node = func_node.child_by_field_name("path") + qualifier = _read_text(qual_node, source) if qual_node else "" + # `use std::fs; fs::read()` names std's fs, never a local `fs.rs`. + first_seg = qualifier.split("::", 1)[0].split("<", 1)[0].strip() + roots = use_roots.get(first_seg, {"crate"}) + if not roots <= {"crate", "super", "self"}: + qualifier = "" + if qualifier: + raw_calls.append({ + "caller_nid": caller_nid, + "callee": callee_name, + "is_member_call": False, + "is_scoped_call": True, + "scope_qualifier": qualifier, + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + }) + elif callee_name.lower() not in _RUST_TRAIT_METHOD_BLOCKLIST: rc_entry = { "caller_nid": caller_nid, "callee": callee_name, diff --git a/graphify/symbol_resolution.py b/graphify/symbol_resolution.py index 1332d32b5c..0b4efe9f10 100644 --- a/graphify/symbol_resolution.py +++ b/graphify/symbol_resolution.py @@ -325,6 +325,62 @@ def resolve_python_import_guided_calls( return resolved_edges +def rust_scoped_call_survivors( + qualifier: str, + candidates: Sequence[str], + nid_to_source_file: dict[str, str], +) -> list[str]: + """Return the candidates a Rust ``module::function()`` qualifier verifies. + + The qualifier is matched right-to-left against each candidate's file path: + the last segment must BE the defining module (``.rs`` or + ``/mod.rs``), and every remaining segment must match the successive + parent directory. Matching stops at ``crate``/``super``/``self`` — those + are root-relative, and resolving the root needs a module-tree model this + deliberately does not attempt; segments to their right are still verified. + Matching only the last segment let ``std::fs::read()`` bind to an + unrelated local ``fs.rs`` (shadowing std module names with wrapper modules + is common Rust); the directory walk fails that on ``std`` vs ``src``. + Candidates not defined in a ``.rs`` file are skipped — a bare stem match + let a Rust ``b::helper()`` bind to a Python ``b.py``. + + Returns [] outright when the qualifier disqualifies resolution: a bare + ``crate``/``super``/``self`` names no module file, and an UpperCamelCase + segment is a type (``Type::method()`` / ``Enum::Variant`` — rustc enforces + the case convention), which keeps its pre-existing skipped behavior. + """ + segments = [s.split("<", 1)[0].strip() for s in qualifier.split("::")] + segments = [s for s in segments if s] + if not segments: + return [] + last = segments[-1] + if last in ("crate", "super", "self") or not last[:1].islower(): + return [] + survivors: list[str] = [] + for cand in candidates: + cand_file = str(nid_to_source_file.get(cand, "")) + if not cand_file.endswith(".rs"): + continue + path = Path(cand_file) + if path.stem == last: + cursor = path.parent + elif path.stem == "mod" and path.parent.name == last: + cursor = path.parent.parent + else: + continue + matched = True + for seg in reversed(segments[:-1]): + if seg in ("crate", "super", "self"): + break + if cursor.name != seg: + matched = False + break + cursor = cursor.parent + if matched: + survivors.append(cand) + return survivors + + def resolve_cross_file_raw_calls( per_file: Sequence[dict[str, Any] | None], all_nodes: list[dict[str, Any]], @@ -356,6 +412,49 @@ def resolve_cross_file_raw_calls( continue if raw_call.get("is_member_call"): continue + if raw_call.get("is_scoped_call"): + # Rust module-qualified call (`module::function()`), enqueued by + # extractors/rust.py with its qualifier path. These must NEVER fall + # through to the bare-name branch below — bare last-segment lookup + # across crate boundaries is exactly what produced the spurious + # INFERRED edges that motivated dropping scoped calls (#908). + # Instead the qualifier is verified against the candidate's file + # path (see rust_scoped_call_survivors); require exactly one + # survivor or bail (god-node guard preserved). Emitted as EXTRACTED + # because the source names the module explicitly — same reasoning + # as the Python qualified-class-method pass (#1446/#1533). + qualifier = str(raw_call.get("scope_qualifier", "")).strip() + survivors = rust_scoped_call_survivors( + qualifier, label_index.get(callee.lower(), []), nid_to_source_file + ) + if len(survivors) != 1: + continue + target = survivors[0] + caller = str(raw_call.get("caller_nid", "")) + if not caller or target == caller: + continue + pair = (caller, target, "calls") + if pair in known_pairs: + continue + known_pairs.add(pair) + resolved.append( + { + "source": caller, + "target": target, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": raw_call.get("source_file", ""), + "source_location": raw_call.get("source_location"), + "weight": 1.0, + "metadata": sanitize_metadata({ + "resolver": "rust_scoped_module_call", + "scope_qualifier": qualifier, + }), + } + ) + continue candidates = label_index.get(callee.lower(), []) if not candidates: continue diff --git a/tests/test_rust_scoped_call_resolution.py b/tests/test_rust_scoped_call_resolution.py new file mode 100644 index 0000000000..0de1b923c8 --- /dev/null +++ b/tests/test_rust_scoped_call_resolution.py @@ -0,0 +1,162 @@ +"""Rust module-qualified calls resolve cross-file, verified by the qualifier (#908). + +Scoped calls (`module::function()`) — the dominant intra-crate call form in +idiomatic Rust — used to be dropped at extraction: bare last-segment lookup +across crates produced spurious INFERRED edges (#908), so a real production +caller showed 0 dependents while grep found the call site immediately. + +The resolver keeps the #908 guard but uses the discarded qualifier as the +verification key: the last segment must BE the defining module (`.rs` or +`/mod.rs`), every remaining segment must match the successive parent +directory (stopping at `crate`/`super`/`self`), candidates are Rust-only, and +a qualifier bound by a `use` from outside the crate is skipped at extraction. +Exactly one survivor or no edge. + +These tests pin: the resolution positives (plain, `mod.rs`, crate-qualified, +use-bound from the crate), the std-shadowing and cross-language false +positives, and the guards (ambiguity, uppercase types, bare `super`/`self`, +untouched bare-name path). +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _calls(files: list[Path], base: Path) -> set[tuple[str, str, str]]: + r = extract(files, cache_root=base, parallel=False) + # Rust function labels carry their call parens (`run()`); strip for + # comparison so assertions read as source names. + lbl = {n["id"]: n["label"].strip("()") for n in r["nodes"]} + return { + (lbl.get(e["source"], ""), lbl.get(e["target"], ""), e.get("confidence")) + for e in r["edges"] if e["relation"] == "calls" + } + + +def test_module_qualified_call_resolves_to_the_named_module(tmp_path: Path) -> None: + _write(tmp_path / "src/a.rs", + "pub fn run() { b::helper(); }\n") + _write(tmp_path / "src/b.rs", + "pub fn helper() {}\n") + # c.rs defines a same-named helper → the qualifier, not the name, decides + _write(tmp_path / "src/c.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert ("run", "helper", "EXTRACTED") in calls, calls + + +def test_mod_rs_form_resolves(tmp_path: Path) -> None: + _write(tmp_path / "src/a.rs", + "pub fn run() { util::helper(); }\n") + _write(tmp_path / "src/util/mod.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert ("run", "helper", "EXTRACTED") in calls, calls + + +def test_two_matching_module_files_emit_nothing(tmp_path: Path) -> None: + # helper() in both b.rs and b/mod.rs → two survivors → skipped (god-node guard) + _write(tmp_path / "src/a.rs", + "pub fn run() { b::helper(); }\n") + _write(tmp_path / "src/b.rs", + "pub fn helper() {}\n") + _write(tmp_path / "src/b/mod.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert not any(s == "run" and t == "helper" for s, t, _ in calls), calls + + +def test_uppercase_qualifier_is_a_type_not_a_module(tmp_path: Path) -> None: + # Uppercase qualifier is a type (`Type::method()`) → keeps the old skipped behavior + _write(tmp_path / "src/a.rs", + "pub fn run() { Helper::helper(); }\n") + _write(tmp_path / "src/helper.rs", + "pub struct Helper;\n" + "impl Helper { pub fn helper() {} }\n" + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert not any(s == "run" for s, _t, _ in calls), calls + + +def test_crate_qualified_path_verifies_against_directories(tmp_path: Path) -> None: + # Segments right of `crate` verify against directories → resolves without a module tree + _write(tmp_path / "src/a.rs", + "pub fn run() { crate::util::helper(); }\n") + _write(tmp_path / "src/util.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert ("run", "helper", "EXTRACTED") in calls, calls + + +def test_bare_super_and_self_are_skipped(tmp_path: Path) -> None: + # Bare `super::`/`self::` names no module file → needs a module tree → skipped + _write(tmp_path / "src/a.rs", + "pub fn run() { super::helper(); self::helper2(); }\n") + _write(tmp_path / "src/b.rs", + "pub fn helper() {}\npub fn helper2() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert not any(s == "run" for s, _t, _ in calls), calls + + +def test_std_qualified_call_does_not_bind_to_local_shadow(tmp_path: Path) -> None: + # `std::fs::read(...)` with an unrelated local fs.rs defining read → + # the directory walk fails on `std` vs `src` → no edge + _write(tmp_path / "src/a.rs", + 'pub fn run() { let _ = std::fs::read("x"); }\n') + _write(tmp_path / "src/fs.rs", + "pub fn read(p: &str) -> Vec { Vec::new() }\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert not any(s == "run" and t == "read" for s, t, _ in calls), calls + + +def test_use_bound_qualifier_from_std_is_skipped(tmp_path: Path) -> None: + # `use std::fs; fs::read()` → the qualifier names std's fs, not the + # unrelated local fs.rs → skipped at enqueue + _write(tmp_path / "src/a.rs", + "use std::fs;\n" + 'pub fn run() { let _ = fs::read("x"); }\n') + _write(tmp_path / "src/fs.rs", + "pub fn read(p: &str) -> Vec { Vec::new() }\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert not any(s == "run" and t == "read" for s, t, _ in calls), calls + + +def test_use_bound_qualifier_from_crate_still_resolves(tmp_path: Path) -> None: + # `use crate::util;` binds util from inside the crate → still resolves + _write(tmp_path / "src/a.rs", + "use crate::util;\n" + "pub fn run() { util::helper(); }\n") + _write(tmp_path / "src/util.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert ("run", "helper", "EXTRACTED") in calls, calls + + +def test_rust_scoped_call_cannot_bind_across_languages(tmp_path: Path) -> None: + # Rust `b::helper()` with a Python b.py defining helper → candidates are + # Rust-only → no cross-language edge + _write(tmp_path / "src/a.rs", + "pub fn run() { b::helper(); }\n") + _write(tmp_path / "src/b.py", + "def helper():\n pass\n") + files = sorted(tmp_path.rglob("*.rs")) + sorted(tmp_path.rglob("*.py")) + calls = _calls(files, tmp_path) + assert not any(s == "run" and t == "helper" for s, t, _ in calls), calls + + +def test_bare_name_calls_keep_their_existing_resolution(tmp_path: Path) -> None: + # Control: bare-name calls keep their existing INFERRED resolution + _write(tmp_path / "src/a.rs", + "pub fn run() { helper(); }\n") + _write(tmp_path / "src/b.rs", + "pub fn helper() {}\n") + calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) + assert any(s == "run" and t == "helper" for s, t, _ in calls), calls From ef748fd9f07abf9578c0bf4453b18709ef6be797 Mon Sep 17 00:00:00 2001 From: nothariharan Date: Mon, 2 Nov 2026 09:27:19 +0530 Subject: [PATCH 2/2] fix(rust): keep module-path calls inside the caller's crate and out of local names Review follow-ups on the rebased #2135. - `crate::util::f()` stopped matching at `crate` without checking which crate the candidate is in, so in a workspace it bound to another crate's util.rs. A candidate must now share the caller's crate root (nearest Cargo.toml, else the directory above `src`). - `crate::b::helper()` in a.rs went through the in-file bare-name lookup first and bound to a.rs's own helper. A module-path call binds in-file only for `self::f()` or an inline `mod m { .. }` declared in that file. - Module names starting with `_` were rejected (`'_'.islower()` is False); only an UpperCamelCase segment means a type. - Methods are skipped as candidates: a module path names a free function. --- graphify/extract.py | 7 +++ graphify/extractors/rust.py | 21 ++++++++ graphify/symbol_resolution.py | 38 +++++++++++++- tests/test_rust_scoped_call_resolution.py | 62 +++++++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index dd3e0b988e..a81e5b61b2 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -10982,6 +10982,7 @@ def _looks_like_bash(result: object) -> bool: # nid -> raw source_file string, for the ambiguous-name tie-breakers below # (test/non-test classification + path proximity). Kept separate from the # file-node-id map because tie-breaking compares the actual file paths. + _rust_nid_to_label: "dict[str, str] | None" = None nid_to_source_file: dict[str, str] = {} for n in resolution_nodes: sf = n.get("source_file") @@ -11099,10 +11100,16 @@ def _looks_like_bash(result: object) -> bool: # EXTRACTED because the module is named explicitly in source — same # reasoning as the qualified-class-method passes (#1446/#1533). if rc.get("is_scoped_call"): + if _rust_nid_to_label is None: + _rust_nid_to_label = { + str(n.get("id")): str(n.get("label", "")) for n in resolution_nodes + } _survivors = rust_scoped_call_survivors( str(rc.get("scope_qualifier", "")).strip(), global_label_to_nids.get(callee, []), nid_to_source_file, + caller_file=str(rc.get("source_file", "")), + nid_to_label=_rust_nid_to_label, ) if len(_survivors) != 1: continue diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 8d9a5f5280..32f107bef5 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -286,6 +286,17 @@ def _scan_local_types(scan_node) -> None: # are resolved through their own registry rather than the function/type # label_to_nid to avoid cross-binding. macro_nids_by_name: dict[str, str] = {} + # `mod m { .. }` blocks declared in this file, so a `m::f()` call can bind + # in-file; `mod m;` declares a module that lives in another file. + inline_mods: set[str] = set() + _mod_stack = [root] + while _mod_stack: + _n = _mod_stack.pop() + if _n.type == "mod_item" and _n.child_by_field_name("body") is not None: + _name = _n.child_by_field_name("name") + if _name is not None: + inline_mods.add(_read_text(_name, source)) + _mod_stack.extend(_n.children) # (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. @@ -938,6 +949,16 @@ def walk_calls( tgt_nid = named if named in type_nids else None elif is_member_call: tgt_nid = method_label_to_nid.get(callee_name) + elif is_scoped_call: + # `m::f()` names module m. It is in this file only for + # `self::f()` or an inline `mod m { .. }` declared here; any + # other module lives in another file, so the bare-name match + # would bind `crate::b::helper()` to this file's own helper. + path_node = func_node.child_by_field_name("path") if func_node else None + qual = _read_text(path_node, source) if path_node is not None else "" + head = qual.split("::")[-1].strip() + tgt_nid = label_to_nid.get(callee_name) if ( + qual == "self" or head in inline_mods) else None else: tgt_nid = label_to_nid.get(callee_name) if tgt_nid and tgt_nid != caller_nid: diff --git a/graphify/symbol_resolution.py b/graphify/symbol_resolution.py index 0b4efe9f10..168bba5779 100644 --- a/graphify/symbol_resolution.py +++ b/graphify/symbol_resolution.py @@ -6,6 +6,7 @@ import re import unicodedata from dataclasses import dataclass +from functools import lru_cache from pathlib import Path from collections.abc import Sequence from typing import Any @@ -325,10 +326,31 @@ def resolve_python_import_guided_calls( return resolved_edges +@lru_cache(maxsize=4096) +def _rust_crate_root(source_file: str) -> str | None: + """The crate a Rust file belongs to: the nearest directory holding a + ``Cargo.toml``, else the directory above the nearest ``src``. None when + neither is found (a flat layout this pass cannot place).""" + path = Path(source_file) + for parent in path.parents: + try: + if (parent / "Cargo.toml").is_file(): + return str(parent) + except OSError: + break + parts = path.parts + if "src" in parts[:-1]: + idx = len(parts) - 1 - parts[::-1].index("src") + return str(Path(*parts[:idx])) if idx else "" + return None + + def rust_scoped_call_survivors( qualifier: str, candidates: Sequence[str], nid_to_source_file: dict[str, str], + caller_file: str = "", + nid_to_label: "dict[str, str] | None" = None, ) -> list[str]: """Return the candidates a Rust ``module::function()`` qualifier verifies. @@ -344,6 +366,10 @@ def rust_scoped_call_survivors( Candidates not defined in a ``.rs`` file are skipped — a bare stem match let a Rust ``b::helper()`` bind to a Python ``b.py``. + A candidate must live in the caller's crate (see ``_rust_crate_root``): + ``crate::util::f()`` in crate_a never means crate_b's ``util.rs``. Methods + are skipped, since a module path names a free function, never an impl's. + Returns [] outright when the qualifier disqualifies resolution: a bare ``crate``/``super``/``self`` names no module file, and an UpperCamelCase segment is a type (``Type::method()`` / ``Enum::Variant`` — rustc enforces @@ -354,13 +380,18 @@ def rust_scoped_call_survivors( if not segments: return [] last = segments[-1] - if last in ("crate", "super", "self") or not last[:1].islower(): + if last in ("crate", "super", "self") or last[:1].isupper(): return [] + caller_crate = _rust_crate_root(caller_file) if caller_file else None survivors: list[str] = [] for cand in candidates: cand_file = str(nid_to_source_file.get(cand, "")) if not cand_file.endswith(".rs"): continue + if nid_to_label is not None and nid_to_label.get(cand, "").startswith("."): + continue + if caller_file and _rust_crate_root(cand_file) != caller_crate: + continue path = Path(cand_file) if path.stem == last: cursor = path.parent @@ -404,6 +435,7 @@ def resolve_cross_file_raw_calls( for n in all_nodes if n.get("id") } + nid_to_label = {str(n.get("id")): str(n.get("label", "")) for n in all_nodes if n.get("id")} resolved: list[dict[str, Any]] = [] for raw_call in iter_raw_calls(per_file): @@ -425,7 +457,9 @@ def resolve_cross_file_raw_calls( # as the Python qualified-class-method pass (#1446/#1533). qualifier = str(raw_call.get("scope_qualifier", "")).strip() survivors = rust_scoped_call_survivors( - qualifier, label_index.get(callee.lower(), []), nid_to_source_file + qualifier, label_index.get(callee.lower(), []), nid_to_source_file, + caller_file=str(raw_call.get("source_file", "")), + nid_to_label=nid_to_label, ) if len(survivors) != 1: continue diff --git a/tests/test_rust_scoped_call_resolution.py b/tests/test_rust_scoped_call_resolution.py index 0de1b923c8..93b19ce7c8 100644 --- a/tests/test_rust_scoped_call_resolution.py +++ b/tests/test_rust_scoped_call_resolution.py @@ -160,3 +160,65 @@ def test_bare_name_calls_keep_their_existing_resolution(tmp_path: Path) -> None: "pub fn helper() {}\n") calls = _calls(sorted(tmp_path.rglob("*.rs")), tmp_path) assert any(s == "run" and t == "helper" for s, t, _ in calls), calls + + +def _scoped_calls(tmp_path, files: dict[str, str]) -> set[tuple[str, str]]: + paths = [] + for name, text in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + paths.append(path) + result = extract(paths, root=tmp_path, cache_root=tmp_path / "cache") + node = {n["id"]: n for n in result["nodes"]} + + def where(nid: str) -> str: + n = node.get(nid, {}) + sf = Path(n.get("source_file", "")) + rel = (sf.relative_to(tmp_path) if sf.is_absolute() else sf).as_posix() + return f'{rel}:{n.get("label")}' + + return { + (where(e["source"]), where(e["target"])) + for e in result["edges"] + if e["relation"] == "calls" and e["target"] in node and e["source"] in node + } + + +def test_crate_path_does_not_cross_into_another_workspace_crate(tmp_path): + calls = _scoped_calls(tmp_path, { + "crate_a/Cargo.toml": "[package]\nname = \"a\"\n", + "crate_a/src/main.rs": "mod util;\nfn run() { crate::util::f(); }\n", + "crate_a/src/util.rs": "pub fn g() {}\n", + "crate_b/Cargo.toml": "[package]\nname = \"b\"\n", + "crate_b/src/util.rs": "pub fn f() {}\n", + }) + assert not any(tgt.startswith("crate_b/") for _, tgt in calls) + + +def test_module_path_never_names_an_impl_method(tmp_path): + calls = _scoped_calls(tmp_path, { + "Cargo.toml": "[package]\nname = \"s\"\n", + "src/store.rs": "pub struct Db;\nimpl Db { pub fn open(&self) {} }\n", + "src/main.rs": "mod store;\nfn run() { store::open(); }\n", + }) + assert not any(src.endswith("run()") for src, _ in calls) + + +def test_underscore_module_resolves(tmp_path): + calls = _scoped_calls(tmp_path, { + "Cargo.toml": "[package]\nname = \"u\"\n", + "src/_internal.rs": "pub fn helper() {}\n", + "src/main.rs": "mod _internal;\nfn run() { crate::_internal::helper(); }\n", + }) + assert ("src/main.rs:run()", "src/_internal.rs:helper()") in calls + + +def test_path_call_does_not_bind_to_a_same_named_local_function(tmp_path): + calls = _scoped_calls(tmp_path, { + "Cargo.toml": "[package]\nname = \"p\"\n", + "src/a.rs": "fn helper() {}\nfn run() { crate::b::helper(); }\n", + "src/b.rs": "pub fn helper() {}\n", + }) + assert ("src/a.rs:run()", "src/b.rs:helper()") in calls + assert ("src/a.rs:run()", "src/a.rs:helper()") not in calls