Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions graphify/extractors/objc.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
# `C++Bridge.h` and `Foo+.h` are left intact.
_OBJC_STEM_PART = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")

# Declaration markers, matching what the generic engine puts on every other
# language's definitions (#2438): `_callable` says "a real callable, not a
# same-named data symbol", and `_callable_class` narrows that to a type, which is
# callable only through a constructor. Passes that index declarations gate on
# these, so a node without them is invisible to them.
_CALLABLE = ("_callable",)
_CALLABLE_CLASS = ("_callable", "_callable_class")


def _objc_category_base_stem(stem: str) -> str:
"""Strip an ObjC category/extension suffix from a file stem (``Foo+Cat`` -> ``Foo``).
Expand Down Expand Up @@ -115,11 +123,14 @@ def extract_objc(path: Path) -> dict:
# same (class, field) tombstones the entry (None) — drop, don't guess.
objc_field_types: dict[str, dict[str, str | None]] = {}

def add_node(nid: str, label: str, line: int) -> None:
def add_node(nid: str, label: str, line: int, markers: tuple[str, ...] = ()) -> None:
if nid not in seen_ids:
seen_ids.add(nid)
nodes.append({"id": nid, "label": label, "file_type": "code",
"source_file": str_path, "source_location": f"L{line}"})
node = {"id": nid, "label": label, "file_type": "code",
"source_file": str_path, "source_location": f"L{line}"}
for marker in markers:
node[marker] = True
nodes.append(node)

def add_edge(src: str, tgt: str, relation: str, line: int,
confidence: str = "EXTRACTED", weight: float = 1.0,
Expand Down Expand Up @@ -284,7 +295,7 @@ def walk(node, parent_nid: str | None = None) -> None:
# produced fine when the members lived in `Foo.h` (#1556).
cls_stem = _objc_category_base_stem(stem) if _objc_is_category(node) else stem
cls_nid = _make_id(cls_stem, name)
add_node(cls_nid, name, line)
add_node(cls_nid, name, line, _CALLABLE_CLASS)
add_edge(file_nid, cls_nid, "contains", line)
# superclass is second identifier after ':'
colon_seen = False
Expand Down Expand Up @@ -349,7 +360,7 @@ def walk(node, parent_nid: str | None = None) -> None:
impl_stem = _objc_category_base_stem(stem) if _objc_is_category(node) else stem
impl_nid = _make_id(impl_stem, name)
if impl_nid not in seen_ids:
add_node(impl_nid, name, line)
add_node(impl_nid, name, line, _CALLABLE_CLASS)
add_edge(file_nid, impl_nid, "contains", line)
for child in node.children:
if child.type == "instance_variables":
Expand All @@ -367,7 +378,9 @@ def walk(node, parent_nid: str | None = None) -> None:
break
if name:
proto_nid = _make_id(stem, name)
add_node(proto_nid, f"<{name}>", line)
# A protocol is a type declaration like any other interface, and
# the engine marks a Java/C# interface the same way.
add_node(proto_nid, f"<{name}>", line, _CALLABLE_CLASS)
add_edge(file_nid, proto_nid, "contains", line)
# Adopted protocols: `@protocol Derived <Base, Other>`. These
# nest under a protocol_reference_list node (distinct from the
Expand Down Expand Up @@ -402,7 +415,7 @@ def walk(node, parent_nid: str | None = None) -> None:
method_name = "".join(parts) if parts else None
if method_name:
method_nid = _make_id(container, method_name)
add_node(method_nid, f"{prefix}{method_name}", line)
add_node(method_nid, f"{prefix}{method_name}", line, _CALLABLE)
add_edge(container, method_nid, "method", line)
if t == "method_definition":
method_bodies.append((method_nid, node, container))
Expand Down
106 changes: 106 additions & 0 deletions tests/test_objc_callable_markers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""ObjC declarations carry the `_callable` / `_callable_class` markers.

Every other extractor stamps its definitions with `_callable` — "a real callable,
not a same-named data symbol" (#2438) — and narrows a type to `_callable_class`,
callable only through a constructor (#2137). The generic engine does it for all of
them in one place (`extractors/engine.py`, `callable_def_nids` /
`callable_class_nids`).

The ObjC extractor builds its nodes by hand and set neither, so an ObjC class was
invisible to every pass that indexes declarations by those markers, and an ObjC
method could never be told apart from a data symbol of the same name. This pins the
markers onto the four node kinds ObjC produces, including the two that must stay
unmarked.
"""
from __future__ import annotations

from pathlib import Path

from graphify.extract import extract

GREETER_H = (
"@interface Greeter : NSObject\n"
"- (void)greet;\n"
"+ (instancetype)shared;\n"
"@end\n"
)
GREETER_M = (
"#import \"Greeter.h\"\n"
"@implementation Greeter\n"
"- (void)greet {}\n"
"@end\n"
)


def _extract(tmp_path: Path, files: dict[str, str]) -> dict:
paths = []
for name, body in files.items():
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
paths.append(path)
return extract(paths, cache_root=tmp_path / "graphify-out")


def _node(result: dict, label: str) -> dict:
matches = [n for n in result["nodes"] if n.get("label") == label]
assert len(matches) == 1, [n.get("label") for n in result["nodes"]]
return matches[0]


def test_a_class_is_marked_as_a_type(tmp_path: Path):
result = _extract(tmp_path, {"Greeter.h": GREETER_H})
greeter = _node(result, "Greeter")
assert greeter.get("_callable") is True
assert greeter.get("_callable_class") is True


def test_an_implementation_only_class_is_marked_too(tmp_path: Path):
# A class whose `@interface` is not in this corpus is still a declaration.
result = _extract(tmp_path, {"Greeter.m": "@implementation Greeter\n- (void)greet {}\n@end\n"})
greeter = _node(result, "Greeter")
assert greeter.get("_callable") is True
assert greeter.get("_callable_class") is True


def test_a_header_and_impl_pair_keeps_the_markers_after_folding(tmp_path: Path):
# `_merge_decl_def_classes` folds the `.h`/`.m` pair into one node; the markers
# have to be on whichever node survives.
result = _extract(tmp_path, {"Greeter.h": GREETER_H, "Greeter.m": GREETER_M})
greeter = _node(result, "Greeter")
assert greeter.get("_callable") is True
assert greeter.get("_callable_class") is True


def test_a_method_is_callable_but_is_not_a_type(tmp_path: Path):
result = _extract(tmp_path, {"Greeter.h": GREETER_H})
for label in ("-greet", "+shared"):
method = _node(result, label)
assert method.get("_callable") is True, label
assert "_callable_class" not in method, label


def test_a_protocol_is_marked_like_any_other_interface(tmp_path: Path):
# A Java or C# interface gets `_callable_class` from the generic engine, and a
# protocol is the same kind of declaration.
result = _extract(tmp_path, {"Greeting.h": "@protocol Greeting\n- (void)greet;\n@end\n"})
protocol = _node(result, "<Greeting>")
assert protocol.get("_callable") is True
assert protocol.get("_callable_class") is True


def test_a_dangling_reference_is_not_marked(tmp_path: Path):
# `NSObject` is a stub minted for a name this corpus never declares, so it has
# no source file and no declaration behind it.
result = _extract(tmp_path, {"Greeter.h": GREETER_H})
stub = _node(result, "NSObject")
assert not stub.get("source_file")
assert "_callable" not in stub
assert "_callable_class" not in stub


def test_the_file_node_is_not_marked(tmp_path: Path):
result = _extract(tmp_path, {"Greeter.h": GREETER_H})
file_node = _node(result, "Greeter.h")
assert "_callable" not in file_node
assert "_callable_class" not in file_node