mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 07:59:40 +00:00
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:
1 parent
786e8868a0
commit
61b43f5e91
2 files changed
+286
-17
No files matched your search
+156
-14
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
Reference in new issue
Block a user