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)
This commit is contained in:
Yyunozor committed 2026-09-29 08:29:48 +02:00
1 parent d73c4adf59
commit 93fa872e2c
2 files changed
+107 -12

No files matched your search

+36 -7
View File
@@ -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:
+71 -5
View File
@@ -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<u8>) {}\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<T> Tr for Wrapper<T> {}\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