diff --git a/graphify/extractors/objc.py b/graphify/extractors/objc.py index 8b2820f46..b2e510850 100644 --- a/graphify/extractors/objc.py +++ b/graphify/extractors/objc.py @@ -226,6 +226,30 @@ def extract_objc(path: Path) -> dict: if entry is not None: _record_field_type(cls_nid, *entry) + def _emit_enum(enum_spec, name: str, line: int) -> None: + """Emit a C-style enum type node plus a node + `case_of` edge per + enumerator, mirroring how every other language with enums models them + (Java #1719, C++ #3939, Swift). Objective-C is an object-oriented + extractor that already emits @interface/@protocol types, but plain + `enum`/`typedef enum` definitions were dropped entirely. + """ + enum_nid = _make_id(stem, name) + add_node(enum_nid, name, line) + add_edge(file_nid, enum_nid, "contains", line) + body = next((c for c in enum_spec.children if c.type == "enumerator_list"), None) + if body is None: + return + for member in body.children: + if member.type != "enumerator": + continue + mname = next((_read(c) for c in member.children if c.type == "identifier"), None) + if not mname: + continue + m_line = member.start_point[0] + 1 + member_nid = _make_id(enum_nid, mname) + add_node(member_nid, mname, m_line) + add_edge(enum_nid, member_nid, "case_of", m_line) + def walk(node, parent_nid: str | None = None) -> None: t = node.type line = node.start_point[0] + 1 @@ -408,6 +432,42 @@ def extract_objc(path: Path) -> dict: method_bodies.append((method_nid, node, container)) return + if t == "type_definition": + # `typedef enum { ... } Name;` / `typedef enum Tag { ... } Name;`. + # The enum body sits on a child enum_specifier that carries no usable + # name; the name callers reference is the typedef alias (the trailing + # type_identifier). NS_ENUM/NS_OPTIONS never reach here — they are + # macro_type_specifiers that tree-sitter parses into ERROR subtrees, + # so they remain unsupported. + enum_spec = next((c for c in node.children if c.type == "enum_specifier"), None) + if enum_spec is not None and any( + c.type == "enumerator_list" for c in enum_spec.children + ): + alias = next( + (_read(c) for c in reversed(node.children) if c.type == "type_identifier"), + None, + ) + if alias is None: + alias = next( + (_read(c) for c in enum_spec.children if c.type == "type_identifier"), + None, + ) + if alias: + _emit_enum(enum_spec, alias, line) + return + # Not an enum typedef: fall through to the generic recursion. + + if t == "enum_specifier": + # A named C-style enum definition: `enum Color { Red, Green };`. Skip + # a bare reference / forward declaration (no enumerator_list) and an + # anonymous enum (no name): neither is a nameable definition. + name = next( + (_read(c) for c in node.children if c.type == "type_identifier"), None + ) + if name and any(c.type == "enumerator_list" for c in node.children): + _emit_enum(node, name, line) + return + for child in node.children: walk(child, parent_nid) diff --git a/tests/test_objc_enum_declarations.py b/tests/test_objc_enum_declarations.py new file mode 100644 index 000000000..392ba03d7 --- /dev/null +++ b/tests/test_objc_enum_declarations.py @@ -0,0 +1,85 @@ +"""C-style enum extraction for Objective-C. + +Objective-C is an object-oriented extractor that already emits @interface, +@implementation and @protocol type nodes, but plain `enum` and `typedef enum` +definitions were dropped entirely — the type and all of its enumerators were +invisible. Every other language with enums emits a type node plus a `case_of` +edge per member (Java #1719, C++ #3939, Swift); ObjC now matches. + +The NS_ENUM/NS_OPTIONS macro spelling is deliberately out of scope: it is a +macro call that tree-sitter-objc parses into an ERROR subtree, so there is no +clean AST to read. +""" +from __future__ import annotations + +from graphify.extract import extract + + +def _edge_labels(result: dict, relation: str) -> set[tuple[str, str]]: + labels = {node["id"]: node["label"] for node in result["nodes"]} + return { + (labels.get(edge["source"], edge["source"]), labels.get(edge["target"], edge["target"])) + for edge in result["edges"] + if edge["relation"] == relation + } + + +def test_objc_plain_enum_emits_type_and_case_of_members(tmp_path): + source = tmp_path / "Color.m" + source.write_text( + "enum Color { Red, Green, Blue };\n" + "@interface Foo : NSObject\n@end\n" + "@implementation Foo\n@end\n", + encoding="utf-8", + ) + + result = extract([source], cache_root=tmp_path) + + labels = {n["label"] for n in result["nodes"]} + assert "Color" in labels, "enum type dropped" + case_of = _edge_labels(result, "case_of") + assert ("Color", "Red") in case_of + assert ("Color", "Green") in case_of + assert ("Color", "Blue") in case_of + # The enum does not disturb the surrounding class. + assert "Foo" in labels + + +def test_objc_typedef_enum_uses_the_typedef_alias(tmp_path): + # `.m` is shared with MATLAB, so give the file an unambiguous ObjC marker so + # detection routes it to the ObjC extractor. + source = tmp_path / "Dir.m" + source.write_text( + "#import \n" + "typedef enum { North, South } Direction;\n", + encoding="utf-8", + ) + + result = extract([source], cache_root=tmp_path) + + # The anonymous enum is named after its typedef alias, the name callers use. + assert "Direction" in {n["label"] for n in result["nodes"]} + case_of = _edge_labels(result, "case_of") + assert ("Direction", "North") in case_of + assert ("Direction", "South") in case_of + + +def test_objc_anonymous_and_bodyless_enums_mint_no_phantom(tmp_path): + # A nameless enum has nothing to name a node after; a bare `enum X y;` use is + # a reference, not a definition. Neither should fabricate a node — while the + # surrounding class is still extracted. + source = tmp_path / "None.m" + source.write_text( + "#import \n" + "enum { Loose1, Loose2 };\n" + "@interface Keep : NSObject\n@end\n" + "@implementation Keep\n@end\n", + encoding="utf-8", + ) + + result = extract([source], cache_root=tmp_path) + + labels = {n["label"] for n in result["nodes"]} + assert "Keep" in labels, "surrounding class should still be extracted" + assert labels.isdisjoint({"Loose1", "Loose2"}) + assert not _edge_labels(result, "case_of")