mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 23:49:14 +00:00
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:
1 parent
23d3a2af58
commit
912637d314
2 files changed
+145
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user