feat(resolution): mixed iOS / React Native / Expo cross-language bridging (#430)

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)
This commit is contained in:
Colby Mchenry
2026-05-26 02:14:00 -05:00
committed by GitHub
parent 1821038e4b
commit 4d1a2b3c4d
22 changed files with 3786 additions and 6 deletions
+193
View File
@@ -0,0 +1,193 @@
/**
* Expo Modules framework — close the JS → native flow for Expo SDK packages.
*
* Expo Modules use a Swift / Kotlin DSL distinct from the React Native legacy
* bridge. Each native module is a class extending `Module` whose
* `definition()` body declares the JS surface via literal `Name(...)`,
* `Function(...)`, `AsyncFunction(...)`, `Property(...)`, and `View {...}`
* calls. Tree-sitter parses these as ordinary call_expressions with trailing
* closures, so the JS-visible methods don't exist as named symbol nodes by
* default — `Camera.takePictureAsync(...)` on the JS side has nothing to
* resolve to.
*
* This framework extractor walks the file source for those declarative
* literals and emits method nodes named `takePictureAsync` /
* `notificationAsync` / `width` / etc., attributed to the Swift / Kotlin
* file. The standard name-matcher then resolves JS `Foo.takePictureAsync(...)`
* to them via the existing `obj.method` → method-name path — no separate
* resolve() branch needed.
*
* Real-world shape (expo-haptics):
*
* public class HapticsModule: Module {
* public func definition() -> ModuleDefinition {
* Name("ExpoHaptics")
* AsyncFunction("notificationAsync") { ... }
* AsyncFunction("impactAsync") { ... }
* AsyncFunction("selectionAsync") { ... }
* }
* }
*
* Kotlin Module declarations are the same DSL (the API mirrors Swift).
*
* Anti-goals (deferred):
* - The trailing-closure BODY is not extracted as the method's body — it
* remains attributed to `definition()` in the existing extraction. Future
* work could synthesize a body-range for richer `trace` output, but the
* reachability (which is the bridge's main value) is already complete.
* - `View { ... }` blocks expose JSX prop bindings; that overlaps with
* Fabric (Phase 6) and is left to that phase.
*/
import type { Node } from '../../types';
import {
FrameworkExtractionResult,
FrameworkResolver,
} from '../types';
/**
* Match `Function("name")`, `AsyncFunction("name")`, or `Property("name")`
* at the start of an expression (line-anchored after optional whitespace).
* The trailing closure that follows isn't captured — we just need the name
* literal that becomes the JS-visible method.
*
* NOTE: the regex deliberately requires the open paren to live on the same
* line as the keyword, which matches every real Expo Module declaration
* style. Multi-line `AsyncFunction(\n"x"\n)` forms aren't a real shape in
* the SDK; if any appear we'd extend the regex.
*/
const EXPO_DECL_RE =
/\b(Function|AsyncFunction|Property|Constants)\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g;
/**
* Match the module name literal `Name("ExpoX")`. Used to enrich each emitted
* method's qualifiedName so the same JS callsite to `Foo.fn` doesn't ambiguate
* across multiple Expo modules in a monorepo.
*/
const EXPO_MODULE_NAME_RE = /\bName\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/;
/**
* Heuristic class-name match — used as a fallback if `Name(...)` literal
* isn't found. Detects `class XxxModule: Module` (Swift) or
* `class XxxModule : Module` (Kotlin / with whitespace tolerance).
*/
const EXPO_CLASS_RE =
/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*Module\b/;
/**
* Detect whether a file is plausibly an Expo Module — looking for both
* the `: Module` inheritance and at least one declarative `Function(...)`
* / `AsyncFunction(...)` / `Property(...)` / `Name(...)` literal. Any one
* of those alone produces too many false positives (random Swift code can
* have `class X: Module` for unrelated reasons).
*/
function isExpoModuleSource(source: string): boolean {
if (!EXPO_CLASS_RE.test(source)) return false;
// Reset lastIndex defensively; EXPO_DECL_RE has the `g` flag.
EXPO_DECL_RE.lastIndex = 0;
return EXPO_DECL_RE.test(source);
}
/**
* Extract Expo Module method declarations from a Swift / Kotlin source
* file. Each `Function("X") { … }` / `AsyncFunction("X") { … }` /
* `Property("X") { … }` literal becomes a method node named `X`,
* attributed to the file at the line of the literal.
*/
function extractExpoMethods(filePath: string, source: string, language: 'swift' | 'kotlin'): Node[] {
if (!isExpoModuleSource(source)) return [];
const nodes: Node[] = [];
const nameMatch = source.match(EXPO_MODULE_NAME_RE);
const classMatch = source.match(EXPO_CLASS_RE);
// Prefer the explicit `Name("X")` literal — that's the JS-visible
// module name. Class name is the fallback.
const moduleName = nameMatch?.[1] ?? classMatch?.[1] ?? 'ExpoModule';
const now = Date.now();
const seenAtLine = new Set<string>();
EXPO_DECL_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = EXPO_DECL_RE.exec(source)) !== null) {
const kind = m[1]!;
const methodName = m[2]!;
// Compute line number from match index.
const before = source.slice(0, m.index);
const startLine = before.split('\n').length;
// Avoid duplicates if the same method literal appears twice in one
// file (e.g., declared and re-declared inside a `View {...}` block).
const dedupKey = `${methodName}:${startLine}`;
if (seenAtLine.has(dedupKey)) continue;
seenAtLine.add(dedupKey);
const startColumn = before.length - before.lastIndexOf('\n') - 1;
nodes.push({
id: `expo-module:${filePath}:${moduleName}:${methodName}:${startLine}`,
kind: 'method',
name: methodName,
qualifiedName: `${filePath}::${moduleName}.${methodName}`,
filePath,
language,
startLine,
// We don't extract the closure body's end-line — use the literal's
// line as a single-line range. trace/explore still surfaces the
// declaration site, which is the main user-visible signal.
endLine: startLine,
startColumn,
endColumn: startColumn + kind.length + 2 + methodName.length + 2,
docstring: `Expo Modules ${kind}("${methodName}") in ${moduleName}`,
signature: `${kind}("${methodName}")`,
isExported: true,
updatedAt: now,
});
}
return nodes;
}
export const expoModulesResolver: FrameworkResolver = {
name: 'expo-modules',
languages: ['swift', 'kotlin'],
/**
* Detect Expo Modules by looking at the project's package.json or
* a small scan of source files for the `: Module` + declarative-DSL
* markers. Either signal suffices.
*/
detect(context) {
const pkg = context.readFile('package.json');
if (pkg && /["']expo-modules-core["']\s*:/.test(pkg)) return true;
const files = context.getAllFiles();
for (let i = 0; i < Math.min(files.length, 200); i++) {
const f = files[i];
if (!f) continue;
if (f.endsWith('.swift') || f.endsWith('.kt')) {
const src = context.readFile(f);
if (src && isExpoModuleSource(src)) return true;
}
}
return false;
},
/**
* Per-file extraction — the orchestrator invokes this for every
* `.swift` / `.kt` file in the project. We only emit nodes when the
* file looks like an Expo Module; otherwise return empty.
*/
extract(filePath, source): FrameworkExtractionResult {
const language = filePath.endsWith('.kt') ? 'kotlin' : 'swift';
return {
nodes: extractExpoMethods(filePath, source, language),
references: [],
};
},
/**
* No bespoke resolution needed — the synthetic method nodes emitted by
* `extract()` get picked up by the standard name-matcher when a JS
* callsite like `Foo.takePictureAsync(args)` resolves. Returning null
* here is correct.
*/
resolve() {
return null;
},
};
+411
View File
@@ -0,0 +1,411 @@
/**
* React Native Fabric / Codegen view components — Phase 6 of the
* mixed-iOS/RN bridging effort.
*
* In the new RN architecture, JS-visible view components are declared via
* Codegen TS spec files of the shape:
*
* // src/fabric/MyComponentNativeComponent.ts
* import { codegenNativeComponent } from 'react-native';
* import type { ViewProps, CodegenTypes as CT } from 'react-native';
*
* export interface NativeProps extends ViewProps {
* color?: ColorValue;
* onTap?: CT.DirectEventHandler<TapEvent>;
* }
*
* export default codegenNativeComponent<NativeProps>('MyComponent');
*
* Codegen then generates a native ComponentDescriptor that wires the JS
* component name to a native implementation class — by RN convention,
* one of `MyComponent`, `MyComponentView`, `MyComponentComponentView`,
* `MyComponentManager`, `MyComponentViewManager`. The actual implementation
* lives in ObjC++ (.mm) on iOS or Kotlin/Java on Android.
*
* Without bridging, JSX `<MyComponent color="red"/>` in a consumer app has
* nothing in the graph to land on — the JS-visible name `MyComponent` isn't
* a node anywhere (only `MyComponentView` is, in the .mm), and the JSX
* synthesizer matches strictly by name.
*
* What this extractor does:
* 1. Parse the spec file's `codegenNativeComponent<Props>('Name', ...)`
* literal — emit a `component` node named `Name`, attributed to the
* spec file.
* 2. Parse the `NativeProps` interface and emit one `property` node per
* prop, attributed to the spec file. Props like `onTap` /
* `onFinishTransitioning` are JS-callable event-handler bindings;
* surfacing them as nodes lets the agent discover the JS surface of
* the component.
*
* A companion synthesizer (`fabricNativeImplEdges` in
* callback-synthesizer.ts) links the emitted component node to its
* native implementation class via the convention-based name+suffix
* lookup — that produces the cross-language hop the JSX synthesizer's
* `<MyComponent>` edges naturally chain through.
*/
import type { Node } from '../../types';
import {
FrameworkExtractionResult,
FrameworkResolver,
} from '../types';
const CODEGEN_DECL_RE =
/codegenNativeComponent\s*(?:<[^>]+>)?\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/g;
/**
* Legacy Paper view manager macros — older RN libs (still very common,
* especially small libs that haven't migrated to Codegen) declare a
* ViewManager class and expose props via these macros. Both shapes:
*
* RCT_EXPORT_VIEW_PROPERTY(values, NSArray)
* RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock)
* RCT_CUSTOM_VIEW_PROPERTY(text, NSString, RNCMyView) { … }
* RCT_REMAP_VIEW_PROPERTY(jsName, nativeKeyPath, NSString)
*
* Capture the FIRST argument — that's the JS-visible prop name.
*/
const RCT_VIEW_PROP_RE =
/\bRCT_(?:EXPORT|CUSTOM|REMAP)_VIEW_PROPERTY\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/g;
/**
* ObjC `@implementation Foo` extraction. Used to identify the ViewManager
* class so we can derive a JS-visible component name (strip the `Manager`
* suffix and a leading `RCT` prefix, both standard conventions).
*/
const OBJC_IMPL_RE = /@implementation\s+([A-Za-z_][A-Za-z0-9_]*)/;
/**
* Derive the JS-visible component name from a native ViewManager class.
* Strip a trailing `Manager` (and optionally `ViewManager`) — RN's view
* registry maps `XXXManager` ↔ JS `<XXX/>` by this convention. The
* leading `RCT` prefix is also stripped (matches what
* `defaultObjcModuleName` does for RN's legacy bridge modules).
*/
function deriveComponentNameFromManager(className: string): string {
let name = className.startsWith('RCT') ? className.slice(3) : className;
// Trim ViewManager > Manager > View, in order.
if (name.endsWith('ViewManager')) name = name.slice(0, -'ViewManager'.length);
else if (name.endsWith('Manager')) name = name.slice(0, -'Manager'.length);
return name;
}
/**
* Cheap source-level detector — must contain `codegenNativeComponent` to
* be worth parsing. The presence of that import is the canonical Fabric
* spec signal.
*/
function isFabricSpec(source: string): boolean {
return source.includes('codegenNativeComponent');
}
/**
* Pull the `NativeProps` interface body out of a Fabric spec source.
* Returns `null` when the interface isn't declared in the expected shape.
*/
function findNativePropsBody(source: string): string | null {
// Permissive: `export interface NativeProps [extends X, Y] { … }`.
const m = source.match(/export\s+interface\s+NativeProps\b[^{]*\{([\s\S]*?)\n\}/);
return m?.[1] ?? null;
}
/**
* Parse the NativeProps interface body and return prop names.
* Each prop is `name?: Type;` or `name: Type;` on its own line.
* We don't care about types — just the JS-visible name.
*/
function extractPropNames(body: string): string[] {
const props: string[] = [];
// Anchor to start-of-line (after optional whitespace), then capture an
// identifier, then optional `?`, then `:`. Skip lines that look like
// method declarations (`name(`) — those are TurboModule spec methods,
// not view props.
const regex = /^\s*([A-Za-z_][A-Za-z0-9_]*)\??\s*:/gm;
let m: RegExpExecArray | null;
while ((m = regex.exec(body)) !== null) {
const name = m[1]!;
// Exclude any line that immediately turns into a function-shape (e.g.
// `onTap?: () => void` is fine — it's a prop, not a method body —
// but a literal `name(arg: T): R` is a method declaration).
const after = body.slice(m.index + m[0].length, m.index + m[0].length + 80);
if (/^\s*\(/.test(after)) continue; // method-shape, skip
props.push(name);
}
return props;
}
/**
* Extract legacy Paper view-manager declarations from a .m/.mm file.
* Emits a `component` node named after the JS-visible name (derived from
* the @implementation class) plus a `property` node per
* `RCT_EXPORT_VIEW_PROPERTY(name, ...)` macro.
*
* Returns `[]` if the file doesn't look like a ViewManager (no
* RCT_EXPORT_VIEW_PROPERTY macros).
*/
function extractLegacyViewManagerNodes(filePath: string, source: string): Node[] {
// Cheap gate: no view-property macros at all → not a view manager.
if (!source.includes('RCT_EXPORT_VIEW_PROPERTY') &&
!source.includes('RCT_CUSTOM_VIEW_PROPERTY') &&
!source.includes('RCT_REMAP_VIEW_PROPERTY')) {
return [];
}
const implMatch = source.match(OBJC_IMPL_RE);
if (!implMatch || !implMatch[1]) return [];
const className = implMatch[1];
// Only process actual ViewManagers — classes ending in Manager or
// (legacy) ViewManager. Classes with view-property macros that don't
// follow the naming convention are unusual; skip to keep precision.
if (!className.endsWith('Manager') && !className.endsWith('ViewManager')) return [];
const componentName = deriveComponentNameFromManager(className);
if (!componentName) return [];
const now = Date.now();
const nodes: Node[] = [];
// Component node — same shape as Codegen Fabric's, so the
// fabricNativeImplEdges synthesizer linking component → native class
// works for legacy too. The native class IS the manager itself in this
// case; the convention-based suffix lookup in the synthesizer
// (`Manager`, `ViewManager`) will find it.
const before = source.slice(0, implMatch.index ?? 0);
const startLine = before.split('\n').length;
nodes.push({
id: `fabric-component:${filePath}:${componentName}:${startLine}`,
kind: 'component',
name: componentName,
qualifiedName: `${filePath}::${componentName}`,
filePath,
language: 'objc',
startLine,
endLine: startLine,
startColumn: 0,
endColumn: componentName.length,
docstring: `Legacy Paper ViewManager component '${componentName}' (from @implementation ${className})`,
signature: `RCT_EXPORT_MODULE() // ViewManager: ${className}`,
isExported: true,
updatedAt: now,
});
// Property nodes per RCT_EXPORT_VIEW_PROPERTY macro.
const seen = new Set<string>();
RCT_VIEW_PROP_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = RCT_VIEW_PROP_RE.exec(source)) !== null) {
const propName = m[1]!;
if (seen.has(propName)) continue;
seen.add(propName);
const propBefore = source.slice(0, m.index);
const propLine = propBefore.split('\n').length;
nodes.push({
id: `fabric-prop:${filePath}:${propName}:${propLine}`,
kind: 'property',
name: propName,
qualifiedName: `${filePath}::${componentName}.${propName}`,
filePath,
language: 'objc',
startLine: propLine,
endLine: propLine,
startColumn: 0,
endColumn: propName.length,
docstring: `Legacy Paper view prop '${propName}' on ${componentName}`,
isExported: true,
updatedAt: now,
});
}
return nodes;
}
/**
* Java/Kotlin `@ReactProp("name")` extraction. The annotation precedes a
* setter method on a class that extends `ViewManager` /
* `SimpleViewManager` (or in Kotlin, `:` syntax).
*
* Returns `[]` if no @ReactProp annotations are found.
*/
function extractJvmViewManagerNodes(filePath: string, source: string): Node[] {
if (!source.includes('@ReactProp')) return [];
// Class name — looking for `class FooManager [extends ViewManager...]`
// (Java) or `class FooManager : ViewManager...` (Kotlin). Either gates
// us into a ViewManager file; non-Manager classes with @ReactProp are
// unusual.
const classMatch = source.match(/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\b/);
if (!classMatch || !classMatch[1]) return [];
const className = classMatch[1];
if (!className.endsWith('Manager') && !className.endsWith('ViewManager')) return [];
const componentName = deriveComponentNameFromManager(className);
if (!componentName) return [];
const language: 'java' | 'kotlin' = filePath.endsWith('.kt') ? 'kotlin' : 'java';
const now = Date.now();
const nodes: Node[] = [];
const classBefore = source.slice(0, classMatch.index ?? 0);
const startLine = classBefore.split('\n').length;
nodes.push({
id: `fabric-component:${filePath}:${componentName}:${startLine}`,
kind: 'component',
name: componentName,
qualifiedName: `${filePath}::${componentName}`,
filePath,
language,
startLine,
endLine: startLine,
startColumn: 0,
endColumn: componentName.length,
docstring: `Android view-manager component '${componentName}' (from class ${className})`,
signature: `class ${className} : ViewManager`,
isExported: true,
updatedAt: now,
});
// @ReactProp("name") followed (after optional modifiers / args) by a
// setter declaration. The annotation argument is the JS-visible prop
// name. Permissive about the rest — we only need the literal.
const REACT_PROP_RE = /@ReactProp\s*\(\s*(?:name\s*=\s*)?"([^"]+)"/g;
const seen = new Set<string>();
let m: RegExpExecArray | null;
while ((m = REACT_PROP_RE.exec(source)) !== null) {
const propName = m[1]!;
if (seen.has(propName)) continue;
seen.add(propName);
const propBefore = source.slice(0, m.index);
const propLine = propBefore.split('\n').length;
nodes.push({
id: `fabric-prop:${filePath}:${propName}:${propLine}`,
kind: 'property',
name: propName,
qualifiedName: `${filePath}::${componentName}.${propName}`,
filePath,
language,
startLine: propLine,
endLine: propLine,
startColumn: 0,
endColumn: propName.length,
docstring: `Android @ReactProp prop '${propName}' on ${componentName}`,
isExported: true,
updatedAt: now,
});
}
return nodes;
}
function extractFabricNodes(filePath: string, source: string): Node[] {
if (!isFabricSpec(source)) return [];
const now = Date.now();
const nodes: Node[] = [];
CODEGEN_DECL_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = CODEGEN_DECL_RE.exec(source)) !== null) {
const componentName = m[1]!;
const before = source.slice(0, m.index);
const startLine = before.split('\n').length;
const startColumn = before.length - before.lastIndexOf('\n') - 1;
// The component itself — kind: 'component' so the existing
// reactJsxChildEdges synthesizer matches `<MyComponent>` JSX tags to
// it (its name+kind filter is the gate).
const componentId = `fabric-component:${filePath}:${componentName}:${startLine}`;
nodes.push({
id: componentId,
kind: 'component',
name: componentName,
qualifiedName: `${filePath}::${componentName}`,
filePath,
// The spec file is .ts or .tsx; use the file's apparent language
// by extension. Trim to a known Language value.
language: filePath.endsWith('.tsx') ? 'tsx' : 'typescript',
startLine,
endLine: startLine,
startColumn,
endColumn: startColumn + 'codegenNativeComponent'.length,
docstring: `Fabric/Codegen native component '${componentName}'`,
signature: `codegenNativeComponent<NativeProps>('${componentName}')`,
isExported: true,
updatedAt: now,
});
}
// Props from the NativeProps interface. These are not "method" semantic
// — they're JS-visible bindings the consumer sets via JSX attributes —
// so use `property` kind. (The JSX synthesizer doesn't currently
// produce per-attribute edges, but surfacing the prop names as nodes
// lets `codegraph_search('onFinishTransitioning')` discover them.)
const body = findNativePropsBody(source);
if (body) {
const props = extractPropNames(body);
for (const propName of props) {
const propBefore = source.indexOf(propName, source.indexOf(body));
const propLine =
propBefore >= 0 ? source.slice(0, propBefore).split('\n').length : 1;
nodes.push({
id: `fabric-prop:${filePath}:${propName}:${propLine}`,
kind: 'property',
name: propName,
qualifiedName: `${filePath}::NativeProps.${propName}`,
filePath,
language: filePath.endsWith('.tsx') ? 'tsx' : 'typescript',
startLine: propLine,
endLine: propLine,
startColumn: 0,
endColumn: propName.length,
docstring: `Fabric NativeProps prop '${propName}'`,
isExported: true,
updatedAt: now,
});
}
}
return nodes;
}
export const fabricViewResolver: FrameworkResolver = {
name: 'fabric-view',
languages: ['typescript', 'tsx', 'objc', 'java', 'kotlin'],
detect(context) {
// Root package.json is the common case. The indexer only tracks
// SOURCE files in getAllFiles(), so package.jsons in subpackages
// aren't enumerable that way — we have to probe them explicitly via
// listDirectories() for monorepos.
const checkPkg = (relativePath: string) => {
const pkg = context.readFile(relativePath);
return pkg ? /["']react-native["']\s*:/.test(pkg) : false;
};
if (checkPkg('package.json')) return true;
// Monorepo escape hatch — react-native-skia and similar workspace
// repos have the RN dep only in `packages/<sub>/package.json`. Walk
// the common workspace roots one level deep.
const list = context.listDirectories;
if (!list) return false;
for (const root of ['packages', 'apps', 'modules', 'libraries']) {
for (const sub of list(root) ?? []) {
if (checkPkg(`${root}/${sub}/package.json`)) return true;
}
}
return false;
},
extract(filePath, source): FrameworkExtractionResult {
// Pick the right extractor by file language. The framework registry
// already filters by `languages` so we only see relevant files.
let nodes: Node[] = [];
if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
nodes = extractFabricNodes(filePath, source);
} else if (filePath.endsWith('.m') || filePath.endsWith('.mm')) {
nodes = extractLegacyViewManagerNodes(filePath, source);
} else if (filePath.endsWith('.java') || filePath.endsWith('.kt')) {
nodes = extractJvmViewManagerNodes(filePath, source);
}
return { nodes, references: [] };
},
resolve() {
// The companion synthesizer (`fabricNativeImplEdges`) handles
// cross-language edges; standard name resolution handles
// <MyComponent> → component-node via the JSX synthesizer.
return null;
},
};
+16
View File
@@ -21,6 +21,10 @@ import { goResolver } from './go';
import { rustResolver } from './rust';
import { aspnetResolver } from './csharp';
import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
import { swiftObjcBridgeResolver } from './swift-objc';
import { reactNativeBridgeResolver } from './react-native';
import { expoModulesResolver } from './expo-modules';
import { fabricViewResolver } from './fabric';
/**
* All registered framework resolvers
@@ -54,6 +58,14 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
swiftUIResolver,
uikitResolver,
vaporResolver,
// Swift ↔ Objective-C cross-language bridging (mixed iOS apps)
swiftObjcBridgeResolver,
// React Native JS ↔ native bridge (legacy + TurboModules)
reactNativeBridgeResolver,
// Expo Modules — Function/AsyncFunction/Property DSL on Swift/Kotlin
expoModulesResolver,
// React Native Fabric / Codegen view components — TS spec → component nodes
fabricViewResolver,
];
/**
@@ -124,3 +136,7 @@ export { goResolver } from './go';
export { rustResolver } from './rust';
export { aspnetResolver } from './csharp';
export { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
export { swiftObjcBridgeResolver } from './swift-objc';
export { reactNativeBridgeResolver } from './react-native';
export { expoModulesResolver } from './expo-modules';
export { fabricViewResolver } from './fabric';
+434
View File
@@ -0,0 +1,434 @@
/**
* React Native cross-language bridge resolver.
*
* Closes the JS ↔ native flow gap in React Native projects. Covers:
*
* **Legacy bridge** (older / still-prevalent in mid-tier RN libs):
* - ObjC: `RCT_EXPORT_MODULE([opt_name])` declares a module; the module
* name defaults to the class name minus an `RCT` prefix when no
* argument is given. `RCT_EXPORT_METHOD(selector:(args))` declares a
* JS-callable method whose JS name is the selector's first keyword.
* `RCT_REMAP_METHOD(jsName, nativeSelector:(args))` overrides the JS
* name explicitly.
* - Java/Kotlin: `@ReactMethod` annotated methods on a
* `ReactContextBaseJavaModule` subclass; the module name comes from
* `getName()` returning a literal string.
*
* **TurboModules** (modern, used by react-native-svg, screens, FBSDK
* Next-gen libraries):
* - TS spec interface declared in a `Native<X>.ts` file exporting
* `TurboModuleRegistry.getEnforcing<Spec>('<ModuleName>')` (or
* `.get<Spec>('<ModuleName>')`). The Spec interface methods are the
* JS-callable surface; the matching native implementation is a class
* whose method names match (selector first-keyword on ObjC,
* identifier on Kotlin/Java).
*
* The two mechanisms share an end shape: a map from `(moduleName,
* jsMethodName)` to a native method node, plus a smaller map from
* `jsMethodName` alone for cases where the JS callsite doesn't carry
* the module qualifier (the most common JS pattern is
* `import Geo from './NativeGeolocation'; Geo.getPosition()` — the
* receiver is the default export, not literally `NativeModules.<Mod>`,
* so name-by-method-only is what actually resolves in practice).
*
* **Not covered** (deferred to a follow-up phase, per design doc §6):
* - Fabric view components (`RCT_EXPORT_VIEW_PROPERTY` / Codegen view
* specs) — these connect JSX props to native renderers, a different
* flow shape that composes with the existing JSX synthesizer.
* - Native → JS events (`RCTEventEmitter` / `NativeEventEmitter`) —
* belongs in the callback synthesizer's cross-language channel.
*/
import type { Node } from '../../types';
import {
FrameworkResolver,
ResolutionContext,
} from '../types';
/**
* One native RN method known to the resolver. Indexed by JS-visible name.
*/
interface NativeMethod {
/** Module name as seen from JS (`Geolocation`, `RNSVGRenderableModule`, …). */
moduleName: string;
/** JS-visible method name. */
jsName: string;
/** Native implementation node (ObjC method / Java method / Kotlin function). */
node: Node;
}
/** Per-context lazy map cache. */
const nativeMethodMaps: WeakMap<
ResolutionContext,
{ byJsName: Map<string, NativeMethod[]> }
> = new WeakMap();
// ─── Native-side extraction ─────────────────────────────────────────────────
/**
* Default ObjC module name when `RCT_EXPORT_MODULE()` has no argument:
* strip a leading `RCT` prefix from the class name (Apple's convention)
* and treat the rest as the JS-visible module name. `RCTGeolocation` →
* `Geolocation`. Class names without an `RCT` prefix are returned
* unchanged.
*/
function defaultObjcModuleName(className: string): string {
return className.startsWith('RCT') && className.length > 3
? className.slice(3)
: className;
}
/**
* Parse an ObjC `.m`/`.mm` file's source for `RCT_EXPORT_MODULE` and
* `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` declarations, returning the
* inferred (moduleName, jsMethodName) pairs.
*
* The macro forms (a single `RCT_EXPORT_MODULE` per file conventionally
* matched to a single `@implementation`):
* - `RCT_EXPORT_MODULE()` — module name = class name with `RCT` prefix
* stripped
* - `RCT_EXPORT_MODULE(jsName)` — explicit name
* - `RCT_EXPORT_METHOD(selector:(arg1)label1:(arg2)label2)` — JS name =
* `selector` (the first keyword)
* - `RCT_REMAP_METHOD(jsName, selector:(arg1)label1:(arg2)label2)` —
* JS name = literal `jsName`
*
* Regex-based scan is sufficient — these macros are highly stylized and
* appear at top level. Pulling them out of the full AST would require a
* macro-aware ObjC parse the tree-sitter grammar doesn't provide.
*/
function parseObjcRNExports(
source: string,
className: string | null
): Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string }> {
const results: Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string }> = [];
// RCT_EXPORT_MODULE — one per file by convention. Capture the optional arg.
const moduleMatch = source.match(/RCT_EXPORT_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)?\s*\)/);
// Need a module name to attribute methods. Prefer the explicit macro arg,
// then the class name, then bail (no module = nothing useful to register).
const moduleName =
moduleMatch?.[1] ??
(className ? defaultObjcModuleName(className) : null);
if (!moduleName) return results;
// RCT_EXPORT_METHOD(selectorFirstKw:(args)…)
// The first keyword (everything up to the first `:` or open paren) is the
// JS-visible name. We don't try to parse full multi-keyword selectors —
// RN's JS view of the method uses only the first keyword.
const exportRegex = /RCT_EXPORT_METHOD\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/g;
let m: RegExpExecArray | null;
while ((m = exportRegex.exec(source)) !== null) {
const kw = m[1];
if (kw) results.push({ moduleName, jsName: kw, nativeSelectorFirstKw: kw });
}
// RCT_REMAP_METHOD(jsName, nativeSelectorFirstKw:(args)…)
const remapRegex =
/RCT_REMAP_METHOD\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/g;
while ((m = remapRegex.exec(source)) !== null) {
const jsName = m[1];
const nativeKw = m[2];
if (jsName && nativeKw) {
results.push({ moduleName, jsName, nativeSelectorFirstKw: nativeKw });
}
}
return results;
}
/**
* Find the `@implementation` class name in an ObjC file — used as the
* fallback module name when `RCT_EXPORT_MODULE()` has no argument.
* (Categories of the form `@implementation Foo (Bar)` are correctly
* captured here as `Foo`, but a category file probably isn't where a
* fresh `RCT_EXPORT_MODULE` lives anyway.)
*/
function findObjcClassName(source: string): string | null {
const m = source.match(/@implementation\s+([A-Za-z_][A-Za-z0-9_]*)/);
return m?.[1] ?? null;
}
/**
* Parse a Java/Kotlin source file for `@ReactMethod` annotated methods
* and the surrounding class's `getName()` return value (the JS-visible
* module name).
*
* Java: `@ReactMethod public void getCurrentPosition(Callback cb) { … }`
* Kotlin: `@ReactMethod fun getCurrentPosition(cb: Callback) { … }`
*
* Class name comes from `class XxxModule extends ReactContextBaseJavaModule`
* (Java) or `class XxxModule : ReactContextBaseJavaModule(...)` (Kotlin).
* The JS-visible module name comes from `getName()` returning a literal
* string — fall back to the class name with a `Module` suffix stripped
* when the literal isn't present.
*/
function parseJvmRNExports(
source: string
): Array<{ moduleName: string; jsName: string }> {
const results: Array<{ moduleName: string; jsName: string }> = [];
// getName() literal — Java + Kotlin both look something like:
// public String getName() { return "Geolocation"; }
// fun getName(): String = "Geolocation"
// fun getName() = "Geolocation"
const getName = source.match(
/\bgetName\s*\([^)]*\)\s*(?::\s*String)?\s*(?:=\s*|\{[^}]*return\s*)"([^"]+)"/
);
// Class name fallback.
const classMatch =
source.match(/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\b[^{]*ReactContextBaseJavaModule/) ??
source.match(/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\b[^{]*ReactPackage/);
const moduleName =
getName?.[1] ?? (classMatch?.[1] ? classMatch[1].replace(/Module$/, '') : null);
if (!moduleName) return results;
// @ReactMethod annotations — followed (after optional modifiers / args /
// newlines) by either `void <name>(` (Java) or `fun <name>(` (Kotlin).
const methodRegex =
/@ReactMethod\b[^{]*?(?:\bfun\s+|\bvoid\s+|\bpublic\s+\w[\w<>\[\]]*\s+)([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
let m: RegExpExecArray | null;
while ((m = methodRegex.exec(source)) !== null) {
const jsName = m[1];
if (jsName) results.push({ moduleName, jsName });
}
return results;
}
/**
* Parse a TS file for a TurboModule spec declaration. The spec file is
* the JS↔native source-of-truth in the new architecture — its interface
* lists every JS-visible method, and a `TurboModuleRegistry.get*<Spec>(...)`
* default export pins the module name.
*
* Returns `null` when the file isn't a TurboModule spec.
*/
function parseTurboModuleSpec(
source: string
): { moduleName: string; methods: string[] } | null {
// `TurboModuleRegistry.getEnforcing<Spec>('ModuleName')` or
// `TurboModuleRegistry.get<Spec>('ModuleName')`. The literal must be a
// single-or-double-quoted string.
const regMatch = source.match(
/TurboModuleRegistry\.(?:getEnforcing|get)\s*<[^>]*>\s*\(\s*['"]([^'"]+)['"]\s*\)/
);
if (!regMatch || !regMatch[1]) return null;
const moduleName = regMatch[1];
// Find `export interface Spec extends TurboModule { … }` and pull each
// method declaration's name. We don't need types — just names.
const ifaceMatch = source.match(
/export\s+interface\s+Spec\b[^{]*\{([\s\S]*?)\n\}/
);
if (!ifaceMatch || !ifaceMatch[1]) return null;
const body = ifaceMatch[1];
const methods: string[] = [];
// Method shape: `name(args): ReturnType;` or `name(): void;`. Skip
// properties (no parens before colon).
const methodRegex = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\(/gm;
let m: RegExpExecArray | null;
while ((m = methodRegex.exec(body)) !== null) {
const name = m[1];
if (name) methods.push(name);
}
return { moduleName, methods };
}
// ─── Map building ───────────────────────────────────────────────────────────
/**
* RCTEventEmitter built-ins that every emitter subclass inherits. JS code
* doesn't directly call these — they're internal plumbing for the
* `NativeEventEmitter` abstraction. If we leave them in the bridge map,
* every JS `addListener` / `remove` call (Firestore subscribers, RxJS
* pipelines, plain Array.remove, etc.) gets mis-bridged to whichever
* emitter happens to define them. Skip during map building.
*/
const RN_EMITTER_BUILTINS = new Set([
'addListener',
'removeListeners',
'remove',
'invalidate',
'startObserving',
'stopObserving',
]);
function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, NativeMethod[]> } {
const cached = nativeMethodMaps.get(context);
if (cached) return cached;
const byJsName = new Map<string, NativeMethod[]>();
const allFiles = context.getAllFiles();
// Pre-index native methods by name for fast lookup when matching to
// their bridge exports.
const objcMethodsByFirstKw = new Map<string, Node[]>();
const jvmMethodsByName = new Map<string, Node[]>();
for (const node of context.getNodesByKind('method')) {
if (node.language === 'objc') {
const firstKw = node.name.includes(':') ? node.name.split(':')[0] : node.name;
if (firstKw) {
const arr = objcMethodsByFirstKw.get(firstKw);
if (arr) arr.push(node);
else objcMethodsByFirstKw.set(firstKw, [node]);
}
} else if (node.language === 'java' || node.language === 'kotlin') {
const arr = jvmMethodsByName.get(node.name);
if (arr) arr.push(node);
else jvmMethodsByName.set(node.name, [node]);
}
}
for (const file of allFiles) {
// Legacy bridge — ObjC side.
if (file.endsWith('.m') || file.endsWith('.mm')) {
const source = context.readFile(file);
if (!source) continue;
const className = findObjcClassName(source);
const exports = parseObjcRNExports(source, className);
for (const exp of exports) {
if (RN_EMITTER_BUILTINS.has(exp.jsName)) continue;
// Resolve to the native node by selector first-keyword. Multiple
// ObjC methods may share a first keyword across modules; filter by
// file path to attribute the export to this module's
// implementation file.
const candidates = objcMethodsByFirstKw.get(exp.nativeSelectorFirstKw) ?? [];
const node = candidates.find((c) => c.filePath === file) ?? candidates[0];
if (!node) continue;
const entry: NativeMethod = { moduleName: exp.moduleName, jsName: exp.jsName, node };
const arr = byJsName.get(exp.jsName);
if (arr) arr.push(entry);
else byJsName.set(exp.jsName, [entry]);
}
}
// Legacy bridge — Java/Kotlin side.
if (file.endsWith('.java') || file.endsWith('.kt')) {
const source = context.readFile(file);
if (!source) continue;
const exports = parseJvmRNExports(source);
for (const exp of exports) {
if (RN_EMITTER_BUILTINS.has(exp.jsName)) continue;
const candidates = jvmMethodsByName.get(exp.jsName) ?? [];
const node = candidates.find((c) => c.filePath === file) ?? candidates[0];
if (!node) continue;
const entry: NativeMethod = { moduleName: exp.moduleName, jsName: exp.jsName, node };
const arr = byJsName.get(exp.jsName);
if (arr) arr.push(entry);
else byJsName.set(exp.jsName, [entry]);
}
}
// TurboModule spec — TS side.
if (file.endsWith('.ts') || file.endsWith('.tsx')) {
const source = context.readFile(file);
if (!source) continue;
const spec = parseTurboModuleSpec(source);
if (!spec) continue;
// For each spec method, find a matching native implementation by
// name. The spec's module name doesn't determine the native file
// path (Codegen wires it via name convention), so we match across
// all native methods of the right name.
for (const methodName of spec.methods) {
if (RN_EMITTER_BUILTINS.has(methodName)) continue;
// ObjC first-keyword match, then JVM bare-name match. Don't
// require module-name match for ObjC because the native side may
// have stripped a prefix.
const objcCands = objcMethodsByFirstKw.get(methodName) ?? [];
const jvmCands = jvmMethodsByName.get(methodName) ?? [];
for (const node of [...objcCands, ...jvmCands]) {
const entry: NativeMethod = { moduleName: spec.moduleName, jsName: methodName, node };
const arr = byJsName.get(methodName);
if (arr) arr.push(entry);
else byJsName.set(methodName, [entry]);
}
}
}
}
const result = { byJsName };
nativeMethodMaps.set(context, result);
return result;
}
// ─── Resolver ───────────────────────────────────────────────────────────────
export const reactNativeBridgeResolver: FrameworkResolver = {
name: 'react-native-bridge',
languages: ['javascript', 'typescript', 'tsx', 'jsx'],
/**
* Detect: package.json depends on `react-native`, OR any source file
* uses the `RCT_EXPORT_MODULE` / `RCT_EXPORT_METHOD` /
* `TurboModuleRegistry` markers. Either signal is enough — different
* libraries split the JS package from the native code (`react-native-svg`'s
* apple/ + android/ directories vs its src/), so we don't require both.
*/
detect(context) {
const pkg = context.readFile('package.json');
if (pkg && /["']react-native["']\s*:/.test(pkg)) return true;
// Fallback: scan a small number of files for the macro markers — only
// looking at the first ones returned by getAllFiles to keep detect()
// fast on huge repos.
const files = context.getAllFiles();
for (let i = 0; i < Math.min(files.length, 200); i++) {
const f = files[i];
if (!f) continue;
if (f.endsWith('.mm') || f.endsWith('.m')) {
const src = context.readFile(f);
if (src && /RCT_EXPORT_MODULE\b/.test(src)) return true;
}
if (f.endsWith('.ts') || f.endsWith('.tsx')) {
const src = context.readFile(f);
if (src && /TurboModuleRegistry\.(?:get|getEnforcing)\s*</.test(src)) return true;
}
}
return false;
},
claimsReference(_name) {
// JS-visible method names are ordinary identifiers and are typically
// already in `knownNames` (every TurboModule spec method, every
// RCT_EXPORT_METHOD, has a node somewhere). So we don't need to
// claim through the pre-filter — the ref reaches us via the normal
// hasAnyPossibleMatch path.
return false;
},
resolve(ref, context) {
// We only redirect JS callers — native callers don't need this resolver.
if (
ref.language !== 'javascript' &&
ref.language !== 'typescript' &&
ref.language !== 'tsx' &&
ref.language !== 'jsx'
) {
return null;
}
// JS callsites of `obj.method()` reach the resolver as either
// `obj.method` (qualified) or `method` (bare). Strip a single dot
// prefix to get the JS-visible method name.
const name = ref.referenceName.includes('.')
? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
: ref.referenceName;
const maps = buildRNMaps(context);
const entries = maps.byJsName.get(name);
if (!entries || entries.length === 0) return null;
// Prefer the iOS (ObjC) target over Android when both exist — iOS is
// the conventional first-class platform for RN library docs and most
// graph queries. We still record only one edge; a JVM-only resolution
// is fine when no ObjC target exists.
const objc = entries.find((e) => e.node.language === 'objc');
const target = objc ?? entries[0];
if (!target) return null;
return {
original: ref,
targetNodeId: target.node.id,
confidence: 0.6,
resolvedBy: 'framework',
};
},
};
+299
View File
@@ -0,0 +1,299 @@
/**
* Swift ↔ Objective-C bridge resolver.
*
* Closes the cross-language flow gap in mixed iOS codebases. The pure
* bridging name math lives in `../swift-objc-bridge.ts`; this file wires
* it into the resolution pipeline.
*
* **Two directions to close:**
*
* 1. **Swift call → ObjC method** — A Swift caller writes
* `imageDownloader.download(url:completion:)`. Tree-sitter-swift parses
* this as a call_expression whose callee identifier is `download`
* (parameter labels live in the argument list, not the callee). The
* name-matcher tries to find any node named `download` and fails (no
* Swift method by that name in this project; the ObjC implementation is
* `-downloadURL:completion:`). We catch it here: from the bare Swift
* name `download`, look up ObjC methods whose bridged Swift base name
* would be `download` (using `swiftBaseNamesForObjcSelector`'s reverse
* map, precomputed once per session).
*
* 2. **ObjC call → Swift method** — An ObjC caller writes
* `[swiftThing fooWithBar:42]`. Tree-sitter-objc parses this as a
* message_expression with selector `fooWithBar:` (after the multi-
* keyword fix in this branch). The name-matcher tries to find a node
* named `fooWithBar:` — no Swift node has colons in its name, so it
* fails. We catch it: from the ObjC selector, derive candidate Swift
* base names (`['fooWithBar', 'foo']`), and look up Swift methods
* named those.
*
* **Provenance:** every edge produced here is recorded as a framework-
* resolved reference (`resolvedBy: 'framework'`) with `confidence: 0.7`
* (matches the django ORM dynamic-dispatch precedent — not exact, but
* deterministic from the bridging rule).
*/
import { FrameworkResolver, ResolutionContext, ResolvedRef, UnresolvedRef } from '../types';
import type { Node } from '../../types';
import {
swiftBaseNamesForObjcSelector,
isObjcExposed,
} from '../swift-objc-bridge';
/**
* Memoized "Swift base name → ObjC method nodes" map.
*
* Built lazily on first `resolve()` per resolver instance — the resolver is
* recreated when the index is rebuilt, so this naturally invalidates with
* the graph. Keyed by ResolutionContext identity so multiple projects sharing
* a process (the daemon) don't bleed maps between them.
*/
const objcByCandidateSwiftBase: WeakMap<
ResolutionContext,
Map<string, Node[]>
> = new WeakMap();
/**
* Build the reverse-bridge map: for every ObjC method node in the graph,
* compute the Swift base names that would auto-bridge to its selector and
* record the node under each.
*
* Runs once per resolver lifetime; the cost scales linearly with the count
* of ObjC method nodes. On Wikipedia-iOS (~2500 files, ~25k ObjC methods)
* this is a few hundred ms — much cheaper than re-parsing source on each
* unresolved ref.
*/
/**
* Names that are too generic to bridge with any precision. These are common
* Cocoa / NSObject conventions that almost every ObjC class implements; if a
* Swift caller writes `init()` or `description`, mapping it to an arbitrary
* project-local ObjC method of the same name produces noise, not signal.
*
* Critically, refs of these names virtually always resolve via the regular
* name-matcher (every project has many `init` nodes) — skipping them here
* just keeps the bridge from competing with name-match on already-handled
* refs.
*/
const GENERIC_NAMES = new Set([
'init',
'description',
'debugDescription',
'hash',
'isEqual',
'isEqualTo',
'copy',
'mutableCopy',
'class',
'self',
'count',
'length',
'value',
'name',
'data',
'string',
'object',
'add',
'remove',
'update',
'load',
'save',
'reload',
'cancel',
'start',
'stop',
'pause',
'resume',
'close',
'open',
'show',
'hide',
'toString',
'dealloc',
'release',
'retain',
'autorelease',
]);
function buildObjcMap(context: ResolutionContext): Map<string, Node[]> {
const cached = objcByCandidateSwiftBase.get(context);
if (cached) return cached;
const map = new Map<string, Node[]>();
const objcMethods = context
.getNodesByKind('method')
.filter((n) => n.language === 'objc');
for (const node of objcMethods) {
const candidates = swiftBaseNamesForObjcSelector(node.name);
for (const c of candidates) {
// Skip the trivial case where the Swift base name equals the ObjC
// method name verbatim (no colons) — the regular name-matcher
// already handles that and our map would just duplicate the work.
if (c === node.name && !node.name.includes(':')) continue;
// Skip generic Cocoa names (init, description, etc.) — they would
// false-positive against any project-local ObjC method of the same
// name. The regular name-matcher handles them.
if (GENERIC_NAMES.has(c)) continue;
const arr = map.get(c);
if (arr) arr.push(node);
else map.set(c, [node]);
}
}
objcByCandidateSwiftBase.set(context, map);
return map;
}
/**
* Window of source text around a Swift declaration used by `isObjcExposed`
* to spot `@objc` / `@nonobjc` annotations. Read line above + the
* declaration line — Swift attributes typically sit on the preceding line
* (`@objc` on a line of its own) or inline.
*/
const SOURCE_PROBE_LINES = 3;
/**
* Read a small window of source ending at `node.startLine`, used to
* inspect Swift attribute annotations attached to a declaration. Returns
* an empty string if the source can't be read.
*/
function declarationSourceWindow(node: Node, context: ResolutionContext): string {
const content = context.readFile(node.filePath);
if (!content) return '';
const lines = content.split(/\r?\n/);
const startIdx = Math.max(0, node.startLine - 1 - SOURCE_PROBE_LINES);
const endIdx = Math.min(lines.length, node.startLine);
return lines.slice(startIdx, endIdx).join('\n');
}
/**
* Try to resolve a Swift caller's bare reference to an ObjC implementation.
*
* Strategy: look up the ObjC reverse-bridge map for nodes whose Swift base
* name would match. Return the first match (matches the existing
* single-target resolution contract).
*/
function resolveSwiftCallToObjc(
ref: UnresolvedRef,
context: ResolutionContext
): ResolvedRef | null {
// Swift call sites of `obj.foo(bar:)` reach the resolver as either bare
// name `foo` (tree-sitter-swift) or qualified `obj.foo` — strip prefix.
const rawName = ref.referenceName.includes('.')
? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
: ref.referenceName;
const map = buildObjcMap(context);
const candidates = map.get(rawName);
if (!candidates || candidates.length === 0) return null;
// Prefer ObjC methods whose corresponding Swift declaration isn't itself
// present (so we don't wrongly redirect a Swift call to ObjC when a Swift
// method of the same name is the real target — that's the in-language case
// and should already be resolved by the name-matcher). Since this resolver
// runs AFTER exact-match, any matching Swift node would already have won;
// so a candidate reaching us is a legitimate cross-language hit.
const target = candidates[0];
if (!target) return null;
return {
original: ref,
targetNodeId: target.id,
confidence: 0.6,
resolvedBy: 'framework',
};
}
/**
* Try to resolve an ObjC caller's selector reference to a Swift `@objc`
* implementation.
*
* Strategy: derive candidate Swift base names from the selector via
* `swiftBaseNamesForObjcSelector`. For each, look up Swift methods named
* that and verify with a source-window check that the declaration is
* `@objc`-exposed (filters out false matches where a Swift function
* happens to share the name but isn't bridged).
*/
function resolveObjcCallToSwift(
ref: UnresolvedRef,
context: ResolutionContext
): ResolvedRef | null {
// ObjC call sites get receiver-prefixed when the receiver isn't self/super
// (see tree-sitter.ts message_expression handling): `[obj foo:bar:]`
// becomes `obj.foo:bar:`. Strip the receiver prefix to recover the raw
// selector for the bridge math.
const rawSelector = ref.referenceName.includes('.')
? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
: ref.referenceName;
// Bridge math only applies to selector-shape names (contain `:`).
if (!rawSelector.includes(':')) return null;
const candidates = swiftBaseNamesForObjcSelector(rawSelector);
for (const candidate of candidates) {
const matches = context
.getNodesByName(candidate)
.filter((n) => n.language === 'swift' && (n.kind === 'method' || n.kind === 'function'));
for (const match of matches) {
const window = declarationSourceWindow(match, context);
if (isObjcExposed(window)) {
return {
original: ref,
targetNodeId: match.id,
confidence: 0.6,
resolvedBy: 'framework',
};
}
}
}
return null;
}
export const swiftObjcBridgeResolver: FrameworkResolver = {
name: 'swift-objc-bridge',
// Applies to both languages — bridging crosses the boundary.
languages: ['swift', 'objc'],
/**
* Detect: this resolver is relevant when the project has both Swift and
* Objective-C source. Either-side-only projects don't need bridging
* (and the empty reverse-map would be a no-op anyway).
*/
detect(context) {
const files = context.getAllFiles();
let hasSwift = false;
let hasObjc = false;
for (const f of files) {
if (f.endsWith('.swift')) hasSwift = true;
else if (f.endsWith('.m') || f.endsWith('.mm')) hasObjc = true;
if (hasSwift && hasObjc) return true;
}
return false;
},
/**
* Let selector-shape references (anything containing a `:`) through the
* resolver's name-exists pre-filter — no Swift node has a colon in its
* name, so without this opt-in those refs would be dropped before
* `resolve()` sees them. Also opt-in `setX:`-style names that aren't
* otherwise declared symbols, in case the Swift side is a property.
*/
claimsReference(name) {
if (name.includes(':')) return true;
// Bare names without colons are handled by the regular name-exists
// pre-filter — no need to opt them in here.
return false;
},
/**
* Route based on which language the caller is in. The two directions are
* symmetric in shape but very different in implementation (forward
* direction uses the precomputed reverse-bridge map; reverse direction
* uses the deterministic name-derivation).
*/
resolve(ref, context) {
if (ref.language === 'swift') {
return resolveSwiftCallToObjc(ref, context);
}
if (ref.language === 'objc') {
return resolveObjcCallToSwift(ref, context);
}
return null;
},
};