diff --git a/graphify/extractors/objc.py b/graphify/extractors/objc.py index 2668f0093..5e1d6c97d 100644 --- a/graphify/extractors/objc.py +++ b/graphify/extractors/objc.py @@ -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} diff --git a/tests/test_objc_enum_declarations.py b/tests/test_objc_enum_declarations.py index 392ba03d7..ba827a06e 100644 --- a/tests/test_objc_enum_declarations.py +++ b/tests/test_objc_enum_declarations.py @@ -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 \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 \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 ", + "// 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 \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 \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"), + }