fix(objc): resolve chained message-send calls [[Foo create] doIt] (#750) (#786)

Ports the #645/#608 chained-receiver mechanism to Objective-C. A message send
whose receiver is itself a message send — `[[Foo create] doIt]` — used to drop
the receiver, so `doIt` name-matched a same-named method on an unrelated class
(commonly a test helper's `init` or an Apple-SDK method).

- objc.ts: getReturnType reads the method's `method_type`, SKIPPING nullability /
  ARC qualifiers (`nonnull instancetype` must yield instancetype, not `nonnull`).
- tree-sitter.ts: the message_expression branch now re-encodes a chained send
  `[[Foo create] doIt]` as `Foo.create().doIt` when the inner receiver is a
  capitalized class and the outer selector is unary.
- name-matcher.ts: `objc` joins the dotted-chain gate + CHAIN_LANGUAGES. A
  class-message factory returns an instance of the RECEIVER class by convention
  (`instancetype`), so when the factory's own return type isn't recoverable
  (`alloc`/`new`/`shared…` return instancetype, or aren't user nodes), the
  receiver's type is the class itself — this resolves the ubiquitous
  `[[X alloc] init]` and singleton chains. resolveMethodOnType validates against
  the class and its supertypes, so a wrong inference yields no edge.

Validation: 4 synthetic tests (factory+decoy, superclass conformance, absent-method
safety, the nonnull-instancetype singleton). Real-repo A/B on SDWebImage (208 files):
+35 / -75 — all corrections (the -75 are wrong `init` mis-matches to a test helper /
wrong class, retargeted to the right class's init in the +35, plus 2 Apple-SDK chains
on unindexed classes). db stable, no node explosion. EXTRACTION_VERSION 14->15.
Full suite green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-11 00:35:49 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 16c73e2b0e
commit d21d2dfa50
7 changed files with 233 additions and 9 deletions
+1 -1
View File
@@ -21,4 +21,4 @@
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
* in the product is load-bearing").
*/
export const EXTRACTION_VERSION = 14;
export const EXTRACTION_VERSION = 15;
+41
View File
@@ -31,6 +31,46 @@ function extractObjcMethodName(node: SyntaxNode, source: string): string | undef
return identifiers.map((id) => `${getNodeText(id, source)}:`).join('');
}
/** Nullability / ARC qualifiers that sit where a return type's first type
* identifier does (`(nonnull instancetype)`, `(nullable Bar *)`) — never the type. */
const OBJC_TYPE_QUALIFIERS = new Set([
'nonnull', 'nullable', 'null_unspecified', 'null_resettable',
'_Nonnull', '_Nullable', '_Null_unspecified', '__nonnull', '__nullable',
'const', 'volatile', 'strong', 'weak', 'copy', 'assign', 'retain', 'oneway',
'__strong', '__weak', '__unsafe_unretained', '__autoreleasing', '__kindof',
]);
/** Collect the type identifiers under a `method_type`, in document order. */
function collectTypeIdentifiers(node: SyntaxNode, source: string, out: string[]): void {
if (node.type === 'type_identifier') out.push(getNodeText(node, source).trim());
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child) collectTypeIdentifiers(child, source, out);
}
}
/**
* Capture an ObjC method's declared return type as a bare class name, for the
* chained static-factory call mechanism (#750). `+ (Bar *)create` yields `Bar`;
* a nullability/ARC qualifier (`(nonnull instancetype)`, `(nullable Bar *)`) is
* skipped to reach the real type. `void` / `id` / `instancetype` / primitives
* yield undefined — for a class-message factory that means the receiver's type
* is the class itself (handled in resolution), so `[[X alloc] init]` and
* singleton chains still resolve.
*/
function extractObjcReturnType(node: SyntaxNode, source: string): string | undefined {
if (node.type !== 'method_definition' && node.type !== 'method_declaration') return undefined;
const methodType = node.namedChildren.find((c) => c.type === 'method_type');
if (!methodType) return undefined;
const ids: string[] = [];
collectTypeIdentifiers(methodType, source, ids);
const name = ids.find((n) => !OBJC_TYPE_QUALIFIERS.has(n));
if (!name || !/^[A-Za-z_]\w*$/.test(name) || name === 'void' || name === 'id' || name === 'instancetype') {
return undefined;
}
return name;
}
function extractObjcPropertyName(node: SyntaxNode, source: string): string | null {
if (node.type !== 'property_declaration') return null;
@@ -73,6 +113,7 @@ export const objcExtractor: LanguageExtractor = {
nameField: 'declarator',
bodyField: 'body',
paramsField: 'parameters',
getReturnType: extractObjcReturnType,
resolveName: extractObjcMethodName,
extractPropertyName: extractObjcPropertyName,
resolveBody: (node, bodyField) => {
+27
View File
@@ -2482,6 +2482,33 @@ export class TreeSitterExtractor {
} else {
calleeName = methodName;
}
} else if (receiverField && receiverField.type === 'message_expression' && /^\w+$/.test(methodName)) {
// Chained message send `[[Foo create] doIt]` — the receiver is itself a
// class message. Recover the inner `Class.selector` and encode
// `Class.selector().doIt` so resolution infers doIt's class from what
// `Class.selector` RETURNS (#645/#608). Only a CLASS-factory chain
// (capitalized inner receiver); a unary outer selector is required
// because the chain resolver's method part is `\w+` (no `:`). An
// instance chain (`[[obj foo] bar]`, lowercase inner) stays bare.
const innerRecv = getChildByField(receiverField, 'receiver');
const innerRecvName = innerRecv ? getNodeText(innerRecv, this.source) : '';
if (innerRecv?.type === 'identifier' && /^[A-Z]/.test(innerRecvName)) {
const innerKw: string[] = [];
for (let i = 0; i < receiverField.namedChildCount; i++) {
if (receiverField.fieldNameForNamedChild(i) === 'method') {
const kw = receiverField.namedChild(i);
if (kw) innerKw.push(getNodeText(kw, this.source));
}
}
let innerColon = false;
for (let i = 0; i < receiverField.childCount; i++) {
if (receiverField.child(i)?.type === ':') { innerColon = true; break; }
}
const innerSelector = innerColon ? innerKw.map((k) => `${k}:`).join('') : innerKw[0];
calleeName = innerSelector ? `${innerRecvName}.${innerSelector}().${methodName}` : methodName;
} else {
calleeName = methodName;
}
} else {
calleeName = methodName;
}
+1 -1
View File
@@ -37,7 +37,7 @@ const SUPERTYPE_BEARING_KINDS = new Set<Node['kind']>([
* second pass. Dotted-receiver languages resolve via matchDottedCallChain; the
* `::`-receiver ones (Rust) via matchScopedCallChain.
*/
const CHAIN_LANGUAGES = new Set(['java', 'kotlin', 'csharp', 'swift', 'rust', 'go', 'scala', 'dart']);
const CHAIN_LANGUAGES = new Set(['java', 'kotlin', 'csharp', 'swift', 'rust', 'go', 'scala', 'dart', 'objc']);
const SCOPED_CHAIN_LANGUAGES = new Set(['rust']);
/** The extractor's chained-receiver encoding: `<inner>().<method>`. */
+25 -7
View File
@@ -673,7 +673,23 @@ export function matchDottedCallChain(
const factoryMethod = inner.slice(lastDot + 1);
if (!factoryClass || !factoryMethod) return null;
const ret = lookupCalleeReturnType(`${factoryClass}::${factoryMethod}`, ref, context);
if (!ret) return null;
if (!ret) {
// Objective-C: a class-message factory — `[X alloc]`, `[X new]`,
// `[X sharedFoo]` — returns an instance of the RECEIVER class `X` by
// convention (`instancetype`). So when the factory's own return type isn't
// recoverable (its selector returns `instancetype`, or `alloc`/`new` aren't
// user-defined nodes at all), the receiver's type is the class `X` itself.
// This resolves the ubiquitous `[[X alloc] init]` and singleton chains.
// resolveMethodOnType validates against X (and its supertypes), so a class
// whose method actually lives elsewhere yields NO edge, not a wrong one — and
// crucially this does NOT fire when a concrete return type WAS captured but
// simply lacks the method (that already returned null above: absent-method
// safety, so a same-named decoy is still never matched).
if (ref.language === 'objc' && /^[A-Z]/.test(factoryClass)) {
return resolveMethodOnType(factoryClass, method, ref, context, 0.8, 'instance-method', importedFqnOf(factoryClass, ref, context));
}
return null;
}
return resolveMethodOnType(ret, method, ref, context, 0.85, 'instance-method', importedFqnOf(ret, ref, context));
}
@@ -1123,11 +1139,12 @@ export function matchReference(
}
// 1d. Dotted chained static-factory / fluent call (Java / Kotlin / C# / Swift /
// Go / Scala / Dart) — `Foo.getInstance().bar()` encoded as `Foo.getInstance().bar`,
// Go's bare-factory `New().Method()` as `New().Method`, Scala's companion factory
// `Foo.create().bar()`, or Dart's static factory / factory-constructor
// `Foo.create().bar()` (#645/#608 mechanism). Resolve the method's class from the
// inner call's declared return type, then validate it.
// Go / Scala / Dart / Objective-C) — `Foo.getInstance().bar()` encoded as
// `Foo.getInstance().bar`, Go's bare-factory `New().Method()` as `New().Method`,
// Scala's companion factory, Dart's static factory / factory-constructor, or
// ObjC's chained message send `[[Foo create] doIt]` encoded as `Foo.create().doIt`
// (#645/#608 mechanism). Resolve the method's class from the inner call's
// declared return type, then validate it.
if (
ref.language === 'java' ||
ref.language === 'kotlin' ||
@@ -1135,7 +1152,8 @@ export function matchReference(
ref.language === 'swift' ||
ref.language === 'go' ||
ref.language === 'scala' ||
ref.language === 'dart'
ref.language === 'dart' ||
ref.language === 'objc'
) {
result = matchDottedCallChain(ref, context);
if (result) return result;