Mark ObjC declarations with _callable / _callable_class (#3228)

Every other extractor stamps its definitions through the generic engine:
`_callable` for "a real callable, not a same-named data symbol" (#2438),
narrowed to `_callable_class` for a type, which is callable only through a
constructor (#2137). 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 — the `same_type_as` pass from #3007, the
qualified-name indexes, and anything new that needs to tell a declaration
from a reference — and an ObjC method could not be told apart from a data
symbol of the same name.

`@interface`, `@implementation` and `@protocol` nodes now carry both
markers (a protocol is a type declaration, the same way the engine marks a
Java or C# interface), and method nodes carry `_callable`. The file node
and the stubs minted for dangling references stay unmarked: a stub has no
declaration behind it.
This commit is contained in:
xiongjianxu committed 2026-10-13 11:28:30 +08:00
1 parent a9860f70dc
commit 9a64af9be1
2 files changed
+126 -7

No files matched your search

+20 -7
View File
@@ -13,6 +13,14 @@ import re
# `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``).
@@ -116,11 +124,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,
@@ -322,7 +333,7 @@ def extract_objc(path: Path) -> dict:
# 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
@@ -387,7 +398,7 @@ def extract_objc(path: Path) -> dict:
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":
@@ -405,7 +416,9 @@ def extract_objc(path: Path) -> dict:
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
@@ -440,7 +453,7 @@ def extract_objc(path: Path) -> dict:
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))
+106
View File
@@ -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