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
+272 -3
View File
@@ -520,10 +520,266 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
return edges;
}
/**
* React Native cross-language event channel (Phase 3 of the mixed-iOS/RN
* bridging effort). Same shape as `eventEmitterEdges` but cross-language:
*
* Native (ObjC, on RCTEventEmitter subclass):
* [self sendEventWithName:@"locationUpdate" body:@{...}];
*
* Native (Java/Kotlin, via the JS module dispatcher):
* emitter.emit("locationUpdate", body);
* reactContext.getJSModule(RCTDeviceEventEmitter.class).emit("locationUpdate", body);
*
* JS (subscriber):
* new NativeEventEmitter(NativeModules.Geo).addListener("locationUpdate", handler);
* DeviceEventEmitter.addListener("locationUpdate", handler);
*
* Synthesize: native dispatch site → JS handler, keyed by the literal
* event name. Only matches NAMED handlers (the existing `ON_RE` named-
* capture form). Inline arrow handlers like `addListener('x', d => …)`
* aren't named at extraction time and would need link-through-body
* support; matches the deliberate scope of the in-language synthesizer.
*
* Provenance `'heuristic'`, synthesizedBy `'rn-event-channel'`.
*/
// ObjC's `[self sendEventWithName:@"X" body:...]` shape (bracket syntax,
// `@` string literals).
const RN_OBJC_SEND_RE = /\bsendEventWithName\s*:\s*@"([^"]+)"/g;
// Swift's `sendEvent(withName: "X", body: ...)` shape — same RCTEventEmitter
// method, different call syntax. Both Objective-C and Swift subclass
// RCTEventEmitter so this catches the Swift-side equivalent emission sites
// (e.g. RNFusedLocation.swift's `sendEvent(withName: "geolocationDidChange",
// body: locationData)`).
const RN_SWIFT_SEND_RE = /\bsendEvent\s*\(\s*withName\s*:\s*"([^"]+)"/g;
// JVM-side emitter calls: `emitter.emit("X", body)`. Matches both Java
// and Kotlin syntax because the call form is identical. Restricted to
// JVM source files in the consumer so we don't re-process JS emits
// (which `eventEmitterEdges` already handles).
const RN_JVM_EMIT_RE = /\.emit\s*\(\s*"([^"]+)"\s*,/g;
function rnEventEdges(ctx: ResolutionContext): Edge[] {
// Native dispatchers (source = the native method whose body sends the
// event) and JS handlers (target = the function/method registered as
// the listener) keyed by event name.
const nativeDispatchersByEvent = new Map<string, Set<string>>();
const jsHandlersByEvent = new Map<string, Map<string, string>>();
for (const file of ctx.getAllFiles()) {
const content = ctx.readFile(file);
if (!content) continue;
const nodesInFile = ctx.getNodesInFile(file);
const lineOf = (idx: number) => content.slice(0, idx).split('\n').length;
const addDispatcher = (event: string, line: number) => {
const disp = enclosingFn(nodesInFile, line);
if (!disp) return;
const set = nativeDispatchersByEvent.get(event) ?? new Set<string>();
set.add(disp.id);
nativeDispatchersByEvent.set(event, set);
};
// ObjC side: `sendEventWithName:@"X"` only fires inside `.m`/`.mm`
// files (RCTEventEmitter subclasses).
if (file.endsWith('.m') || file.endsWith('.mm')) {
RN_OBJC_SEND_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = RN_OBJC_SEND_RE.exec(content))) {
if (m[1]) addDispatcher(m[1], lineOf(m.index));
}
}
// Swift side: same RCTEventEmitter method, parens/named-args syntax.
if (file.endsWith('.swift')) {
RN_SWIFT_SEND_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = RN_SWIFT_SEND_RE.exec(content))) {
if (m[1]) addDispatcher(m[1], lineOf(m.index));
}
}
// JVM side: `.emit("X", …)` in Java/Kotlin. (We pattern-match
// anywhere in the file; the JS in-language path uses a separate
// emitter object pattern and is already handled by eventEmitterEdges.)
if (file.endsWith('.java') || file.endsWith('.kt')) {
RN_JVM_EMIT_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = RN_JVM_EMIT_RE.exec(content))) {
if (m[1]) addDispatcher(m[1], lineOf(m.index));
}
}
// JS subscribers (.addListener("X", handler)). Restrict to JS-family
// files so a native file's `addListener:` (the ObjC method) doesn't
// get mistaken for a JS subscription — they're entirely different
// things despite sharing a name.
if (
file.endsWith('.js') ||
file.endsWith('.jsx') ||
file.endsWith('.ts') ||
file.endsWith('.tsx') ||
file.endsWith('.mjs') ||
file.endsWith('.cjs')
) {
// Match BOTH the named-handler form (`.addListener('x', fn)`) and
// an unnamed-handler form (`.addListener('x', listener)` where
// `listener` is a parameter — common in RN wrapper APIs like
// RNFirebase's `messaging().onMessageReceived(listener)`). For the
// unnamed case we attribute the subscription to the ENCLOSING JS
// function (the abstraction layer), giving a reachability-correct
// hop even when the actual user-side handler lives one call up.
const ADDLISTENER_ANY = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*([A-Za-z_][\w.]*)/g;
ADDLISTENER_ANY.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = ADDLISTENER_ANY.exec(content))) {
const event = m[1];
const arg = m[2];
if (!event || !arg) continue;
const bareName = arg.includes('.') ? arg.slice(arg.lastIndexOf('.') + 1) : arg;
// Try a named-symbol match first (matches the in-language semantic).
const namedHandler = ctx
.getNodesByName(bareName)
.find((n) => n.kind === 'function' || n.kind === 'method');
let targetId: string | null = namedHandler?.id ?? null;
if (!targetId) {
// Fall back to the enclosing function — the subscribe-wrapper
// pattern means the event fires THROUGH this function on its
// way to user code. Reachability-correct attribution.
const enclosing = enclosingFn(nodesInFile, lineOf(m.index));
targetId = enclosing?.id ?? null;
}
if (!targetId) {
// Broader fallback for JS object-literal API shape
// (`const Foo = { watchX(...) { … addListener(...) … } }`):
// method shorthand inside an object literal isn't extracted
// as a method node, so enclosingFn returns null. Attribute to
// the smallest enclosing `constant` / `variable` node — that's
// the API surface a downstream caller would `import` and
// invoke. Reachability-correct.
const line = lineOf(m.index);
let smallest: typeof nodesInFile[number] | null = null;
for (const n of nodesInFile) {
if (n.kind !== 'constant' && n.kind !== 'variable') continue;
const end = n.endLine ?? n.startLine;
if (n.startLine <= line && end >= line) {
if (!smallest || n.startLine >= smallest.startLine) smallest = n;
}
}
targetId = smallest?.id ?? null;
}
if (!targetId) continue;
const map = jsHandlersByEvent.get(event) ?? new Map<string, string>();
map.set(targetId, `${file}:${lineOf(m.index)}`);
jsHandlersByEvent.set(event, map);
}
}
}
const edges: Edge[] = [];
const seen = new Set<string>();
for (const [event, dispatchers] of nativeDispatchersByEvent) {
const handlers = jsHandlersByEvent.get(event);
if (!handlers) continue;
// Same fan-out guard as the in-language channel: generic event names
// (e.g. 'change', 'error', 'data') with many handlers/dispatchers
// can't be matched precisely without receiver-type info.
if (dispatchers.size > EVENT_FANOUT_CAP || handlers.size > EVENT_FANOUT_CAP) continue;
for (const d of dispatchers) {
for (const [h, registeredAt] of handlers) {
if (d === h) continue;
const key = `${d}>${h}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: d,
target: h,
kind: 'calls',
provenance: 'heuristic',
metadata: { synthesizedBy: 'rn-event-channel', event, registeredAt },
});
}
}
}
return edges;
}
/**
* Phase 6 — React Native Fabric/Codegen view component bridge.
*
* The Fabric framework extractor (`frameworks/fabric.ts`) emits
* `component` nodes named after the JS-visible component (e.g.
* `RNSScreenStack`) from each `codegenNativeComponent<Props>('Name')`
* spec declaration. The native implementation lives in an ObjC++/.mm or
* Kotlin/Java class whose name follows one of RN's conventions:
*
* - Exact: `RNSScreenStack`
* - With suffix: `RNSScreenStackView`, `RNSScreenStackViewManager`,
* `RNSScreenStackComponentView`, `RNSScreenStackManager`
*
* This synthesizer walks every Fabric component node and looks for a
* native class matching one of those names; when found, emits a
* `calls` edge `component → native class` (provenance `'heuristic'`,
* `synthesizedBy:'fabric-native-impl'`) so trace from JSX usage of the
* component continues into native.
*
* The convention-based suffix lookup is precise: there's no name
* collision in RN view-manager codebases by design (Codegen output would
* conflict otherwise).
*/
const FABRIC_NATIVE_SUFFIXES = ['', 'View', 'ViewManager', 'ComponentView', 'Manager'];
function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
// The Fabric extractor IDs are prefixed `fabric-component:` so we can
// filter to just those without iterating all `component` nodes.
const components = ctx.getNodesByKind('component').filter((n) => n.id.startsWith('fabric-component:'));
if (components.length === 0) return edges;
// Pre-index native classes by name for O(1) lookup.
const nativeClassesByName = new Map<string, Node[]>();
for (const n of ctx.getNodesByKind('class')) {
if (n.language !== 'objc' && n.language !== 'kotlin' && n.language !== 'java' && n.language !== 'cpp') continue;
const arr = nativeClassesByName.get(n.name);
if (arr) arr.push(n);
else nativeClassesByName.set(n.name, [n]);
}
for (const component of components) {
for (const suffix of FABRIC_NATIVE_SUFFIXES) {
const candidate = component.name + suffix;
const matches = nativeClassesByName.get(candidate);
if (!matches || matches.length === 0) continue;
// Link the component node to every matching native class (iOS +
// Android each have one).
for (const native of matches) {
const key = `${component.id}>${native.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: component.id,
target: native.id,
kind: 'calls',
provenance: 'heuristic',
metadata: {
synthesizedBy: 'fabric-native-impl',
viaSuffix: suffix || '(exact)',
componentName: component.name,
},
});
}
}
}
return edges;
}
/**
* Synthesize dispatcher→callback edges (field observers + EventEmitters +
* React re-render + JSX children + Vue templates). Returns the count added.
* Never throws into indexing — callers wrap in try/catch.
* React re-render + JSX children + Vue templates + RN event channel +
* Fabric native-impl). Returns the count added. Never throws into
* indexing — callers wrap in try/catch.
*/
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
const fieldEdges = fieldChannelEdges(queries, ctx);
@@ -534,10 +790,23 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
const flutterEdges = flutterBuildEdges(queries, ctx);
const cppEdges = cppOverrideEdges(queries);
const ifaceEdges = interfaceOverrideEdges(queries);
const rnEventEdgesList = rnEventEdges(ctx);
const fabricNativeEdges = fabricNativeImplEdges(ctx);
const merged: Edge[] = [];
const seen = new Set<string>();
for (const e of [...fieldEdges, ...emitterEdges, ...renderEdges, ...jsxEdges, ...vueEdges, ...flutterEdges, ...cppEdges, ...ifaceEdges]) {
for (const e of [
...fieldEdges,
...emitterEdges,
...renderEdges,
...jsxEdges,
...vueEdges,
...flutterEdges,
...cppEdges,
...ifaceEdges,
...rnEventEdgesList,
...fabricNativeEdges,
]) {
const key = `${e.source}>${e.target}`;
if (seen.has(key)) continue;
seen.add(key);
+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;
},
};
+276
View File
@@ -0,0 +1,276 @@
/**
* 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);
}