diff --git a/graphify/extractors/rust.py b/graphify/extractors/rust.py index 908d477b5..bc79d64c2 100644 --- a/graphify/extractors/rust.py +++ b/graphify/extractors/rust.py @@ -322,19 +322,17 @@ 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. 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. + # here ends up as a node. A type is matched by its exact name, since a `type + # HANDLE` alias or an imported `HANDLE` folds to the id of a local `struct + # Handle`. Distinct names that fold to one id get distinct ids (see + # file_item_nid below), so a declared type is pre-registered unless a + # non-type item has its exact name (`struct Foo {}` beside `fn Foo()`, which + # still share one node); then 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` + # folds to `struct Handle`'s id, `impl Tr for &Handle<'_>` too); 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() declared_type_names: dict[str, set[str]] = {} def _scan_type_items(node) -> None: @@ -350,7 +348,6 @@ def extract_rust(path: Path) -> dict: 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) declared_type_names.setdefault(nid, set()).add(name) return if t == "impl_item": @@ -370,10 +367,12 @@ def extract_rust(path: Path) -> dict: impl_bare_names: dict[int, str] = {} _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 - } + local_type_names = frozenset( + name + for nid, names in declared_type_names.items() + for name in names + if all(is_type for is_type, item_name in items_by_id[nid] if item_name == name) + ) # The same scan is the collision census for file-level items (#3302's rule in # Rust's shape). Distinct names that fold to one id (`fn ignore` / `struct diff --git a/tests/test_multilang.py b/tests/test_multilang.py index 913982227..96dfb0a8f 100644 --- a/tests/test_multilang.py +++ b/tests/test_multilang.py @@ -587,23 +587,46 @@ 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): +def test_rust_forward_reference_to_a_casefold_colliding_type_resolves_to_it(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.""" + `const HANDLE`, `struct HANDLE` or an `impl` for `HANDLE` fold to the id of + `struct Handle`. Those items now get ids of their own (#3302's rule), so the + struct owns its id and a reference above the declarations resolves to it, as + a reference below already did, instead of to a sourceless stub.""" extern_fn = 'extern "C" {\n fn handle();\n}' impl_for_alias = "type HANDLE = usize;\n\ntrait Tr {}\n\nimpl Tr for HANDLE {}" + for name, other in ( + ("fn_collision.rs", "fn handle() {}"), + ("extern_fn_collision.rs", extern_fn), + ("const_collision.rs", "const HANDLE: u32 = 0;"), + ("static_collision.rs", "static HANDLE: u32 = 0;"), + ("type_collision.rs", "struct HANDLE;"), + ("impl_collision.rs", impl_for_alias), + ): + p = tmp_path / name + p.write_text( + f"fn user(h: &Handle) {{}}\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"]} + handle = next(n for n in r["nodes"] if n["label"] == "Handle") + targets = [ + e["target"] for e in r["edges"] + if e["relation"] == "references" and e["target"] in by_id + ] + assert targets == [handle["id"]], name + assert handle["source_file"] == str(p), name + + +def test_rust_forward_reference_keeps_its_stub_when_a_non_type_shares_the_name(tmp_path): + """#3782: a `type HANDLE` alias is not a node, and `fn Handle()` beside + `struct Handle {}` has the type's exact name, so the two still share one node + that walk() gives to whichever comes first. A reference above the + declarations keeps the sourceless stub v8's extractor gives it.""" 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"), + ("same_name_fn.rs", "fn user(h: &Handle) {}", "fn Handle() {}", "Handle"), ): p = tmp_path / name p.write_text( diff --git a/tests/test_rust_case_collisions.py b/tests/test_rust_case_collisions.py index 45e3caf66..b847e9d3b 100644 --- a/tests/test_rust_case_collisions.py +++ b/tests/test_rust_case_collisions.py @@ -138,3 +138,24 @@ def test_cfg_twins_still_share_one_node(tmp_path: Path) -> None: assert len(_sourced(result, "device_num()")) == 1 assert len(_sourced(result, "Device_Num")) == 1 assert _sourced(result, "Device_Num")[0]["id"] == twin_nodes[0]["id"] + + +def test_forward_reference_to_the_retained_type_lands_on_it(tmp_path: Path) -> None: + """A reference above ``struct Ignore`` in a file that also has ``fn ignore`` + resolves to the struct even when another file declares an ``Ignore`` too, + so the corpus-level rewire cannot fold a sourceless stub back onto it.""" + (tmp_path / "dir.rs").write_text( + "fn before() -> Ignore {\n ignore()\n}\n\n" + _IGNORE_RS, encoding="utf-8" + ) + (tmp_path / "other.rs").write_text("pub struct Ignore;\n", encoding="utf-8") + result = extract(sorted(tmp_path.glob("*.rs")), cache_root=tmp_path, root=tmp_path, + parallel=False) + by_id = {n["id"]: n for n in result["nodes"]} + before = next(n["id"] for n in result["nodes"] if n.get("label") == "before()") + targets = [ + by_id[e["target"]] for e in result["edges"] + if e.get("relation") == "references" and e.get("source") == before + ] + assert [(n["label"], Path(n["source_file"]).name if n.get("source_file") else "") + for n in targets] == [("Ignore", "dir.rs")] +