fix(objc): extract C-style enum declarations

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 2d30c413d3)
This commit is contained in:
rajatnagda45 authored and safishamsi committed 2026-10-05 19:40:03 +01:00
1 parent e444d5ac37
commit e4c48bf97e
2 files changed
+145

No files matched your search

+60
View File
@@ -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)
+85
View File
@@ -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 <Foundation/Foundation.h>\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 <Foundation/Foundation.h>\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")