Skip to content

Commit 7c7f0dd

Browse files
colbymchenryclaude
andauthored
fix(swift): resolve chained static-factory/fluent calls + nested-extension naming (colbymchenry#750) (colbymchenry#755)
Completes Swift in the colbymchenry#750 chained-call series (after Java colbymchenry#751, Kotlin colbymchenry#752, C# colbymchenry#753, conformance colbymchenry#754). Two parts: 1. Swift chained-call resolution (the colbymchenry#645/colbymchenry#608 mechanism): capture Swift return types (positional, member types -> last segment), encode capitalized-receiver chains `Foo.make().draw()` / `Foo(args).draw()`, resolve+validate via the shared matchDottedCallChain (+ constructor branch). Fixes the decoy wrong-edge bug where a chained method dropped to a bare name and attached to a same-named method on an unrelated class. 2. Nested-type extension naming fix: `extension KF.Builder: KFOptionSetter` parsed as a class_declaration named `KF.Builder` (dot) — inconsistent with the type's own declaration `KF::Builder` (name `Builder`) — so the extension's conformances and members were invisible to a chained call on the type. A Swift resolveName now names a nested-type extension by its last segment (`Builder`), so its `implements`/`extends` edges and methods are found by the supertype walk (conformance colbymchenry#754) and the simple-name method match. Validated: synthetic decoy + args + constructor + absent-method tests; full suite green; nested-extension repro (`KF.url().onSuccess()` resolves via conformance to the protocol method). Real-repo A/B vs main (conformance) — Alamofire and Kingfisher both **0 added / 0 removed, node count unchanged**: NEUTRAL and SAFE. The prior -168 Kingfisher regression (from the naming inconsistency) is eliminated; Swift's unique-named fluent methods already resolved by bare name, so the chain path lands the same edges — the value here is decoy-collision correctness, the nested-extension naming fix, and consistency with the other four languages. EXTRACTION_VERSION 9 -> 10. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 48d4654 commit 7c7f0dd

7 files changed

Lines changed: 163 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3030
### Fixes
3131

3232
- Chained method calls now resolve when the chained method is **inherited from a superclass or declared on an interface/protocol** the receiver's type conforms to — for example a call on a sealed-subclass instance (`Either.Right(x).combine(...)`) that invokes a method defined on its parent type. Previously these chains found no caller edge even though the factory's type was known, so the call was invisible to callers, impact, and trace. CodeGraph now walks the type's supertypes (its `extends` / `implements` relationships) to find the method, creating the edge only when a supertype genuinely declares it (so a wrong inference still produces no edge). This makes Java, Kotlin, and C# factory and fluent chains more complete. Existing indexes should be re-indexed (`codegraph index -f`) to benefit. (#750)
33+
- Swift method calls made through a static factory, fluent chain, or constructor now resolve to the correct class. A call like `Foo.make().draw()` or `Foo().draw()` used to drop the receiver, so the chained method silently attached to a same-named method on an unrelated class — or didn't resolve at all. CodeGraph now captures Swift return types and infers the chained receiver's type from what the inner call returns (or the constructed type), creating the edge only when that class genuinely has the method (so a wrong inference produces no edge instead of a misleading one). Existing Swift indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Swift)
3334
- C# method calls made through a static factory or fluent chain now resolve to the correct class. A call like `Foo.Create().Bar()` or `JObject.Parse(s).Property(...)` used to lose the receiver's type, so the chained method didn't resolve and the call was invisible to callers/impact/trace. CodeGraph now captures C# return types and infers the chained receiver's type from what the inner call returns, creating the edge only when that class genuinely has the method (so a wrong inference produces no edge). Existing C# indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (C#)
3435
- Kotlin method calls made through a companion-object factory or fluent chain now resolve to the correct class. A call like `Foo.getInstance().bar()` or `Config.create(opts).build()` used to drop the receiver entirely, so the chained method silently attached to a same-named method on an unrelated class — or didn't resolve at all — corrupting callers, impact, and trace. CodeGraph now captures Kotlin return types and infers the chained receiver's type from what the inner call returns, creating the edge only when that class genuinely has the method (so a wrong inference produces no edge instead of a misleading one). Existing Kotlin indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Kotlin)
3536
- Java method calls made through a static factory or fluent chain now resolve to the correct class. A call like `Foo.getInstance().bar()` or `Config.create(opts).build()` used to lose the receiver's type, so when two classes had a same-named method the call silently attached to whichever was indexed first — or didn't resolve at all — corrupting callers, impact, and trace. CodeGraph now captures Java return types and infers the chained receiver's type from what the inner call returns, creating the edge only when that class genuinely has the method (so a wrong inference produces no edge instead of a misleading one). Covers factories and fluent builders that take arguments (`hashKeys().arrayListValues()`), including builders that return a nested type. Existing Java indexes should be re-indexed (`codegraph index -f`) to benefit. (#750) (Java)

__tests__/resolution.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2403,6 +2403,72 @@ class Caller {
24032403
});
24042404
});
24052405

2406+
describe('Swift chained static-factory call resolution (#645/#608 mechanism)', () => {
2407+
function callerNamesOf(qualifiedName: string): string[] {
2408+
const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName);
2409+
if (!target) return [];
2410+
const names = cg
2411+
.getIncomingEdges(target.id)
2412+
.filter((e) => e.kind === 'calls')
2413+
.map((e) => cg.getNode(e.source)?.name)
2414+
.filter((n): n is string => !!n);
2415+
return [...new Set(names)].sort();
2416+
}
2417+
2418+
it('resolves Foo.make().draw() via the factory return type, never a same-named decoy', async () => {
2419+
// Aaa sorts first and has a same-named draw() — without the fix Swift dropped
2420+
// the receiver to a bare `draw` and attached to Aaa (a wrong edge).
2421+
fs.writeFileSync(
2422+
path.join(tempDir, 'Main.swift'),
2423+
`class Aaa { func draw() {} }
2424+
class Foo {
2425+
static func make() -> Foo { return Foo() }
2426+
func draw() {}
2427+
}
2428+
func runCaller() { Foo.make().draw() }
2429+
`
2430+
);
2431+
cg = await CodeGraph.init(tempDir, { index: true });
2432+
expect(callerNamesOf('Foo::draw')).toEqual(['runCaller']);
2433+
expect(callerNamesOf('Aaa::draw')).toEqual([]);
2434+
});
2435+
2436+
it('resolves a constructor chain Foo().draw() and an args factory chain Foo.build(c).render()', async () => {
2437+
fs.writeFileSync(
2438+
path.join(tempDir, 'Main.swift'),
2439+
`class Config {}
2440+
class Foo {
2441+
static func build(_ c: Config) -> Foo { return Foo() }
2442+
func draw() {}
2443+
func render() {}
2444+
}
2445+
func runCaller() {
2446+
Foo().draw()
2447+
Foo.build(Config()).render()
2448+
}
2449+
`
2450+
);
2451+
cg = await CodeGraph.init(tempDir, { index: true });
2452+
expect(callerNamesOf('Foo::draw')).toEqual(['runCaller']);
2453+
expect(callerNamesOf('Foo::render')).toEqual(['runCaller']);
2454+
});
2455+
2456+
it('creates NO edge when the factory return type lacks the method (silent miss, not a wrong edge)', async () => {
2457+
fs.writeFileSync(
2458+
path.join(tempDir, 'Main.swift'),
2459+
`class Foo {
2460+
static func make() -> Foo { return Foo() }
2461+
}
2462+
class Other { func onlyOther() {} }
2463+
func runCaller() { Foo.make().onlyOther() }
2464+
`
2465+
);
2466+
cg = await CodeGraph.init(tempDir, { index: true });
2467+
// Foo has no onlyOther() — must not mis-attach to the same-named Other::onlyOther.
2468+
expect(callerNamesOf('Other::onlyOther')).toEqual([]);
2469+
});
2470+
});
2471+
24062472
describe('Chained call resolves a method on a supertype (conformance, #750)', () => {
24072473
function callerNamesOf(qualifiedName: string): string[] {
24082474
const target = cg.getNodesByKind('method').find((n) => n.qualifiedName === qualifiedName);

src/extraction/extraction-version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@
2121
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
2222
* in the product is load-bearing").
2323
*/
24-
export const EXTRACTION_VERSION = 9;
24+
export const EXTRACTION_VERSION = 10;

src/extraction/languages/swift.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,44 @@ import type { Node as SyntaxNode } from 'web-tree-sitter';
22
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
33
import type { LanguageExtractor } from '../tree-sitter-types';
44

5+
/**
6+
* A Swift function's declared return type, normalized to the bare class name a
7+
* chained `Foo.make().draw()` could be called on (the #645/#608 mechanism).
8+
* tree-sitter-swift labels BOTH the function name (`simple_identifier`) and the
9+
* return type (a `user_type`) with the field `name`, so `childForFieldName`
10+
* returns the name; the return type is found positionally — the first type node
11+
* after the `simple_identifier` name, before the body. Optionals (`Foo?`) are
12+
* unwrapped; arrays/tuples/function types and `Void` yield undefined.
13+
*/
14+
function extractSwiftReturnType(node: SyntaxNode, source: string): string | undefined {
15+
let seenName = false;
16+
for (let i = 0; i < node.namedChildCount; i++) {
17+
const child = node.namedChild(i);
18+
if (!child) continue;
19+
if (child.type === 'simple_identifier' && !seenName) {
20+
seenName = true;
21+
continue;
22+
}
23+
if (!seenName) continue;
24+
if (child.type === 'function_body') return undefined; // body reached: no return type
25+
let typeNode: SyntaxNode | null = null;
26+
if (child.type === 'user_type') typeNode = child;
27+
else if (child.type === 'optional_type') {
28+
typeNode = child.namedChildren.find((c: SyntaxNode) => c.type === 'user_type') ?? null;
29+
}
30+
if (typeNode) {
31+
// Use the whole type node's text, strip generics, then take the LAST
32+
// dotted segment — a member type `KF.Builder` resolves to `Builder` (its
33+
// first type_identifier is the OUTER `KF`, which would be wrong).
34+
const name = getNodeText(typeNode, source).trim().replace(/<[^>]*>/g, '');
35+
const last = name.split('.').pop()?.trim();
36+
if (!last || !/^[A-Za-z_]\w*$/.test(last) || last === 'Void') return undefined;
37+
return last;
38+
}
39+
}
40+
return undefined;
41+
}
42+
543
export const swiftExtractor: LanguageExtractor = {
644
functionTypes: ['function_declaration'],
745
classTypes: ['class_declaration'],
@@ -18,6 +56,23 @@ export const swiftExtractor: LanguageExtractor = {
1856
bodyField: 'body',
1957
paramsField: 'parameter',
2058
returnField: 'return_type',
59+
getReturnType: extractSwiftReturnType,
60+
resolveName: (node, source) => {
61+
// A nested-type extension `extension KF.Builder { … }` parses as a
62+
// class_declaration whose `name` is a multi-segment `user_type` (`KF.Builder`
63+
// = type_identifiers `KF`, `Builder`). Name the node by the LAST segment
64+
// (`Builder`) so it shares the simple name of the extended type's own
65+
// declaration (`struct Builder` → `KF::Builder`) instead of becoming a
66+
// distinct `KF.Builder` node. Without this, the extension's conformances and
67+
// members are invisible to a chained call on the type — supertype lookup and
68+
// method matching both key off the simple name (#750). Simple names (regular
69+
// class/struct/enum, or `extension Plain`) fall through to default extraction.
70+
if (node.type !== 'class_declaration') return undefined;
71+
const nameNode = getChildByField(node, 'name');
72+
if (!nameNode || nameNode.type !== 'user_type') return undefined;
73+
const ids = nameNode.namedChildren.filter((c: SyntaxNode) => c.type === 'type_identifier');
74+
return ids.length > 1 ? getNodeText(ids[ids.length - 1]!, source) : undefined;
75+
},
2176
getSignature: (node, source) => {
2277
// Swift function signature: func name(params) -> ReturnType
2378
const params = getChildByField(node, 'parameter');

src/extraction/tree-sitter.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2525,32 +2525,36 @@ export class TreeSitterExtractor {
25252525
calleeName = methodName;
25262526
}
25272527
} else if (
2528-
(this.language === 'cpp' || this.language === 'c' || this.language === 'kotlin') &&
2528+
(this.language === 'cpp' ||
2529+
this.language === 'c' ||
2530+
this.language === 'kotlin' ||
2531+
this.language === 'swift') &&
25292532
receiver &&
25302533
receiver.type === 'call_expression'
25312534
) {
25322535
// Receiver that is itself a call — `Foo::instance().bar()`,
25332536
// `openSession()->run()`, `mgr.view().render()` (C/C++), or
2534-
// `Foo.getInstance().bar()` (Kotlin). Keep the inner call so
2535-
// resolution can infer bar()'s class from what the inner call
2536-
// RETURNS (#645/#608). Encode as `<innerCallee>().<method>`; the
2537-
// `().` marker never appears in an ordinary ref, so the resolver
2537+
// `Foo.getInstance().bar()` (Kotlin) / `Foo.make().draw()` (Swift).
2538+
// Keep the inner call so resolution can infer bar()'s class from what
2539+
// the inner call RETURNS (#645/#608). Encode as `<innerCallee>().<method>`;
2540+
// the `().` marker never appears in an ordinary ref, so the resolver
25382541
// can detect and split it. Other languages keep the bare-name
25392542
// behavior (dropping the receiver) below.
25402543
let innerCallee: string;
25412544
let reencode: boolean;
2542-
if (this.language === 'kotlin') {
2543-
// tree-sitter-kotlin has no field names — the inner callee is the
2545+
if (this.language === 'kotlin' || this.language === 'swift') {
2546+
// tree-sitter-kotlin/swift expose the inner callee as the
25442547
// call_expression's first named child (a navigation_expression
2545-
// `Foo.getInstance`, or a bare identifier for a free call).
2548+
// `Foo.getInstance`, or a bare identifier for a free/constructor call).
25462549
const innerNav = receiver.namedChild(0);
25472550
innerCallee = innerNav ? getNodeText(innerNav, this.source).replace(/\s+/g, '') : '';
2548-
// Only re-encode a CLASS / companion-factory chain, whose receiver
2549-
// chain starts with a capitalized type (`Foo.getInstance().bar()`).
2550-
// An instance chain (`list.filter{}.map{}`) has a lowercase receiver
2551-
// whose type we can't recover here — re-encoding it would only drop
2552-
// the edge (no chain resolution, no bare-name fallback), regressing
2553-
// recall in fluent codebases. Leave those to the bare-name path.
2551+
// Only re-encode a CLASS / companion-factory / constructor chain,
2552+
// whose receiver chain starts with a capitalized type
2553+
// (`Foo.getInstance().bar()`, `Foo().bar()`). An instance chain
2554+
// (`list.filter{}.map{}`) has a lowercase receiver whose type we
2555+
// can't recover here — re-encoding it would only drop the edge (no
2556+
// chain resolution, no bare-name fallback), regressing recall in
2557+
// fluent codebases. Leave those to the bare-name path.
25542558
reencode = /^[A-Z]/.test(innerCallee);
25552559
} else {
25562560
const innerFn = getChildByField(receiver, 'function');

src/resolution/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const SUPERTYPE_BEARING_KINDS = new Set<Node['kind']>([
3333
]);
3434

3535
/** Languages whose chained calls use the dotted `inner().method` encoding. */
36-
const DOT_CHAIN_LANGUAGES = new Set(['java', 'kotlin', 'csharp']);
36+
const DOT_CHAIN_LANGUAGES = new Set(['java', 'kotlin', 'csharp', 'swift']);
3737

3838
/** The extractor's chained-receiver encoding: `<inner>().<method>`. */
3939
const CHAIN_SHAPE = /^(.+)\(\)\.(\w+)$/;

src/resolution/name-matcher.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,13 @@ export function matchPhpCallChain(
595595
return resolveMethodOnType(resolvedClass, method, ref, context, 0.85, 'instance-method');
596596
}
597597

598+
/**
599+
* Languages where an unprefixed capitalized call `Foo(args)` constructs the
600+
* class (so a `Foo(args).method()` receiver's type is `Foo`). Java/C# need `new`,
601+
* so a bare `Foo()` there is a method call, not construction — excluded.
602+
*/
603+
const CONSTRUCTS_VIA_BARE_CALL = new Set(['kotlin', 'swift']);
604+
598605
/**
599606
* Resolve a dotted chained call whose receiver is a static factory / fluent call —
600607
* `Foo.getInstance().bar()`, encoded by the extractor as `Foo.getInstance().bar`
@@ -603,7 +610,7 @@ export function matchPhpCallChain(
603610
* it (resolveMethodOnType requires `Type::method` to exist), so a wrong inference
604611
* yields no edge rather than a wrong one (e.g. a same-named `bar()` on an
605612
* unrelated class is never matched). Shared by the dot-notation languages
606-
* (Java, Kotlin, C#) — same receiver shape, same `Class::method` qualified names.
613+
* (Java, Kotlin, C#, Swift) — same receiver shape, same `Class::method` qualified names.
607614
*/
608615
export function matchDottedCallChain(
609616
ref: UnresolvedRef,
@@ -617,13 +624,13 @@ export function matchDottedCallChain(
617624

618625
// Constructor receiver `Foo(args).method()` (encoded `Foo().method`): a bare,
619626
// capitalized inner is a class construction, so the receiver's type is the
620-
// class itself — resolve the method on it. Kotlin only: there an unprefixed
621-
// capitalized call constructs the class, whereas in Java a bare `Foo()` is a
622-
// method call (constructors need `new`), so we must not assume construction.
623-
// A lowercase bare inner is a top-level `factory().method()` whose type we
624-
// can't recover — bail.
627+
// class itself — resolve the method on it. Only in languages where an
628+
// unprefixed capitalized call constructs the class (Kotlin, Swift); in Java/C#
629+
// a bare `Foo()` is a method call (constructors need `new`), so we must not
630+
// assume construction. A lowercase bare inner is a top-level `factory().method()`
631+
// whose type we can't recover — bail.
625632
if (lastDot <= 0) {
626-
if (ref.language !== 'kotlin' || !/^[A-Z]/.test(inner)) return null;
633+
if (!CONSTRUCTS_VIA_BARE_CALL.has(ref.language) || !/^[A-Z]/.test(inner)) return null;
627634
return resolveMethodOnType(inner, method, ref, context, 0.85, 'instance-method', importedFqnOf(inner, ref, context));
628635
}
629636

@@ -1081,11 +1088,16 @@ export function matchReference(
10811088
if (result) return result;
10821089
}
10831090

1084-
// 1d. Dotted chained static-factory / fluent call (Java / Kotlin / C#) —
1091+
// 1d. Dotted chained static-factory / fluent call (Java / Kotlin / C# / Swift) —
10851092
// `Foo.getInstance().bar()` encoded as `Foo.getInstance().bar` (#645/#608
10861093
// mechanism). Resolve bar's class from getInstance's declared return type, then
10871094
// validate the method on it.
1088-
if (ref.language === 'java' || ref.language === 'kotlin' || ref.language === 'csharp') {
1095+
if (
1096+
ref.language === 'java' ||
1097+
ref.language === 'kotlin' ||
1098+
ref.language === 'csharp' ||
1099+
ref.language === 'swift'
1100+
) {
10891101
result = matchDottedCallChain(ref, context);
10901102
if (result) return result;
10911103
}

0 commit comments

Comments
 (0)