fix(cpp): extract namespace-scope using/typedef aliases as type nodes (#4580)

Applied from PR #4580 (authorship preserved); stale cache-schema bump dropped for one combined bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
madhusudanpatnaikandClaude Opus 4.8 authored and safishamsi committed 2026-10-23 18:15:21 +01:00
1 parent 8cfda7f1ce
commit 3bab26798e
3 files changed
+267 -3

No files matched your search

+79
View File
@@ -4848,6 +4848,70 @@ def _scala_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: s
return True
return False
def _cpp_declarator_type_name(declarator, source: bytes) -> str | None:
"""The name a typedef declarator introduces: `Handle`, `*HandlePtr`,
`(*Fn)(int)`, `Arr[4]` each wrap one `type_identifier`."""
node = declarator
while node is not None and node.type != "type_identifier":
inner = node.child_by_field_name("declarator")
if inner is None and node.type == "parenthesized_declarator":
inner = next(iter(node.named_children), None)
node = inner
return _read_text(node, source) if node is not None else None
def _cpp_type_last_name(type_node, source: bytes) -> str | None:
"""`Bar` for `ns::Bar` or `ns::Bar<T>`: the name a qualified type ends in."""
if type_node is not None and type_node.type == "type_descriptor":
type_node = type_node.child_by_field_name("type")
if type_node is None or type_node.type != "qualified_identifier":
return None
while type_node is not None and type_node.type == "qualified_identifier":
type_node = type_node.child_by_field_name("name")
if type_node is not None and type_node.type == "template_type":
type_node = type_node.child_by_field_name("name")
return _read_text(type_node, source) if type_node is not None else None
def _cpp_alias_names(node, source: bytes) -> list[str]:
"""The type names a namespace- or file-scope `using X = ...;` or `typedef`
declares. A member alias (`using value_type = T;` in a class) is not one: it
sits in every container and C++ member ids are not class-qualified, so they
would fold into one hub. Neither is the struct a C-style
`typedef struct Foo {...} Foo;` names (the struct's own node is it), nor a
same-name re-export (`using Bar = ns::Bar;`), which denotes the original
type and declares no new name. An include guard or `#if`/`#ifdef` block is
a conditional wrapper, not a scope, so an alias inside one keeps the scope
around it."""
scope = node.parent
while scope is not None and (
scope.type == "template_declaration" or scope.type.startswith("preproc_")):
scope = scope.parent
if scope is None or scope.type not in ("translation_unit", "declaration_list"):
return []
target = node.child_by_field_name("type")
if node.type == "alias_declaration":
name_node = node.child_by_field_name("name")
candidates = [_read_text(name_node, source)] if name_node is not None else []
else:
candidates = [_cpp_declarator_type_name(d, source)
for d in node.children_by_field_name("declarator")]
own_names = {_cpp_type_last_name(target, source)}
if target is not None and target.type in (
"struct_specifier", "class_specifier", "union_specifier", "enum_specifier"):
spec_name = target.child_by_field_name("name")
if spec_name is not None:
own_names.add(_read_text(spec_name, source))
# A set, not `in names`: one typedef can declare thousands of names.
names: list[str] = []
seen = set(own_names)
for name in candidates:
if name and name not in seen:
seen.add(name)
names.append(name)
return names
def _cpp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
nodes: list, edges: list, seen_ids: set, function_bodies: list,
parent_class_nid: str | None, add_node_fn, add_edge_fn) -> bool:
@@ -4858,7 +4922,22 @@ def _cpp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str
`enumerator` node (`Red`, `Green = 2`) whose name is its `name` field
(an `identifier`). Without this the enum type is a memberless leaf. This is
the C++ parity of Java #1719 (enum_constant), Swift, and Scala.
A namespace- or file-scope alias (`using X = ...;`, `typedef ... X;`) also
gets a type node contained by the file, so a type reference to it lands on
a sourced declaration instead of a sourceless stub. Returns False for it:
the walk goes on, so a typedef'd struct body still gets its own node.
C++ never populates namespace_stack, so the id is the one
ensure_named_node looks up for a name declared in this file.
"""
if node.type in ("alias_declaration", "type_definition"):
line = node.start_point[0] + 1
for alias_name in _cpp_alias_names(node, source):
alias_nid = _make_id(stem, alias_name)
if alias_nid not in seen_ids:
add_node_fn(alias_nid, alias_name, line)
add_edge_fn(file_nid, alias_nid, "contains", line)
return False
if node.type == "enumerator" and parent_class_nid:
name_node = node.child_by_field_name("name")
if name_node is None:
+14 -3
View File
@@ -42,8 +42,15 @@ def test_cpp_enum_specifier_members_are_extracted(tmp_path):
assert ("Old", "Y") in case_of
# An anonymous enum (`typedef enum { A, B } Flag`) has no type name, so it is
# skipped rather than emitting a nameless node; its members do not appear.
# The typedef name `Flag` is a declared type of its own (an alias node, with
# no members of its own) since namespace-scope typedefs are extracted.
assert "A" not in labels
assert "Flag" not in labels
assert "" not in labels
assert ("colors.hpp", "Flag") in {
(ids.get(e["source"]), ids.get(e["target"]))
for e in result["edges"] if e["relation"] == "contains"
}
assert not any(src == "Flag" for src, _ in case_of)
def test_cpp_enum_nested_in_class_and_namespace_is_extracted(tmp_path):
@@ -119,8 +126,12 @@ def test_cpp_union_specifier_is_extracted(tmp_path):
assert ("Value", "i") in defines
assert ("Value", "f") in defines
# An anonymous union (`typedef union { ... } Anon`) has no type name, so it is
# skipped rather than emitting a nameless node, like an anonymous enum.
assert "Anon" not in labels
# skipped rather than emitting a nameless node, like an anonymous enum. The
# typedef name `Anon` is an alias node of its own, owning none of the members.
assert "" not in labels
assert ("value.hpp", "Anon") in contains
assert not any(src == "Anon" for src, _ in defines)
assert not {"a", "b"} & labels
def test_cpp_union_member_function_is_a_method(tmp_path):
+174
View File
@@ -0,0 +1,174 @@
"""C++ `using X = ...;` and `typedef ... X;` at namespace scope are declarations.
The C++ extractor emitted no node for an alias declaration or a typedef, so a
field, parameter or return typed with one, even in the declaring header,
resolved to a SOURCELESS stub that the corpus rewire can grow into a hub. A
namespace- or file-scope alias is now a type node contained by its file; every
declarator of a typedef counts (`typedef int Handle, *HandlePtr;`,
`typedef int (*Fn)(int);`, `typedef int Arr[4];`), and an alias template
(`template <class T> using Ptr = T*;`) too. An include guard or an
`#if`/`#ifdef` block is a conditional wrapper, not a scope: an alias inside
one at file scope is still file-scope.
Not declarations here: a member alias (`using value_type = T;` in a class: one
in every container, and C++ class-member ids are not class-qualified, so they
would fold into one hub); the struct a C-style `typedef struct Foo {...} Foo;`
names (the struct's own node is it); and a same-name re-export
(`using Bar = ns::Bar;`), which denotes the original type and declares no new
name.
"""
from __future__ import annotations
import importlib.util
import textwrap
from pathlib import Path
import pytest
from graphify.extract import extract
pytestmark = pytest.mark.skipif(
importlib.util.find_spec("tree_sitter_cpp") is None,
reason="tree_sitter_cpp not installed",
)
_HEADER = textwrap.dedent("""\
#include <vector>
#include <functional>
namespace app {
using IntVec = std::vector<int>;
typedef int Handle, *HandlePtr;
typedef struct { int x; } Point;
typedef int (*Fn)(int);
template <typename T> using Ptr = T*;
using Callback = std::function<void(int)>;
struct Widget {
using value_type = int;
typedef int size_type;
IntVec items;
Handle h;
Point p;
Fn f;
Ptr<int> raw;
Callback cb;
IntVec get(Handle h, Callback cb);
};
}
""")
def _extract(tmp_path: Path, files: dict[str, str]) -> dict:
for name, body in files.items():
(tmp_path / name).parent.mkdir(parents=True, exist_ok=True)
(tmp_path / name).write_text(body, encoding="utf-8")
return extract([tmp_path / n for n in files], root=tmp_path,
cache_root=tmp_path / "graphify-out", parallel=False)
def _labels(result: dict) -> dict[str, str]:
return {n["id"]: n["label"] for n in result["nodes"]}
def _pairs(result: dict, relation: str) -> set[tuple[str, str]]:
label = _labels(result)
return {(label.get(e["source"], str(e["source"])), label.get(e["target"], str(e["target"])))
for e in result["edges"] if e["relation"] == relation}
def test_each_namespace_scope_alias_is_a_sourced_node_on_its_line(tmp_path: Path):
result = _extract(tmp_path, {"widget.hpp": _HEADER})
expected = {"IntVec": "L4", "Handle": "L5", "HandlePtr": "L5", "Point": "L6",
"Fn": "L7", "Ptr": "L8", "Callback": "L9"}
for label, line in expected.items():
matches = [n for n in result["nodes"] if n["label"] == label]
assert len(matches) == 1, label
assert matches[0]["source_file"], label
assert matches[0]["source_location"] == line, label
assert [n["label"] for n in result["nodes"] if not n.get("source_file")] == []
def test_each_alias_is_contained_by_its_file(tmp_path: Path):
contained = {tgt for _src, tgt in _pairs(_extract(tmp_path, {"widget.hpp": _HEADER}), "contains")}
assert {"IntVec", "Handle", "HandlePtr", "Point", "Fn", "Ptr", "Callback"} <= contained
def test_fields_reference_the_local_aliases(tmp_path: Path):
refs = _pairs(_extract(tmp_path, {"widget.hpp": _HEADER}), "references")
assert {("Widget", "IntVec"), ("Widget", "Handle"), ("Widget", "Point"), ("Widget", "Fn"),
("Widget", "Ptr"), ("Widget", "Callback")} <= refs
def test_member_aliases_are_not_file_level_nodes(tmp_path: Path):
result = _extract(tmp_path, {"widget.hpp": _HEADER})
assert [n for n in result["nodes"] if n["label"] in ("value_type", "size_type")] == []
def test_a_c_style_typedef_of_a_named_struct_is_the_struct(tmp_path: Path):
result = _extract(tmp_path, {"foo.hpp": "typedef struct Foo { int y; } Foo;\nstruct User { Foo f; };\n"})
foos = [n for n in result["nodes"] if n["label"] == "Foo"]
assert len(foos) == 1 and foos[0]["source_file"]
assert ("User", "Foo") in _pairs(result, "references")
def test_a_same_name_re_export_emits_no_node(tmp_path: Path):
result = _extract(tmp_path, {"api.hpp": "namespace ns { struct Bar {}; }\nusing Bar = ns::Bar;\n"})
bars = [n for n in result["nodes"] if n["label"] == "Bar"]
assert len(bars) == 1
assert bars[0]["source_location"] == "L1"
def test_a_reference_from_another_file_lands_on_the_alias(tmp_path: Path):
result = _extract(tmp_path, {
"types.hpp": "#include <vector>\nusing IntVec = std::vector<int>;\n",
"user.hpp": '#include "types.hpp"\nstruct Bag { IntVec v; };\n',
})
[alias] = [n for n in result["nodes"] if n["label"] == "IntVec"]
assert alias["source_file"].endswith("types.hpp")
label = _labels(result)
targets = {e["target"] for e in result["edges"]
if e["relation"] == "references" and label.get(e["source"]) == "Bag"}
assert targets == {alias["id"]}
_GUARDED = textwrap.dedent("""\
#ifndef CONFIG_HPP
#define CONFIG_HPP
using Guarded = int;
typedef long GuardedT;
#if defined(USE_WIDE)
using Char = wchar_t;
#elif defined(USE_U16)
using Char16 = char16_t;
#else
using Narrow = char;
#endif
#ifdef HAVE_POOL
template <typename T> using Pool = T*;
#endif
struct Config {
#ifdef DEBUG
using debug_type = int;
#endif
Guarded g;
GuardedT t;
Narrow n;
};
#endif
""")
def test_aliases_inside_include_guards_and_conditionals_are_nodes(tmp_path: Path):
result = _extract(tmp_path, {"config.hpp": _GUARDED})
expected = {"Guarded": "L3", "GuardedT": "L4", "Char": "L6", "Char16": "L8",
"Narrow": "L10", "Pool": "L13"}
for label, line in expected.items():
matches = [n for n in result["nodes"] if n["label"] == label]
assert len(matches) == 1, label
assert matches[0]["source_file"], label
assert matches[0]["source_location"] == line, label
assert {("Config", "Guarded"), ("Config", "GuardedT"), ("Config", "Narrow")} <= _pairs(result, "references")
def test_a_member_alias_inside_a_conditional_is_still_a_member(tmp_path: Path):
result = _extract(tmp_path, {"config.hpp": _GUARDED})
assert [n for n in result["nodes"] if n["label"] == "debug_type"] == []