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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user