Files
graphify/tests/test_rust_case_collisions.py
T
samie a.andClaude Opus 5.5 bef78ffdbd fix(go,rust): keep same-file declarations whose names differ only by case apart
make_id casefolds, so in one file `type ResponseWriter interface` and
`type responseWriter struct` (gin), or `struct Ignore` and `fn ignore`
(ripgrep), mint one node id. The walk keeps whichever
declaration it meets first and drops the other: the dropped struct's
methods hang off the interface, and a dropped function's calls are
credited to the struct.

Go: the #2779 census now also counts `type` declarations, using its
existing rule. The exported type keeps the plain id and the other is
salted; its methods, receiver anchors and in-file type references use the
salted id. Types are counted first so methods are keyed under their
owner's final id.

Rust: the #3782 pre-scan already lists every file-level item behind an
id, so it doubles as the census. The one declared struct/enum/trait in a
group keeps the plain id (type positions and `impl` blocks name it, so it
and its methods do not move) and every other member is salted with a
hash of its own name; with no single declared type, every member is
salted. `#[cfg]` twins are one name and still share one node.

A name that does not collide keeps its id. The census is per file: a
case-only pair split across two files of one Go package is not covered.

Related to #3302 and #2810.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:36:19 +05:00

141 lines
5.0 KiB
Python

"""Rust file-level items whose names fold to one node id (#3302's rule, Rust shape).
``make_id`` casefolds, so ``fn ignore`` / ``struct Ignore`` (ripgrep's
``crates/ignore/src/dir.rs``) or ``fn e`` / ``fn E`` (``crates/regex/src/literal.rs``)
minted one id and walk() dropped whichever came second.
"""
from pathlib import Path
from graphify.extract import extract
_IGNORE_RS = (
"pub struct Ignore {\n"
" parent: u32,\n"
"}\n"
"\n"
"impl Ignore {\n"
" pub fn matched(&self) -> bool {\n"
" true\n"
" }\n"
"}\n"
"\n"
"pub fn ignore() -> Ignore {\n"
" build()\n"
"}\n"
"\n"
"fn build() -> Ignore {\n"
" Ignore { parent: 0 }\n"
"}\n"
)
def _extract(tmp_path: Path, source: str, name: str = "dir.rs") -> dict:
path = tmp_path / name
path.write_text(source, encoding="utf-8")
return extract([path], cache_root=tmp_path, root=tmp_path, parallel=False)
def _sourced(result: dict, label: str) -> list[dict]:
return [n for n in result["nodes"] if n.get("label") == label and n.get("source_file")]
def _edges(result: dict, relation: str) -> set[tuple[str, str]]:
by_id = {n["id"]: n["label"] for n in result["nodes"]}
return {
(by_id.get(e["source"], ""), by_id.get(e["target"], ""))
for e in result["edges"]
if e.get("relation") == relation
}
def test_function_and_struct_differing_by_case_are_both_extracted(tmp_path: Path) -> None:
result = _extract(tmp_path, _IGNORE_RS)
struct = _sourced(result, "Ignore")
func = _sourced(result, "ignore()")
assert [n["source_location"] for n in struct] == ["L1"], struct
assert [n["source_location"] for n in func] == ["L11"], func
assert struct[0]["id"] != func[0]["id"]
def test_struct_keeps_its_id_and_methods_when_a_function_shares_it(tmp_path: Path) -> None:
"""The declared type keeps the plain id, so it and its methods do not move."""
solo = _extract(tmp_path, _IGNORE_RS.replace("pub fn ignore() -> Ignore {\n build()\n}\n", ""))
solo_ids = {n["id"] for n in solo["nodes"] if n.get("label") in ("Ignore", ".matched()")}
assert len(solo_ids) == 2
result = _extract(tmp_path, _IGNORE_RS)
ids = {n["id"] for n in result["nodes"] if n.get("label") in ("Ignore", ".matched()")}
assert ids == solo_ids
assert ("Ignore", ".matched()") in _edges(result, "method")
def test_salted_function_owns_its_own_body(tmp_path: Path) -> None:
"""The call inside ``ignore()`` is credited to ``ignore()``, not to the struct."""
result = _extract(tmp_path, _IGNORE_RS)
calls = _edges(result, "calls")
assert ("ignore()", "build()") in calls, calls
assert not any(src == "Ignore" for src, _ in calls), calls
assert ("ignore()", "Ignore") in _edges(result, "references")
def test_type_keeps_the_plain_id_whatever_the_declaration_order(tmp_path: Path) -> None:
forward = {(n["label"], n["id"]) for n in _extract(tmp_path, _IGNORE_RS)["nodes"]
if n.get("source_file")}
reordered = (
"pub fn ignore() -> Ignore {\n"
" build()\n"
"}\n"
"\n"
"fn build() -> Ignore {\n"
" Ignore { parent: 0 }\n"
"}\n"
"\n"
"impl Ignore {\n"
" pub fn matched(&self) -> bool {\n"
" true\n"
" }\n"
"}\n"
"\n"
"pub struct Ignore {\n"
" parent: u32,\n"
"}\n"
)
backward = {(n["label"], n["id"]) for n in _extract(tmp_path, reordered)["nodes"]
if n.get("source_file")}
assert forward == backward
def test_case_only_functions_are_both_salted(tmp_path: Path) -> None:
"""With no declared type in the group, neither member keeps the plain id."""
lower_src = "fn e(s: &str) -> u32 {\n 1\n}\n"
plain = _sourced(_extract(tmp_path, lower_src, name="literal.rs"), "e()")[0]["id"]
result = _extract(
tmp_path, lower_src + "\nfn E(s: &str) -> u32 {\n 2\n}\n", name="literal.rs"
)
lower, upper = _sourced(result, "e()"), _sourced(result, "E()")
assert len(lower) == 1 and len(upper) == 1
assert lower[0]["id"] != upper[0]["id"]
assert plain not in {lower[0]["id"], upper[0]["id"]}
def test_cfg_twins_still_share_one_node(tmp_path: Path) -> None:
"""``#[cfg]`` variants of one function are one symbol and keep the plain id."""
twins = (
"#[cfg(unix)]\n"
"fn device_num(p: &str) -> u64 {\n 1\n}\n"
"\n"
"#[cfg(windows)]\n"
"fn device_num(p: &str) -> u64 {\n 2\n}\n"
)
solo = _extract(tmp_path, twins, name="walk.rs")
twin_nodes = _sourced(solo, "device_num()")
assert [n["source_location"] for n in twin_nodes] == ["L2"]
# A case-only sibling splits the group, but the twins stay one node.
result = _extract(tmp_path, twins + "\nstruct Device_Num;\n", name="walk.rs")
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"]