From 912637d314db6c76403f4322a673be305ab1e76d Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Sat, 3 Oct 2026 11:54:49 +0530 Subject: [PATCH] fix(rust): extract macro_rules! definitions and resolve their invocations A `macro_rules! name { ... }` is a named, invocable item, but its node type (macro_definition) had no branch, so the macro was dropped entirely: it never became a node, and a `name!(...)` invocation of it (a macro_invocation, never a call_expression) had nothing to resolve to. Emit a node per macro_definition and resolve a bare `name!` invocation to it with a calls edge. Macros are tracked in their own name registry, not the function/type label map, because a macro and a function can share a name (`vec!` vs `vec`); the node id is macro-qualified for the same reason and the label carries a `!`. A scoped invocation (`log::info!`) is cross-module/crate and stays unresolved (fail-closed). Adds regression tests for the dropped definition, the resolved local invocation, name coexistence with a same-named function, and the unresolved scoped case. (cherry picked from commit 70103222503c29fc115ab1999f121a4a75eb210b) --- graphify/extractors/rust.py | 52 +++++++++++++++++++ tests/test_rust_macro_rules.py | 93 ++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 tests/test_rust_macro_rules.py diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index eefba065b..621d10167 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -197,6 +197,13 @@ def extract_rust(path: Path) -> dict: _scan_local_types(root) local_type_names = frozenset(local_types) + # `macro_rules!` macros defined in this file, keyed by bare name. A macro and a + # function can share a name in Rust (`vec!` vs `vec`), and a macro is invoked + # as `name!(...)` (a `macro_invocation`, never a `call_expression`), so macros + # 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] = {} + def add_node(nid: str, label: str, line: int) -> None: if nid not in seen_ids: seen_ids.add(nid) @@ -382,6 +389,23 @@ def extract_rust(path: Path) -> dict: emit_param_return_refs(node, func_nid, line) return + if t == "macro_definition": + # `macro_rules! name { ... }` defines a named, invocable item. This node + # type had no branch, so the macro was dropped entirely: it was never a + # node, and a `name!(...)` invocation of it had nothing to resolve to. + # The id is macro-qualified so it never collides with a same-named fn or + # type (`vec!` vs `vec`); the `!` suffix in the label marks it a macro. + name_node = node.child_by_field_name("name") + if name_node: + macro_name = _read_text(name_node, source) + if macro_name: + line = node.start_point[0] + 1 + macro_nid = _make_id(stem, "macro", macro_name) + add_node(macro_nid, f"{macro_name}!", line) + add_edge(file_nid, macro_nid, "contains", line) + macro_nids_by_name.setdefault(macro_name, macro_nid) + return + if t in ("struct_item", "enum_item", "trait_item"): name_node = node.child_by_field_name("name") if name_node: @@ -647,6 +671,34 @@ def extract_rust(path: Path) -> dict: ) -> None: if node.type == "function_item": return + if node.type == "macro_invocation": + # `name!(...)` invoking a macro_rules! macro defined in this file. + # Resolve only a bare `identifier` macro against the local registry; + # a `scoped_identifier` (`log::info!`) is cross-module/crate and stays + # unresolved (fail-closed), matching how scoped calls are handled above. + macro_node = node.child_by_field_name("macro") + if macro_node is not None and macro_node.type == "identifier": + macro_name = _read_text(macro_node, source) + tgt_nid = macro_nids_by_name.get(macro_name) + if tgt_nid and tgt_nid != caller_nid: + pair = (caller_nid, tgt_nid) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + line = node.start_point[0] + 1 + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{line}", + "weight": 1.0, + }) + # Fall through to the generic child recursion below rather than + # returning, preserving the prior traversal of the invocation's + # subtree (its arguments are a raw token tree, so this neither adds + # nor drops any nested edges relative to before). if node.type == "call_expression": func_node = node.child_by_field_name("function") callee_name: str | None = None diff --git a/tests/test_rust_macro_rules.py b/tests/test_rust_macro_rules.py new file mode 100644 index 000000000..2bfaa30ab --- /dev/null +++ b/tests/test_rust_macro_rules.py @@ -0,0 +1,93 @@ +"""Rust `macro_rules!` definitions and their invocations. + +A `macro_rules! name { ... }` is a named, invocable item, but its node type +(`macro_definition`) had no branch in the extractor, so the macro was dropped +entirely: it never became a node and a `name!(...)` invocation of it (a +`macro_invocation`, never a `call_expression`) had nothing to resolve to. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +tsr = pytest.importorskip("tree_sitter_rust") + +from graphify.extractors.rust import extract_rust + + +def _extract(tmp_path: Path, body: str) -> dict: + f = tmp_path / "lib.rs" + f.write_text(body, encoding="utf-8") + return extract_rust(f) + + +def test_macro_rules_definition_becomes_a_node(tmp_path): + r = _extract( + tmp_path, + "macro_rules! my_vec {\n" + " ($($x:expr),*) => { vec![$($x),*] };\n" + "}\n", + ) + assert "error" not in r + labels = {n["label"] for n in r["nodes"]} + # Pre-fix the macro_definition was dropped: no node at all. + assert "my_vec!" in labels, f"macro_rules! definition dropped: {sorted(labels)}" + + +def test_macro_invocation_resolves_to_local_macro(tmp_path): + r = _extract( + tmp_path, + "macro_rules! my_vec {\n" + " ($($x:expr),*) => { vec![$($x),*] };\n" + "}\n" + "fn build() -> Vec {\n" + " my_vec!(1, 2, 3)\n" + "}\n", + ) + id_to_label = {n["id"]: n["label"] for n in r["nodes"]} + calls = { + (id_to_label.get(e["source"], e["source"]), + id_to_label.get(e["target"], e["target"])) + for e in r["edges"] if e["relation"] == "calls" + } + assert ("build()", "my_vec!") in calls + + +def test_macro_does_not_collide_with_same_named_function(tmp_path): + """A macro and a function may share a name (`vec!` vs `vec`). The macro id is + macro-qualified, so both coexist and the invocation binds to the macro only.""" + r = _extract( + tmp_path, + "macro_rules! thing {\n" + " () => { 0 };\n" + "}\n" + "fn thing() -> i32 { 1 }\n" + "fn run() -> i32 {\n" + " thing!()\n" + "}\n", + ) + labels = sorted(n["label"] for n in r["nodes"]) + assert "thing!" in labels + assert "thing()" in labels + id_to_label = {n["id"]: n["label"] for n in r["nodes"]} + calls = { + (id_to_label.get(e["source"], e["source"]), + id_to_label.get(e["target"], e["target"])) + for e in r["edges"] if e["relation"] == "calls" + } + # The `thing!()` invocation binds to the macro, not the function. + assert ("run()", "thing!") in calls + assert ("run()", "thing()") not in calls + + +def test_scoped_macro_invocation_stays_unresolved(tmp_path): + """A `log::info!(..)` invocation is cross-module/crate; it must not bind to any + local node (fail-closed), matching how scoped calls are handled.""" + r = _extract( + tmp_path, + "fn run() {\n" + " log::info!(\"hi\");\n" + "}\n", + ) + assert not [e for e in r["edges"] if e["relation"] == "calls"]