From 70ff46406482edee0ff0e9cf50b7bea1a609edf9 Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 10 Sep 2026 18:44:26 +0200 Subject: [PATCH] fix(rust): extract module-level static and const declarations The Rust extractor gated every non-function item on struct_item, enum_item and trait_item, so tree-sitter-rust's static_item and const_item never produced a node: a constant existed in the graph only through the files that referenced it, never from the Rust that defines it (0 of 783 names in a jdx/mise snapshot). Add a branch for both node types: a module-level declaration gets a node with a file-level contains edge, an associated const inside an impl is attributed to the impl like a method, and the declared type is referenced the way a struct field type is. Fixes #3471 --- CHANGELOG.md | 1 + graphify/extractors/rust.py | 31 ++++++++++++++++++++++++++++++- tests/fixtures/sample.rs | 8 ++++++++ tests/test_multilang.py | 26 ++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f797354ec..5f915d786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.57 (unreleased) +- Fix: Rust module-level `static` and `const` declarations (and associated consts inside an `impl`) are now extracted as nodes with a `contains` edge and a reference to their declared type — neither node type had a branch, so a constant only ever reached the graph through files that referenced it (#3471, thanks @sortakool). - Fix: an incremental rebuild no longer wipes cross-file project AST nodes — re-extracting one `.csproj`/`.sln` was dropping package/framework nodes of a *referenced* project (whose stub carried the referenced file's `source_file`); the AST-replacement set is now derived from the files actually extracted (#3411, thanks @hopstreax). - Fix: when duplicate nodes merge, the richer (more complete) node is now kept as the survivor and the losers' non-empty fields are folded in, instead of a shorter-id passing mention winning and dropping content (#3372, thanks @abhay-codes07). - Fix: a C# generic call site with explicit type arguments — `Get(...)`, unqualified or through `this` — now resolves to the method definition instead of capturing `Get` as the callee and failing to match (#3406, thanks @abhay-codes07). diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 72cf95619..5891d71c1 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -59,7 +59,7 @@ _RUST_TRAIT_METHOD_BLOCKLIST: frozenset[str] = frozenset({ }) def extract_rust(path: Path) -> dict: - """Extract functions, structs, enums, traits, impl methods, and use declarations from a .rs file.""" + """Extract functions, structs, enums, traits, impl methods, statics/consts, and use declarations from a .rs file.""" try: import tree_sitter_rust as tsrust from tree_sitter import Language, Parser @@ -325,6 +325,35 @@ def extract_rust(path: Path) -> dict: walk(child, parent_impl_nid=item_nid) return + if t in ("static_item", "const_item"): + # `static NAME: T = …;` / `const NAME: T = …;` at module level, or an + # associated const inside an impl. Neither node type had a branch, so a + # constant reached the graph only through files that referenced it, + # never from the Rust that defines it (#3471). + name_node = node.child_by_field_name("name") + if name_node: + item_name = _read_text(name_node, source) + line = node.start_point[0] + 1 + if parent_impl_nid: + item_nid = _make_id(parent_impl_nid, item_name) + add_node(item_nid, f".{item_name}", line) + add_edge(parent_impl_nid, item_nid, "contains", line) + else: + item_nid = _make_id(stem, item_name) + add_node(item_nid, item_name, line) + add_edge(file_nid, item_nid, "contains", line) + type_node = node.child_by_field_name("type") + if type_node is not None: + refs: list[tuple[str, str]] = [] + _rust_collect_type_refs(type_node, source, False, refs) + for ref_name, role in refs: + tgt = ensure_named_node(ref_name, line) + if tgt == item_nid: + continue + ctx = "generic_arg" if role == "generic_arg" else "field" + add_edge(item_nid, tgt, "references", line, context=ctx) + return + if t == "impl_item": type_node = node.child_by_field_name("type") trait_node = node.child_by_field_name("trait") diff --git a/tests/fixtures/sample.rs b/tests/fixtures/sample.rs index 1a9090296..877c0d4f2 100644 --- a/tests/fixtures/sample.rs +++ b/tests/fixtures/sample.rs @@ -1,10 +1,18 @@ use std::collections::HashMap; +pub static RETRY_LIMIT: usize = 3; +pub const DEFAULT_MODE: &str = "fast"; +const NODE_LIMIT: Limit = Limit(3); + +struct Limit(usize); + struct Graph { nodes: HashMap>, } impl Graph { + const CAPACITY: usize = 16; + fn new() -> Self { Graph { nodes: HashMap::new() } } diff --git a/tests/test_multilang.py b/tests/test_multilang.py index 7ed9c6f98..b9901f402 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -315,6 +315,32 @@ def test_rust_calls_are_extracted(): assert e["confidence"] == "EXTRACTED" +def test_rust_finds_static_and_const_items(): + r = extract_rust(FIXTURES / "sample.rs") + by_id = {n["id"]: n["label"] for n in r["nodes"]} + labels = set(by_id.values()) + for name in ("RETRY_LIMIT", "DEFAULT_MODE", "NODE_LIMIT"): + assert name in labels, name + assert any( + e["relation"] == "contains" and by_id.get(e["target"]) == name + for e in r["edges"] + ), f"{name} has no file-level contains edge" + # An associated const is attributed to its impl, like a method. + assert ".CAPACITY" in labels + assert any( + e["relation"] == "contains" + and by_id.get(e["source"]) == "Graph" + and by_id.get(e["target"]) == ".CAPACITY" + for e in r["edges"] + ) + # The declared type is referenced like a struct field type. + assert any( + e["relation"] == "references" + and by_id.get(e["source"]) == "NODE_LIMIT" + and by_id.get(e["target"]) == "Limit" + for e in r["edges"] + ) + def test_rust_import_edges_have_import_context(): r = extract_rust(FIXTURES / "sample.rs") import_edges = _edges_with_relation(r, "imports", "imports_from")