Implements the design from `docs/design/mixed-ios-and-react-native-bridging.md`.
Closes the cross-language flow gap so `trace` / `callers` / `callees` / `impact` connect end-to-end across language boundaries in real iOS, React Native, and Expo codebases.
## Bridges shipped
| Boundary | Mechanism | Real-codebase validation |
|---|---|---|
| **Swift ↔ Objective-C** | Resolver applying Apple's @objc auto-bridging name math + Cocoa preposition prefixes | Charts (S, 269) · realm-swift (M, 369) · wikipedia-ios (L, 1734) |
| **React Native legacy bridge** | Resolver parsing `RCT_EXPORT_MODULE` / `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` (ObjC) + `@ReactMethod` (Java/Kotlin) | AsyncStorage (S, ~60) · react-native-svg (M, ~700) · react-native-firebase (L, ~1100) |
| **React Native TurboModules** | Resolver treating `Native<X>.ts` spec interface as ground truth | via RNSvg + RNFirebase subsets |
| **Native → JS events** | Synthesizer matching native `sendEventWithName:`/`emit(...)` to JS `addListener('e', handler)` keyed by literal event name; falls back to enclosing constant/variable for wrapper-API parameter handlers | RNGeolocation (S) · RNFirebase (L) |
| **Expo Modules** | Framework extract synthesizes `method` nodes from Swift/Kotlin `Module { Name("X"); Function("y") { ... } }` DSL | expo-haptics (S, 14) · expo-camera (M, 72) · ExpoSweep (L, 332, 7 packages) |
| **Fabric + legacy Paper view components** | Extract `component` + `property` nodes from Codegen `codegenNativeComponent<Props>('Name', ...)` specs AND legacy `RCT_EXPORT_VIEW_PROPERTY` / `@ReactProp` macros, then synthesize component → native class by name+suffix convention | react-native-segmented-control (S, legacy) · react-native-screens (M, Codegen) · react-native-skia (L, hybrid monorepo) |
## Bug fixes surfaced along the way
- `tree-sitter.ts` message_expression — multi-keyword ObjC call sites now reconstruct `a🅱️` selectors so they resolve to multi-part method definitions (gap discovered post-#165; 0 → 84 call edges to `GET:parameters:...` style methods on AFNetworking).
- `src/index.ts` resolver lifecycle — `indexAll()` now re-initializes the resolver after extraction so framework `detect()` sees the populated index. Pre-existing latent bug that affected UIKit and SwiftUI resolvers too.
- `src/extraction/index.ts` `buildDetectionContext` — added `listDirectories` so framework detect() can probe monorepo subpackages uniformly (fix needed for react-native-skia detection).
## Regression check on 5 control repos
| Repo | Result |
|---|---|
| Express (small JS) | ✅ unchanged — 266 routes, express framework detected |
| Excalidraw (medium TS/React) | ✅ 9284 nodes (CLAUDE.md baseline ~9290); canonical `trace(mutateElement, renderStaticScene)` returns the flow |
| Django realworld (Python) | ✅ django framework detected, 16 routes |
| Spring petclinic (Java) | ✅ spring framework detected, 17 routes |
| Texture (pure ObjC, large) | ✅ exactly matches #165 baseline: 4702 methods, 894 classes, 808/808 file coverage, 913 multi-keyword selectors, 55 protocols, 1036 properties |
## Tests
928 passing (+87 net new bridge tests across the 5 channels); 2 pre-existing skips. The mcp-staleness-banner / watcher parallel flakiness is unchanged by this work (different test fails each run, all pass in isolation; pre-existing on main).
## Documentation
- README: new 'Mixed iOS / React Native / Expo bridging' section with the per-boundary table and validation-corpus links.
- CHANGELOG `[Unreleased]`: full entry per bridge with measurements.
- `docs/design/mixed-ios-and-react-native-bridging.md`: the design doc (§8 measurements filled in across §8a-§8g).
- `docs/design/dynamic-dispatch-coverage-playbook.md` §6 coverage matrix: six new rows.
- `.claude/skills/agent-eval/corpus.json`: four new sections covering 15 real GitHub repos for the eval harness.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
/**
|
|
* Swift ↔ Objective-C bridging rules.
|
|
*
|
|
* Apple's auto-bridging mechanism exposes Swift declarations to the ObjC
|
|
* runtime under a deterministic selector name. The full rule set:
|
|
* https://developer.apple.com/documentation/swift/importing-swift-into-objective-c
|
|
*
|
|
* This module is **pure name math** — given a Swift declaration's base name
|
|
* + parameter external labels (or the raw signature text), produce the
|
|
* bridged ObjC selector(s); given an ObjC selector, produce the
|
|
* candidate Swift base names. No graph/DB access here.
|
|
*
|
|
* Used by `frameworks/swift-objc.ts` (the framework resolver that wires
|
|
* the rules into the resolution pipeline) and by its tests.
|
|
*
|
|
* ─── Bridging cheat sheet ───────────────────────────────────────────────
|
|
*
|
|
* Swift declaration ObjC selector
|
|
* ───────────────────────────────────────── ─────────────────────────
|
|
* func play() play
|
|
* func play(_ song: String) play:
|
|
* func play(song: String) playWithSong:
|
|
* func play(_ song: String, by artist: String) play:by:
|
|
* func play(song: String, by artist: String) playWithSong:by:
|
|
* init(name: String) initWithName:
|
|
* init(name: String, age: Int) initWithName:age:
|
|
* var name: String (getter / setter) name / setName:
|
|
* @objc(custom:) func f(_ x: Int) custom: (literal override)
|
|
*
|
|
* The reverse direction (ObjC → Swift) collapses the bridge: a Swift call
|
|
* site for `play(song:)` reaches us as the bare base name `play` (Swift's
|
|
* tree-sitter call_expression strips parameter labels from the callee
|
|
* name). So `swiftBaseNamesForObjcSelector('playWithSong:')` returns
|
|
* `['play']` — the resolver looks up Swift methods named `play`.
|
|
*/
|
|
|
|
/**
|
|
* Capitalize the first character of a string. Used for the "With"-prefix
|
|
* form on the first selector keyword when the Swift declaration has an
|
|
* explicit first-parameter label (e.g. `func play(song:)` → `playWithSong:`).
|
|
*/
|
|
function capFirst(s: string): string {
|
|
return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
|
}
|
|
|
|
/**
|
|
* Lowercase the first character. Used in reverse: `setName:` setter ↔
|
|
* Swift property `name`.
|
|
*/
|
|
function lowerFirst(s: string): string {
|
|
return s.length > 0 ? s.charAt(0).toLowerCase() + s.slice(1) : s;
|
|
}
|
|
|
|
/**
|
|
* Compute the auto-bridged ObjC selector for a Swift method declaration.
|
|
*
|
|
* @param baseName The Swift method's base name (e.g. `play`).
|
|
* @param externalLabels Parameter EXTERNAL labels in declaration order;
|
|
* `null` for a `_` (unlabeled) parameter.
|
|
* `[]` for a no-parameter method.
|
|
* @param explicitObjcName If `@objc(customSel:)` was specified, the
|
|
* literal selector — short-circuits the rule
|
|
* and is returned as-is.
|
|
* @returns The ObjC selector (e.g. `playWithSong:by:`), or `null` if it
|
|
* can't be determined.
|
|
*
|
|
* **Method rules:**
|
|
* - No params → base name (no colons)
|
|
* - Single param, `_` label → `baseName:`
|
|
* - Single param, explicit label `L` → `baseNameWithL:`
|
|
* - Multi-param, `_` first label → `baseName:label2:label3:`
|
|
* - Multi-param, explicit first label `L1` → `baseNameWithL1:label2:label3:`
|
|
*
|
|
* Initializer rules are handled by `objcSelectorForSwiftInit`.
|
|
*/
|
|
export function objcSelectorForSwiftMethod(
|
|
baseName: string,
|
|
externalLabels: (string | null)[],
|
|
explicitObjcName?: string | null
|
|
): string | null {
|
|
if (!baseName) return null;
|
|
if (explicitObjcName) return explicitObjcName;
|
|
|
|
if (externalLabels.length === 0) {
|
|
return baseName;
|
|
}
|
|
|
|
const [first, ...rest] = externalLabels;
|
|
// Single param: "_" → "base:" ; "label" → "baseWithLabel:"
|
|
// Multi-param mirrors the same first-keyword formation, then appends each
|
|
// subsequent label as its own keyword. A `null` later label is invalid
|
|
// ObjC (no way to express unlabeled middle params) — keep as `:` to be safe.
|
|
const firstKeyword =
|
|
first === null || first === undefined || first === '_' || first === ''
|
|
? `${baseName}:`
|
|
: `${baseName}With${capFirst(first)}:`;
|
|
|
|
const restKeywords = rest.map((l) => `${l ?? ''}:`).join('');
|
|
return firstKeyword + restKeywords;
|
|
}
|
|
|
|
/**
|
|
* Compute the bridged ObjC selector for a Swift `init(...)` declaration.
|
|
*
|
|
* **Init rules** (different from regular methods — Apple always uses
|
|
* `initWith` regardless of whether the first label is `_`):
|
|
* - `init()` → `init`
|
|
* - `init(_ name: String)` → `initWithName:` (uses the INTERNAL
|
|
* name when external is `_`, per Apple's
|
|
* bridging conventions)
|
|
* - `init(name: String)` → `initWithName:`
|
|
* - `init(name: String, age: Int)` → `initWithName:age:`
|
|
*
|
|
* For the `_` case we need the internal (second identifier) name —
|
|
* passed via `internalNames`.
|
|
*/
|
|
export function objcSelectorForSwiftInit(
|
|
externalLabels: (string | null)[],
|
|
internalNames: string[],
|
|
explicitObjcName?: string | null
|
|
): string | null {
|
|
if (explicitObjcName) return explicitObjcName;
|
|
|
|
if (externalLabels.length === 0) {
|
|
return 'init';
|
|
}
|
|
|
|
const [firstExt, ...restExt] = externalLabels;
|
|
const [firstInt] = internalNames;
|
|
// Use the internal name when external is "_"; ObjC needs *some* keyword,
|
|
// and Swift's auto-bridger uses the parameter's local name in this case.
|
|
const firstLabel =
|
|
firstExt === null || firstExt === '_' || firstExt === ''
|
|
? firstInt
|
|
: firstExt;
|
|
if (!firstLabel) return null;
|
|
|
|
const firstKeyword = `initWith${capFirst(firstLabel)}:`;
|
|
const restKeywords = restExt
|
|
.map((label, idx) => {
|
|
const internal = internalNames[idx + 1];
|
|
const name = label && label !== '_' ? label : internal ?? '';
|
|
return `${name}:`;
|
|
})
|
|
.join('');
|
|
return firstKeyword + restKeywords;
|
|
}
|
|
|
|
/**
|
|
* Compute the bridged ObjC getter + setter for a Swift `@objc` property.
|
|
*
|
|
* - `var name: String` → getter `name`, setter `setName:`
|
|
* - `var isReady: Bool` → getter `isReady`, setter `setIsReady:`
|
|
* (no special `is` handling — Swift's `isReady` stays as `isReady` in ObjC;
|
|
* `@objc(name:)` overrides if a Cocoa-style getter `isReady` / setter
|
|
* `setReady:` pairing is needed — that's the responsibility of the
|
|
* declaration's `@objc(customGetter)` annotation, which we surface via
|
|
* `explicitObjcName`.)
|
|
*/
|
|
export function objcAccessorsForSwiftProperty(
|
|
swiftName: string,
|
|
explicitObjcName?: string | null
|
|
): { getter: string; setter: string } | null {
|
|
if (!swiftName) return null;
|
|
// The override syntax `@objc(customGetterName)` re-points the GETTER only;
|
|
// the setter still follows the `setX:` rule but is keyed off the override.
|
|
// (`@objc(getX:setY:)` is not currently supported — that's a rarer
|
|
// shape; can extend later if a real codebase needs it.)
|
|
const getter = explicitObjcName ?? swiftName;
|
|
return {
|
|
getter,
|
|
setter: `set${capFirst(getter)}:`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Reverse: from an ObjC selector, return the candidate Swift base names
|
|
* the resolver should try when looking for the bridged Swift declaration.
|
|
*
|
|
* Examples:
|
|
* `play` → ['play']
|
|
* `play:` → ['play']
|
|
* `playWithSong:` → ['play', 'playWithSong']
|
|
* `play:by:` → ['play']
|
|
* `playWithSong:by:` → ['play', 'playWithSong']
|
|
* `initWithName:` → ['init'] (init is its own base name)
|
|
* `initWithName:age:` → ['init']
|
|
* `setName:` → ['name', 'setName'] (could be a setter OR a regular func)
|
|
* `tableView:didSel…:` → ['tableView']
|
|
*
|
|
* Returns multiple candidates because the bare base name is ambiguous —
|
|
* `playWithSong:` could correspond to either `func play(song:)` or
|
|
* `func playWithSong(_ x:)` (a Swift method literally named that with a
|
|
* `_` first label). The resolver tries each.
|
|
*/
|
|
export function swiftBaseNamesForObjcSelector(selector: string): string[] {
|
|
if (!selector) return [];
|
|
|
|
// Strip trailing colons and split into keywords.
|
|
const keywords = selector.replace(/:+$/g, '').split(':');
|
|
const firstKeyword = keywords[0];
|
|
if (!firstKeyword) return [];
|
|
|
|
const candidates: Set<string> = new Set();
|
|
|
|
// Always a candidate: the raw first keyword. Covers
|
|
// `play:` → `play`
|
|
// `play:by:` → `play`
|
|
// `playWithSong:` → `playWithSong` (a literal Swift name)
|
|
// `tableView:...:` → `tableView`
|
|
candidates.add(firstKeyword);
|
|
|
|
// `initWith<X>:` and `initWith<X>:<more>:` always reduce to `init`.
|
|
if (firstKeyword.startsWith('initWith')) {
|
|
candidates.add('init');
|
|
}
|
|
|
|
// Preposition-prefix patterns: `<base>(With|For|By|In|On|At|From|To|Of|As)<Cap>:`
|
|
// covers both Swift's @objc EXPORT rule (always "With") and Cocoa's
|
|
// IMPORTED selectors which use other prepositions natively (e.g.
|
|
// `objectForKey:`, `stringWithFormat:`, `compareTo:`,
|
|
// `imageNamed:inBundle:`). Strip to recover the Swift base name a caller
|
|
// would use (e.g. `object`, `string`, `compare`, `image`).
|
|
const prepositionMatch = firstKeyword.match(
|
|
/^([a-z][a-zA-Z0-9]*?)(?:With|For|By|In|On|At|From|To|Of|As)[A-Z]/
|
|
);
|
|
if (prepositionMatch && prepositionMatch[1]) {
|
|
candidates.add(prepositionMatch[1]);
|
|
}
|
|
|
|
// `setX:` could be a property setter — the Swift property is `x` (lowercase).
|
|
// Only fires for the obvious shape: `set` + capital letter + ':' (one param).
|
|
if (
|
|
keywords.length === 1 &&
|
|
/^set[A-Z]/.test(firstKeyword) &&
|
|
selector.endsWith(':')
|
|
) {
|
|
const propName = lowerFirst(firstKeyword.slice(3));
|
|
if (propName) candidates.add(propName);
|
|
}
|
|
|
|
return Array.from(candidates);
|
|
}
|
|
|
|
/**
|
|
* Detect whether a Swift method `@objc` declaration uses the `@objc(custom:)`
|
|
* override form, returning the literal selector when present.
|
|
*
|
|
* Regex-based scan over the small chunk of source preceding the declaration —
|
|
* tree-sitter would be more precise but this is only consulted as a fallback
|
|
* when the structured AST isn't available (e.g. resolver-time lookups
|
|
* via `context.readFile`).
|
|
*
|
|
* Returns `null` when the declaration is plain `@objc` (no override) or has
|
|
* no `@objc` attribute at all.
|
|
*/
|
|
export function detectExplicitObjcName(sourceSlice: string): string | null {
|
|
// `@objc(customName:)` or `@objc(custom:name:)` — the parens contents are
|
|
// the literal ObjC selector. Whitespace permitted.
|
|
const m = sourceSlice.match(/@objc\s*\(\s*([^)\s]+)\s*\)/);
|
|
return m && m[1] ? m[1] : null;
|
|
}
|
|
|
|
/**
|
|
* Detect whether a Swift declaration is `@objc`-exposed by scanning the
|
|
* source slice that precedes it. Returns true for explicit `@objc`,
|
|
* `@objc(custom:)`, or membership in a `@objcMembers` class (caller's
|
|
* responsibility to pass class-level context if relevant).
|
|
*
|
|
* `@nonobjc` returns false even if `@objc` also appears (per Swift's rule
|
|
* that `@nonobjc` opts out of class-level `@objcMembers`).
|
|
*/
|
|
export function isObjcExposed(sourceSlice: string): boolean {
|
|
if (/@nonobjc\b/.test(sourceSlice)) return false;
|
|
return /@objc\b/.test(sourceSlice);
|
|
}
|