From 93fa872e2ceefab88aabec30fd1fde607d5ccb45 Mon Sep 17 00:00:00 2001 From: Yyunozor Date: Mon, 28 Sep 2026 22:03:02 +0200 Subject: [PATCH] fix(rust): keep a forward reference unresolved when its type id collides with another item (#3782) Ids are casefolded, so `fn handle`, `const HANDLE`, `struct HANDLE` or an `impl` for `HANDLE` share the id of `struct Handle`, and walk() keeps whichever item comes first. The pre-scan registered the type id regardless, so a reference placed above the declarations was bound to that shared id and could end on the function or on the other item, where v8's extractor left a sourceless stub. For the same reason a `HANDLE` reference (a `type HANDLE` alias, or an imported name) placed above a local `struct Handle` was bound to the struct. Only pre-register a type when every file-level item behind its id is that same type (`#[cfg]` twins count once), and match it by its exact name. An `impl` block counts as the type it names, since walk() gives it the node of its type text; methods keep impl-qualified ids and are not counted. Otherwise the forward reference keeps its stub. A reference placed after an item that already owns the id still resolves through seen_ids, unchanged from v8. Adds regression cases for a type colliding with a function (also in an `extern` block), a const, a static, another type, an `impl` for another type and a type alias, and controls where the id is not shared: another function name, `#[cfg]` twins, an `impl` for the type itself, a method named like the type, and a generic type only an `impl` names. Assisted-by: assayer harness on Claude Code (https://github.com/Yyunozor/assayer-memory-mcp) --- graphify/extractors/rust.py | 43 +++++++++++++++++---- tests/test_multilang.py | 76 ++++++++++++++++++++++++++++++++++--- 2 files changed, 107 insertions(+), 12 deletions(-) diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 694feb5cf..b33ae6114 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -169,27 +169,56 @@ def extract_rust(path: Path) -> dict: # items are pre-registered: walk() never makes a node of a `type` alias or a # `union`, so registering one would leave its references pointing at no node. # The scan never descends further than walk() does, so every id registered - # here ends up as a node. - local_type_ids: set[str] = set() + # here ends up as a node. Ids are casefolded, so `fn handle` and + # `struct Handle` (or `struct HANDLE` and `struct Handle`) share one and + # walk() keeps whichever comes first: a type is only pre-registered when + # every file-level item behind its id is that same type (`#[cfg]` twins count + # once), and it is matched by its exact name, since a `type HANDLE` alias or + # an imported `HANDLE` shares the id of a local `struct Handle`. Otherwise the + # forward reference keeps the sourceless stub v8's extractor gives it. An + # `impl` block counts as the type it names, because walk() gives it the node + # of its type text (`impl Tr for HANDLE` collides with `struct Handle`, + # `impl Tr for &Handle<'_>` does not); methods and impl-scoped items have + # impl-qualified ids and are not counted. + items_by_id: dict[str, set[tuple[bool, str]]] = {} + declared_type_ids: set[str] = set() def _scan_type_items(node) -> None: t = node.type - if t in ("struct_item", "enum_item", "trait_item"): + if t in ("struct_item", "enum_item", "trait_item", "function_item", + "function_signature_item", "static_item", "const_item"): name_node = node.child_by_field_name("name") if name_node: - local_type_ids.add(_make_id(stem, _read_text(name_node, source))) + name = _read_text(name_node, source) + nid = _make_id(stem, name) + is_type = t in ("struct_item", "enum_item", "trait_item") + items_by_id.setdefault(nid, set()).add((is_type, name)) + if is_type: + declared_type_ids.add(nid) return - if t in ("function_item", "function_signature_item", "static_item", - "const_item", "impl_item", "use_declaration"): + if t == "impl_item": + type_node = node.child_by_field_name("type") + if type_node is not None: + type_name = _read_text(type_node, source).strip() + refs: list[tuple[str, str]] = [] + _rust_collect_type_refs(type_node, source, False, refs) + bare = next((ref for ref, role in refs if role == "type"), type_name) + items_by_id.setdefault(_make_id(stem, type_name), set()).add((True, bare)) + return + if t == "use_declaration": return for child in node.children: _scan_type_items(child) _scan_type_items(root) + local_type_names = { + next(iter(items))[1] for nid, items in items_by_id.items() + if nid in declared_type_ids and len(items) == 1 + } def ensure_named_node(name: str, line: int) -> str: nid = _make_id(stem, name) - if nid in seen_ids or nid in local_type_ids: + if nid in seen_ids or name in local_type_names: return nid nid = _make_id(name) if nid not in seen_ids: diff --git a/tests/test_multilang.py b/tests/test_multilang.py index c45fb141e..21ca09f14 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -487,11 +487,12 @@ def test_rust_forward_type_reference_resolves_to_local_declaration(tmp_path): def test_rust_forward_reference_prescan_skips_items_that_are_not_nodes(tmp_path): """#3782: only struct/enum/trait declarations become nodes, so only they may resolve a forward reference. A `type` alias, a `union`, a struct declared in a - function body and a name declared nowhere in the file produce no node here; - references to them must keep their sourceless stub instead of being dropped.""" + function body, a name declared nowhere in the file and a generic type that only + an `impl` names produce no node of that name here; references to them must keep + their sourceless stub instead of being dropped.""" p = tmp_path / "shapes.rs" p.write_text( - "fn user(a: &Alias, u: &Bits, l: &Local, e: &Elsewhere) {}\n" + "fn user(a: &Alias, u: &Bits, l: &Local, e: &Elsewhere, w: &Wrapper) {}\n" "\n" "type Alias = u32;\n" "\n" @@ -499,7 +500,11 @@ def test_rust_forward_reference_prescan_skips_items_that_are_not_nodes(tmp_path) "\n" "fn host() {\n" " struct Local;\n" - "}\n", + "}\n" + "\n" + "trait Tr {}\n" + "\n" + "impl Tr for Wrapper {}\n", encoding="utf-8", ) r = extract_rust(p) @@ -509,7 +514,7 @@ def test_rust_forward_reference_prescan_skips_items_that_are_not_nodes(tmp_path) for e in r["edges"] if e["relation"] == "references" and e["target"] in by_id } - for name in ("Alias", "Bits", "Local", "Elsewhere"): + for name in ("Alias", "Bits", "Local", "Elsewhere", "Wrapper"): assert name in targets, f"reference to {name} was dropped" assert targets[name]["source_file"] == "", name @@ -564,6 +569,67 @@ def test_rust_forward_reference_skips_items_in_const_and_static_initializers(tmp assert targets == {"Hidden": "", "Guarded": ""} +def test_rust_forward_reference_to_a_casefold_colliding_type_keeps_its_stub(tmp_path): + """#3782: ids are casefolded, so `fn handle` (also in an `extern` block), + `const HANDLE`, `struct HANDLE` or an `impl` for `HANDLE` share the id of + `struct Handle` and walk() keeps whichever comes first, and a `type HANDLE` + alias shares it without being a node. A reference above the declarations must + keep the sourceless stub v8's extractor gives it, instead of binding to another + item.""" + extern_fn = 'extern "C" {\n fn handle();\n}' + impl_for_alias = "type HANDLE = usize;\n\ntrait Tr {}\n\nimpl Tr for HANDLE {}" + for name, user, other, label in ( + ("fn_collision.rs", "fn user(h: &Handle) {}", "fn handle() {}", "Handle"), + ("extern_fn_collision.rs", "fn user(h: &Handle) {}", extern_fn, "Handle"), + ("const_collision.rs", "fn user(h: &Handle) {}", "const HANDLE: u32 = 0;", "Handle"), + ("static_collision.rs", "fn user(h: &Handle) {}", "static HANDLE: u32 = 0;", "Handle"), + ("type_collision.rs", "fn user(h: &Handle) {}", "struct HANDLE;", "Handle"), + ("impl_collision.rs", "fn user(h: &Handle) {}", impl_for_alias, "Handle"), + ("alias_collision.rs", "fn user(h: HANDLE) {}", "type HANDLE = usize;", "HANDLE"), + ): + p = tmp_path / name + p.write_text( + f"{user}\n\n{other}\n\nstruct Handle {{\n n: u32,\n}}\n", encoding="utf-8" + ) + r = extract_rust(p) + by_id = {n["id"]: n for n in r["nodes"]} + targets = [ + (by_id[e["target"]]["label"], by_id[e["target"]]["source_file"]) + for e in r["edges"] + if e["relation"] == "references" and e["target"] in by_id + ] + assert targets == [(label, "")], name + + +def test_rust_forward_reference_resolves_when_no_other_item_shares_the_id(tmp_path): + """#3782: the collision rule only withholds a type whose id another item + shares. A function with another name, `#[cfg]` twins of the type itself (one + node in walk()), an `impl` for the type itself or a method named like the type + (its id is qualified by the impl) still let the reference above resolve to it.""" + impl_for_type = ( + "struct Handle<'a> {\n s: &'a str,\n}\n\ntrait Tr {}\n\n" + "impl Tr for Handle<'_> {}\n\nimpl Tr for &Handle<'_> {}\n" + ) + for name, source in ( + ("no_collision.rs", "struct Handle {\n n: u32,\n}\n\nfn other() {}\n"), + ("cfg_twins.rs", "#[cfg(unix)]\nstruct Handle;\n\n#[cfg(not(unix))]\nstruct Handle;\n"), + ("impl_for_type.rs", impl_for_type), + ("method_like_type.rs", "struct Handle;\n\nimpl Handle {\n fn handle(&self) {}\n}\n"), + ): + p = tmp_path / name + p.write_text("fn user(h: &Handle) {}\n\n" + source, encoding="utf-8") + r = extract_rust(p) + handle = next(n for n in r["nodes"] if n["label"] == "Handle") + user = next(n for n in r["nodes"] if n["label"] == "user()") + assert handle["source_file"] == str(p), name + assert any( + e["relation"] == "references" + and e["source"] == user["id"] + and e["target"] == handle["id"] + for e in r["edges"] + ), name + + def test_rust_forward_reference_to_ambiguous_type_name_stays_local(tmp_path): """#3782: when two files declare the same struct, the corpus-level rewire declines the ambiguous stub, so the reference above the declaration must