fix(rust): resolve a forward reference to a type that keeps its id beside a case-only sibling

#3782 left a reference placed above `struct Handle` on a sourceless stub
whenever another file-level item folded to the same id (`fn handle`,
`const HANDLE`, an `impl` for `HANDLE`), because walk() gave that id to
whichever item came first. Those items now get ids of their own, so the
struct owns its id and the reference can resolve to it, as a reference
below the declaration already did. Without this, the stub only folds back
when the type's name is unique across the corpus.

A declared type is now pre-registered unless a non-type item has its
exact name (`fn Foo()` beside `struct Foo {}` still share one node), and a
`type HANDLE` alias still keeps its stub. The #3782 test that pinned the
stub for the case-only shapes now asserts the resolution; the shapes that
still share a node keep the stub assertion.

local_type_names becomes a frozenset, which is what
_rust_collect_type_refs is annotated to take.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
samie a.andClaude Opus 5.5 committed 2026-10-15 15:12:22 +05:00
1 parent bef78ffdbd
commit f710c8a16e
3 files changed
+72 -29

No files matched your search

+16 -17
View File
@@ -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
+35 -12
View File
@@ -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(
+21
View File
@@ -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")]