From e4c48bf97e967af45abb6eae5577b037ff01cd4d Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Mon, 5 Oct 2026 09:11:47 +0530 Subject: [PATCH] fix(objc): extract C-style enum declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: both the enum type and all of its enumerators were invisible in the graph. Handle `enum_specifier` (and the `typedef enum { ... } Name;` form, where the name is the typedef alias), emitting a type node plus a node and `case_of` edge per enumerator — the same shape every other language with enums uses (Java #1719, C++ #3939, Swift). A nameless enum and a bodyless `enum X y;` reference are skipped so no phantom node is minted. NS_ENUM/NS_OPTIONS 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. Documented in the test. Add regression tests for the plain, typedef, and skip cases. (cherry picked from commit 2d30c413d3bbae3ff2f84c961b8aaa08deaeabd7) --- graphify/extractors/objc.py | 60 ++++++++++++++++++++ tests/test_objc_enum_declarations.py | 85 ++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 tests/test_objc_enum_declarations.py 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")