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 7010322250)
This commit is contained in:
rajatnagda45 authored and safishamsi committed 2026-10-04 01:43:04 +01:00
1 parent 23d3a2af58
commit 912637d314
2 files changed
+145

No files matched your search

+52
View File
@@ -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
+93
View File
@@ -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<i32> {\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"]