feat: extract Objective-C enum macros

Extract NS_ENUM and NS_OPTIONS typedefs in Objective-C files using isolated enum body parsing and original member locations.

Fixes #4400

AI-Assistance: OpenAI Codex
(cherry picked from commit d6a74da475)
This commit is contained in:
Alex Tumanov authored and safishamsi committed 2026-10-13 11:14:16 +01:00
1 parent 786e8868a0
commit 61b43f5e91
2 files changed
+286 -17

No files matched your search

+156 -14
View File
@@ -230,7 +230,8 @@ 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:
def _emit_enum(enum_spec, name: str, line: int,
mapped_members: list[tuple[str, int]] | None = None) -> 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
@@ -240,20 +241,160 @@ def extract_objc(path: Path) -> dict:
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
if mapped_members is None:
body = next((c for c in enum_spec.children if c.type == "enumerator_list"), None)
if body is None:
return
mapped_members = []
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 mname:
mapped_members.append((mname, member.start_point[0] + 1))
for mname, m_line in mapped_members:
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 _macro_enum_members() -> list[tuple[str, int, list[tuple[str, int]]]]:
"""Validate selected typedef macros without changing the original parse tree.
The Objective-C grammar does not expose these bodies as enum_specifiers.
Its macro header still has structural fields and tokens; use those to
select a declaration, then parse only its unchanged brace-delimited body
as a standalone enum. Every member must map back to the same source bytes.
"""
def descendants(node, kind: str):
if node.type == kind:
yield node
for child in node.children:
yield from descendants(child, kind)
def leaves(node):
if node.type == "comment":
return
if not node.children:
yield node
else:
for child in node.children:
yield from leaves(child)
source_leaves = list(leaves(root))
accepted = []
for macro in descendants(root, "macro_type_specifier"):
macro_name = macro.child_by_field_name("name")
if macro_name is None or _read(macro_name) not in ("NS_ENUM", "NS_OPTIONS"):
continue
# The closest preceding structural token in an enclosing node must
# be typedef. This excludes bare macro uses and text in definitions.
ancestor = macro.parent
while ancestor is not None:
preceding = [token for token in leaves(ancestor)
if token.end_byte <= macro.start_byte]
if preceding and preceding[-1].type == "typedef":
break
ancestor = ancestor.parent
if ancestor is None:
continue
tokens = list(leaves(macro))
opens = [token for token in tokens if token.type == "("]
closes = [token for token in tokens if token.type == ")"]
commas = [token for token in tokens if token.type == ","]
if len(opens) != 1 or len(closes) != 1 or len(commas) != 1:
continue
opening, closing, comma = opens[0], closes[0], commas[0]
if not (opening.end_byte <= comma.start_byte < comma.end_byte <= closing.start_byte):
continue
underlying = [node for node in descendants(macro, "type_descriptor")
if opening.end_byte <= node.start_byte
and node.end_byte <= comma.start_byte]
outer_types = [node for node in underlying if not any(
other is not node and other.start_byte <= node.start_byte
and node.end_byte <= other.end_byte for other in underlying
)]
names = [token for token in tokens
if comma.end_byte <= token.start_byte < closing.start_byte
and token.is_named]
if len(outer_types) != 1 or len(names) != 1 or names[0].type not in (
"identifier", "type_identifier"
):
continue
following = [token for token in source_leaves
if token.start_byte >= macro.end_byte]
if not following or following[0].type != "{":
continue
open_brace = following[0]
depth = 0
close_brace = None
for token in following:
if token.type == "{":
depth += 1
elif token.type == "}":
depth -= 1
if depth == 0:
close_brace = token
break
if depth < 0:
break
if close_brace is None:
continue
after_body = [token for token in source_leaves
if token.start_byte >= close_brace.end_byte]
if not after_body or after_body[0].type != ";":
continue
name_bytes = source[names[0].start_byte:names[0].end_byte]
body_bytes = source[open_brace.start_byte:close_brace.end_byte]
prefix = b"enum " + name_bytes + b" "
fragment = prefix + body_bytes + b";"
fragment_root = parser.parse(fragment).root_node
enum_specs = list(descendants(fragment_root, "enum_specifier"))
if fragment_root.has_error or len(enum_specs) != 1:
continue
enum_spec = enum_specs[0]
enum_name = enum_spec.child_by_field_name("name")
body = enum_spec.child_by_field_name("body")
if (enum_name is None or fragment[enum_name.start_byte:enum_name.end_byte] != name_bytes
or enum_spec.has_error or body is None or body.type != "enumerator_list"
or body.has_error or body.start_byte != len(prefix)
or body.end_byte != len(prefix) + len(body_bytes)):
continue
members: list[tuple[str, int]] = []
previous_member_end = None
for member in body.children:
if member.type != "enumerator":
continue
# This grammar can accept `Bad Other` as two clean enumerators.
# Require the separator token before every later member.
if previous_member_end is not None and not any(
token.type == "," and previous_member_end <= token.start_byte < member.start_byte
for token in body.children
):
break
identifier = member.child_by_field_name("name")
if identifier is None:
identifier = next((c for c in member.children if c.type == "identifier"), None)
if identifier is None or member.has_error or identifier.start_byte < body.start_byte:
break
original_start = open_brace.start_byte + identifier.start_byte - body.start_byte
original_end = open_brace.start_byte + identifier.end_byte - body.start_byte
name = fragment[identifier.start_byte:identifier.end_byte]
if original_end > close_brace.end_byte or source[original_start:original_end] != name:
break
line = open_brace.start_point[0] + identifier.start_point[0] - body.start_point[0] + 1
members.append((name.decode("utf-8", errors="replace"), line))
previous_member_end = member.end_byte
else:
accepted.append((_read(names[0]), macro.start_point[0] + 1, members))
return accepted
macro_enums = _macro_enum_members()
def walk(node, parent_nid: str | None = None) -> None:
t = node.type
line = node.start_point[0] + 1
@@ -450,9 +591,8 @@ def extract_objc(path: Path) -> dict:
# `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.
# type_identifier). NS_ENUM/NS_OPTIONS are handled separately through
# their structural macro headers and isolated enum body fragments.
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
@@ -486,6 +626,8 @@ def extract_objc(path: Path) -> dict:
walk(child, parent_nid)
walk(root)
for name, line, members in macro_enums:
_emit_enum(None, name, line, members)
# Second pass: resolve calls inside method bodies
all_method_nids = {n["id"] for n in nodes if n["id"] != file_nid}
+130 -3
View File
@@ -6,12 +6,12 @@ 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.
NS_ENUM and NS_OPTIONS typedefs use the same graph representation.
"""
from __future__ import annotations
import pytest
from graphify.extract import extract
@@ -83,3 +83,130 @@ def test_objc_anonymous_and_bodyless_enums_mint_no_phantom(tmp_path):
assert "Keep" in labels, "surrounding class should still be extracted"
assert labels.isdisjoint({"Loose1", "Loose2"})
assert not _edge_labels(result, "case_of")
@pytest.mark.parametrize("suffix", [".h", ".m", ".mm"])
@pytest.mark.parametrize("macro", ["NS_ENUM", "NS_OPTIONS"])
def test_objc_macro_enum_types_and_members_survive_cache(tmp_path, suffix, macro):
source = tmp_path / ("Window" + suffix)
source.write_text(
"#import <Foundation/Foundation.h>\n"
f"typedef {macro}(NSUInteger, WindowOptions) {{\n"
" WindowResizable = 1UL << 0,\n"
" WindowClosable = 1UL << 1,\n"
" WindowAll = WindowResizable | WindowClosable,\n"
"};\n",
encoding="utf-8",
)
cold = extract([source], cache_root=tmp_path)
warm = extract([source], cache_root=tmp_path)
expected = {
("WindowOptions", "WindowResizable"),
("WindowOptions", "WindowClosable"),
("WindowOptions", "WindowAll"),
}
assert _edge_labels(cold, "case_of") == expected
assert _edge_labels(warm, "case_of") == expected
assert cold["nodes"] == warm["nodes"]
assert cold["edges"] == warm["edges"]
assert any(target == "WindowOptions" for _, target in _edge_labels(cold, "contains"))
@pytest.mark.parametrize(
("declaration", "members"),
[
("typedef NS_ENUM(NSInteger, Kind) { First, Second = 4 };", {"First", "Second"}),
("typedef NS_OPTIONS(unsigned long, Kind) { First = 1 << 0 };", {"First"}),
("typedef NS_ENUM(NSInteger, Kind) {};", set()),
("typedef NS_ENUM(NSInteger, Kind) { First = -1, Second = First + 1 };", {"First", "Second"}),
("typedef NS_ENUM(NSInteger, Kind) { First = 1, Second = First };", {"First", "Second"}),
("typedef /* type */ NS_ENUM(NSInteger /* a,b */, /* c,d */ Kind) { First /* e,f */, };", {"First"}),
("void use(void) { typedef NS_ENUM(NSInteger, Kind) { First }; }", {"First"}),
("#if ENABLE_KIND\ntypedef NS_ENUM(NSInteger, Kind) { First };\n#endif", {"First"}),
],
)
def test_objc_macro_enum_declaration_forms(tmp_path, declaration, members):
source = tmp_path / "Kinds.h"
source.write_text("#import <Foundation/Foundation.h>\n" + declaration, encoding="utf-8")
result = extract([source], cache_root=tmp_path)
assert "Kind" in {n["label"] for n in result["nodes"]}
assert _edge_labels(result, "case_of") == {("Kind", name) for name in members}
@pytest.mark.parametrize("newline", ["\n", "\r\n"])
def test_objc_macro_enum_preserves_original_utf8_member_lines(tmp_path, newline):
source = tmp_path / "Unicode.h"
lines = [
"#import <Foundation/Foundation.h>",
"// café π",
"NS_ASSUME_NONNULL_BEGIN",
"typedef NS_ENUM(",
" NSInteger /* µ, storage */ ,",
" Kind",
") {",
" Café,",
" Second = 2,",
"};",
"NS_ASSUME_NONNULL_END",
]
source.write_bytes((newline.join(lines) + newline).encode("utf-8"))
result = extract([source], cache_root=tmp_path)
nodes = {n["label"]: n for n in result["nodes"]}
assert _edge_labels(result, "case_of") == {("Kind", "Café"), ("Kind", "Second")}
assert nodes["Kind"]["source_location"] == "L4"
assert nodes["Café"]["source_location"] == "L8"
assert nodes["Second"]["source_location"] == "L9"
@pytest.mark.parametrize(
"declaration",
[
"typedef OTHER_ENUM(NSInteger, Broken) { Bad };",
"typedef MY_NS_ENUM(NSInteger, Broken) { Bad };",
"typedef CF_ENUM(NSInteger, Broken) { Bad };",
"typedef NS_CLOSED_ENUM(NSInteger, Broken) { Bad };",
"NS_ENUM(NSInteger, Broken) { Bad };",
"typedef NS_ENUM(NSInteger, ) { Bad };",
"typedef NS_ENUM(NSInteger, Broken, Extra) { Bad };",
"typedef NS_ENUM(NSInteger, Broken);",
"typedef NS_ENUM(NSInteger, Broken) { Bad = , Other };",
"typedef NS_ENUM(NSInteger, Broken) { Bad Other };",
"typedef NS_ENUM(NSInteger, Broken) { Bad; };",
"typedef NS_ENUM(NSInteger, Broken) { Bad",
"typedef NS_ENUM(NSInteger, Broken) { Bad }",
"typedef NS_ENUM(NSInteger, Broken) unrelated { Bad };",
"// typedef NS_ENUM(NSInteger, Broken) { Bad };",
'const char *text = "typedef NS_ENUM(NSInteger, Broken) { Bad };";',
"#define EXAMPLE typedef NS_ENUM(NSInteger, Broken) { Bad };",
],
)
def test_objc_macro_enum_rejects_unrelated_and_malformed_declarations(tmp_path, declaration):
source = tmp_path / "Negative.h"
source.write_text("#import <Foundation/Foundation.h>\n" + declaration, encoding="utf-8")
result = extract([source], cache_root=tmp_path)
assert {n["label"] for n in result["nodes"]}.isdisjoint({"Broken", "Bad", "Other"})
assert not _edge_labels(result, "case_of")
def test_objc_macro_enums_preserve_siblings_and_unrelated_parser_errors(tmp_path):
source = tmp_path / "Mixed.h"
source.write_text(
"#import <Foundation/Foundation.h>\n"
"enum Plain { PlainOne, PlainTwo };\n"
"typedef enum { North, South } Direction;\n"
"typedef NS_ENUM(NSInteger, Kind) { First, Second };\n"
"typedef NS_OPTIONS(NSUInteger, Broken) { Bad = , Other };\n"
"typedef NS_OPTIONS(NSUInteger, Mask) { Enabled = 1 << 0 };\n"
"@interface Keep : NSObject\n@end\n"
"@interface Invalid : NSObject\n- (void)broken:(;\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
assert labels.isdisjoint({"Broken", "Bad", "Other"})
assert _edge_labels(result, "case_of") == {
("Plain", "PlainOne"), ("Plain", "PlainTwo"),
("Direction", "North"), ("Direction", "South"),
("Kind", "First"), ("Kind", "Second"), ("Mask", "Enabled"),
}