Finish Objective-C member calls across a repository boundary (#3152)

`_resolve_objc_member_calls` types a receiver from `self`/`super`, an explicit
class name, the file's local table or the class's @property/ivar table, then
requires exactly one declaration of that type. Zero declarations and more than
one shared a single bail, so a call into a class another repository declares was
dropped with the receiver type already in hand — the case #3152 parks for the
merge in Java, C++, C# and Swift.

Split the three type-known bails: zero declarations parks `{callee,
receiver_type, lang: "objc", line}` on the caller, more than one stays dropped as
before (a local ambiguity that merging only widens). A builtin receiver type is
filtered out at the parking sites, matching what the Swift and C++ resolvers
already skip before resolving.

On the merge side an ObjC method label keeps its `+`/`-` sigil where every other
extractor writes `.name()`, and a parked selector does not say which was meant,
so both spellings are tried and a class declaring both counts as ambiguous. The
sigil is what keeps ObjC and C++ members disjoint inside a shared `.h`, which is
in both languages' suffix sets.

Depends on the `_callable` / `_callable_class` markers (#3228) that
`_index_declarations` indexes declarations by; the commit before this one carries
them.
This commit is contained in:
xiongjianxu committed 2026-10-13 11:28:30 +08:00
1 parent 9a64af9be1
commit 487fd5490d
3 files changed
+140 -11

No files matched your search

+20 -2
View File
@@ -42,6 +42,7 @@ _LANG_SUFFIXES: dict[str, frozenset[str]] = {
"cpp": frozenset({".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h", ".cu", ".cuh"}),
"csharp": frozenset({".cs"}),
"java": frozenset({".java"}),
"objc": frozenset({".h", ".m", ".mm"}),
"swift": frozenset({".swift"}),
}
@@ -57,7 +58,8 @@ def _key(label: object) -> str:
Type labels are plain (``Greeter``) while method labels carry the extractor's
decoration (``.greet()``). Case is preserved: every language that parks calls
here is case-sensitive, and folding case would let `greeter` answer for
`Greeter`.
`Greeter`. Objective-C's ``+``/``-`` sigil is deliberately kept, so an ObjC
``-greet`` and a C++ ``.greet()`` stay distinct members of a shared header.
"""
return str(label or "").strip().removeprefix(".").removesuffix("()")
@@ -148,6 +150,20 @@ def _member_relations(lang: str) -> tuple[str, ...]:
return ("method", "defines") if lang == "cpp" else ("method",)
def _member_keys(lang: str, callee: str) -> tuple[str, ...]:
"""Which member-index keys a parked callee may answer to.
An ObjC selector carries no class/instance distinction, so both sigils are
tried; a type declaring `+greet` and `-greet` both is an ambiguity, not a hit.
A selector cannot itself begin with a sigil, so stripping one first keeps an
entry that already carries it from asking for `--greet`.
"""
if lang == "objc":
selector = callee.lstrip("+-")
return (f"-{selector}", f"+{selector}")
return (callee,)
def link_cross_repo_member_calls(merged: "nx.Graph") -> int:
"""Emit `calls` edges for parked member calls another repo answers.
@@ -221,7 +237,9 @@ def link_cross_repo_member_calls(merged: "nx.Graph") -> int:
continue
targets: list[str] = []
for relation in _member_relations(lang):
targets = members_by_relation[relation].get((candidates[0], callee), [])
members = members_by_relation[relation]
for member_key in _member_keys(lang, callee):
targets.extend(members.get((candidates[0], member_key), []))
if targets:
break
if len(targets) != 1:
+25 -4
View File
@@ -6079,7 +6079,9 @@ def _resolve_objc_member_calls(
captured; a dotted receiver like ``Foo.shared`` is never passed through,
because ``_key`` would strip the dot and collide with a real ``FooShared``.
An uninferable receiver is SKIPPED (no guess), so an ambiguous selector across
classes never fans out. ``_merge_decl_def_classes`` folds each @interface/@impl
classes never fans out. A receiver typed to a class this corpus declares nowhere
is parked on the caller for a merged graph to finish (#3152).
``_merge_decl_def_classes`` folds each @interface/@impl
pair into one node, so a paired class clears the single-definition guard.
``@protocol`` declarations are excluded from the receiver-type index: a protocol
is a contract, not a message receiver, and ObjC keeps protocol and class names in
@@ -6178,6 +6180,16 @@ def _resolve_objc_member_calls(
queue.extend(_objc_bases.get(c, []))
return None
def _park_absent(caller: str, callee: str, type_name: str, rc: dict) -> None:
"""Park a typed receiver whose class is declared nowhere here (#3152).
A builtin type (NSString, DispatchQueue, ...) is not what another repo
declares, so parking one would only let a same-named user class answer it.
"""
if type_name in _LANGUAGE_BUILTIN_GLOBALS:
return
_park_unresolved_member_call(node_by_id.get(caller), callee, type_name, "objc", rc)
for rc in all_raw_calls:
if not rc.get("is_member_call"):
continue
@@ -6198,7 +6210,10 @@ def _resolve_objc_member_calls(
if not type_name:
continue
type_defs = type_def_nids.get(_key(type_name), [])
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
if not type_defs:
_park_absent(caller, callee, type_name, rc)
continue
if len(type_defs) != 1: # ambiguous -> bail (god-node guard)
continue
type_nid = type_defs[0]
type_qualified = False
@@ -6209,7 +6224,10 @@ def _resolve_objc_member_calls(
type_qualified = True
elif receiver[:1].isupper():
type_defs = type_def_nids.get(_key(receiver), [])
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
if not type_defs:
_park_absent(caller, callee, receiver, rc)
continue
if len(type_defs) != 1: # ambiguous -> bail (god-node guard)
continue
type_nid = type_defs[0]
type_qualified = True
@@ -6223,7 +6241,10 @@ def _resolve_objc_member_calls(
if not type_name:
continue
type_defs = type_def_nids.get(_key(type_name), [])
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
if not type_defs:
_park_absent(caller, callee, type_name, rc)
continue
if len(type_defs) != 1: # ambiguous -> bail (god-node guard)
continue
type_nid = type_defs[0]
type_qualified = False
+95 -5
View File
@@ -6,11 +6,11 @@ type already in hand and nothing about it reached `graph.json` — the only arti
`merge-graphs` and `global add` read. The two-repo graph was missing precisely the
edges that make it a call graph.
The Java, C++, C# and Swift resolvers now park those calls on the caller node and
this pass finishes them after the merge. The cases below pin what it must NOT do
as much as what it must: the single-definition guard, the cross-repo-only scope,
and the language guard are what keep it from fabricating an edge from a name
collision.
The Java, C++, C#, Objective-C and Swift resolvers now park those calls on the
caller node and this pass finishes them after the merge. The cases below pin what
it must NOT do as much as what it must: the single-definition guard, the
cross-repo-only scope, and the language guard are what keep it from fabricating an
edge from a name collision.
"""
from __future__ import annotations
@@ -44,6 +44,7 @@ def _needs(module: str):
needs_java = _needs("tree_sitter_java")
needs_cpp = _needs("tree_sitter_cpp")
needs_csharp = _needs("tree_sitter_c_sharp")
needs_objc = _needs("tree_sitter_objc")
needs_swift = _needs("tree_sitter_swift")
@@ -178,6 +179,79 @@ def test_a_defines_member_does_not_answer_a_java_call():
assert link_cross_repo_member_calls(G) == 0
PARKED_OBJC = [{"callee": "greet", "receiver_type": "Greeter", "lang": "objc", "line": "L5"}]
def test_an_objc_selector_answers_through_either_sigil():
# An ObjC method label keeps its +/- sigil while a parked selector carries no
# class/instance distinction, so both spellings have to be tried.
for label in ("-greet", "+greet"):
G = _graph(
caller=_caller("a", PARKED_OBJC, source_file="src/App.m"),
declarations=[(_declaration("b", "Greeter", "Greeter.m"),
_method("b", label, source_file="Greeter.m"))],
)
assert link_cross_repo_member_calls(G) == 1, label
assert _added_calls(G) == {("a::app_run", "b::greeter_greet")}
def test_an_objc_callee_that_already_carries_a_sigil_still_binds():
# A parked selector is written bare, but the payload comes from `graph.json`
# and a sigiled one must not ask the index for `--greet`.
G = _graph(
caller=_caller("a", [dict(PARKED_OBJC[0], callee="-greet")],
source_file="src/App.m"),
declarations=[(_declaration("b", "Greeter", "Greeter.m"),
_method("b", "-greet", source_file="Greeter.m"))],
)
assert link_cross_repo_member_calls(G) == 1
assert _added_calls(G) == {("a::app_run", "b::greeter_greet")}
def test_a_type_declaring_both_objc_sigils_binds_nothing():
# `+greet` and `-greet` on one class are two different methods and the parked
# selector cannot say which was meant.
decl = _declaration("b", "Greeter", "Greeter.m")
G = _graph(
caller=_caller("a", PARKED_OBJC, source_file="src/App.m"),
declarations=[(decl, _method("b", "-greet", "greeter_inst", "Greeter.m"))],
)
class_method = _method("b", "+greet", "greeter_cls", "Greeter.m")
G.add_node(class_method[0], **class_method[1])
G.add_edge(decl[0], class_method[0], relation="method")
assert link_cross_repo_member_calls(G) == 0
def test_an_objc_call_does_not_bind_to_a_cpp_member_of_a_shared_header():
# `.h` belongs to both languages, so the sigil is what keeps an ObjC `-greet`
# and a C++ `.greet()` from answering for each other inside one header.
G = _graph(
caller=_caller("a", PARKED_OBJC, source_file="src/App.m"),
declarations=[(_declaration("b", "Greeter", "greeter.h"),
_method("b", ".greet()", source_file="greeter.h"))],
)
assert link_cross_repo_member_calls(G) == 0
G = _graph(
caller=_caller("a", PARKED_CPP, source_file="src/app.cpp"),
declarations=[(_declaration("b", "Greeter", "greeter.h"),
_method("b", "-greet", source_file="greeter.h"))],
)
assert link_cross_repo_member_calls(G) == 0
def test_an_objc_protocol_does_not_answer_a_parked_call():
# A protocol carries the same markers as a class but is labelled `<Greeter>`,
# which no parked receiver type ever spells.
G = _graph(
caller=_caller("a", PARKED_OBJC, source_file="src/App.m"),
declarations=[(_declaration("b", "<Greeter>", "Greeter.h"),
_method("b", "-greet", source_file="Greeter.h"))],
)
assert link_cross_repo_member_calls(G) == 0
def test_the_definition_answers_before_a_same_named_declaration():
# A C++ class declares `void greet();` in its header (`defines`) and defines
# it out of line in the `.cpp` (`method`). Both hang off the one folded class
@@ -365,6 +439,22 @@ def test_a_java_build_parks_the_call_and_the_merge_finishes_it(tmp_path: Path):
("src/Greeter.cs", "class Greeter { public void Greet() {} }\n"),
marks=needs_csharp, id="csharp-field-receiver",
),
pytest.param(
"objc", "greet",
("src/App.m", "@interface App : NSObject\n"
"@property (nonatomic, strong) Greeter *greeter;\n"
"@end\n"
"@implementation App\n"
"- (void)run { [self.greeter greet]; }\n"
"@end\n"),
("src/Greeter.m", "@interface Greeter : NSObject\n"
"- (void)greet;\n"
"@end\n"
"@implementation Greeter\n"
"- (void)greet {}\n"
"@end\n"),
marks=needs_objc, id="objc-property-receiver",
),
pytest.param(
"swift", "greet",
("src/App.swift", "class App {\n"