mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-05 08:53:34 +08:00
Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
640 lines
27 KiB
TypeScript
640 lines
27 KiB
TypeScript
/**
|
|
* 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).
|
|
*
|
|
* **Swift modules via `RCT_EXTERN_MODULE`** — the shape an app's OWN native
|
|
* code usually takes: a Swift class exposed through a thin `.m` shim.
|
|
* - `@interface RCT_EXTERN_MODULE(ClassName, RCTSuperclass)` names the
|
|
* Swift class, which is also the JS module (`NativeModules.ClassName`);
|
|
* `RCT_EXTERN_REMAP_MODULE(jsName, ClassName, Super)` renames it.
|
|
* - `RCT_EXTERN_METHOD(selector:(args)…)` exposes the Swift method named
|
|
* by the selector's first keyword; `RCT_EXTERN_REMAP_METHOD(jsName,
|
|
* selector…)` under another JS name. The implementation is the Swift
|
|
* `@objc func` of that name on the class or one of its extensions — a
|
|
* real node from the Swift extractor, so no synthetic node is minted.
|
|
*
|
|
* **Receiver evidence.** `captureView.finalizeCaptureSession()` where
|
|
* `const captureView = NativeModules.CaptureView` names the module as surely
|
|
* as `NativeModules.CaptureView.finalizeCaptureSession()` does: both resolve
|
|
* at 0.95 to that module's method — ahead of the import resolver, which
|
|
* would otherwise land the call on the `captureView` constant and the flow
|
|
* would stop one hop short of native. A bare `.method()` with no module
|
|
* evidence keeps the by-name match at 0.6.
|
|
*
|
|
* **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[]>; aliases: Map<string, string> }
|
|
> = 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; line: number }> {
|
|
const results: Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string; line: number }> = [];
|
|
|
|
// 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;
|
|
|
|
const lineOf = (idx: number): number => {
|
|
let line = 1;
|
|
for (let i = 0; i < idx && i < source.length; i++) if (source.charCodeAt(i) === 10) line++;
|
|
return line;
|
|
};
|
|
|
|
// 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, line: lineOf(m.index) });
|
|
}
|
|
|
|
// 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, line: lineOf(m.index) });
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* `RCT_EXTERN_MODULE` / `RCT_EXTERN_METHOD` — the `.m` shim that exposes a
|
|
* Swift class. One entry per exposed method, each naming the Swift class the
|
|
* implementation lives on (the module name is the class name unless
|
|
* `RCT_EXTERN_REMAP_MODULE` says otherwise).
|
|
*/
|
|
export function parseObjcRNExterns(
|
|
source: string
|
|
): Array<{ moduleName: string; className: string; jsName: string; nativeSelectorFirstKw: string; line: number }> {
|
|
const results: Array<{ moduleName: string; className: string; jsName: string; nativeSelectorFirstKw: string; line: number }> = [];
|
|
const remap = source.match(
|
|
/RCT_EXTERN_REMAP_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/
|
|
);
|
|
const plain = source.match(/RCT_EXTERN_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/);
|
|
const moduleName = remap?.[1] ?? plain?.[1] ?? null;
|
|
const className = remap?.[2] ?? plain?.[1] ?? null;
|
|
if (!moduleName || !className) return results;
|
|
|
|
const lineOf = (idx: number): number => {
|
|
let line = 1;
|
|
for (let i = 0; i < idx && i < source.length; i++) if (source.charCodeAt(i) === 10) line++;
|
|
return line;
|
|
};
|
|
|
|
const methodRegex = /RCT_EXTERN_METHOD\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = methodRegex.exec(source)) !== null) {
|
|
const kw = m[1];
|
|
if (kw) results.push({ moduleName, className, jsName: kw, nativeSelectorFirstKw: kw, line: lineOf(m.index) });
|
|
}
|
|
const remapRegex =
|
|
/RCT_EXTERN_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, className, jsName, nativeSelectorFirstKw: nativeKw, line: lineOf(m.index) });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Local names bound to a native module on the JS side — the receiver evidence
|
|
* `resolve()` trusts:
|
|
*
|
|
* const captureView = NativeModules.CaptureView
|
|
* export const { CaptureEvents } = NativeModules
|
|
* const { Geo: geolocation } = NativeModules
|
|
*
|
|
* Collected across the project by name: an alias is nearly always an exported
|
|
* constant imported elsewhere under the same name. A name bound to two
|
|
* different modules is dropped as ambiguous rather than guessed.
|
|
*/
|
|
export function collectNativeModuleAliases(
|
|
source: string,
|
|
aliases: Map<string, string>,
|
|
ambiguous: Set<string>
|
|
): void {
|
|
const bind = (alias: string, moduleName: string): void => {
|
|
if (ambiguous.has(alias)) return;
|
|
const prior = aliases.get(alias);
|
|
if (prior !== undefined && prior !== moduleName) {
|
|
aliases.delete(alias);
|
|
ambiguous.add(alias);
|
|
return;
|
|
}
|
|
aliases.set(alias, moduleName);
|
|
};
|
|
const direct = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=\s*NativeModules\.([A-Z][\w$]*)/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = direct.exec(source)) !== null) bind(m[1]!, m[2]!);
|
|
const destructured = /\b(?:const|let|var)\s*\{([^}]+)\}\s*=\s*NativeModules\b/g;
|
|
while ((m = destructured.exec(source)) !== null) {
|
|
for (const part of m[1]!.split(',')) {
|
|
const entry = part.trim().match(/^([A-Za-z_$][\w$]*)(?:\s*:\s*([A-Za-z_$][\w$]*))?$/);
|
|
if (entry) bind(entry[2] ?? entry[1]!, entry[1]!);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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[]>; aliases: Map<string, string> } {
|
|
const cached = nativeMethodMaps.get(context);
|
|
if (cached) return cached;
|
|
|
|
const byJsName = new Map<string, NativeMethod[]>();
|
|
const aliases = new Map<string, string>();
|
|
const ambiguousAliases = new Set<string>();
|
|
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[]>();
|
|
const swiftMethodsByName = 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]);
|
|
} else if (node.language === 'swift') {
|
|
const arr = swiftMethodsByName.get(node.name);
|
|
if (arr) arr.push(node);
|
|
else swiftMethodsByName.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]);
|
|
}
|
|
// Swift-backed module: the shim names the class; the implementation is
|
|
// the Swift method of that name on the class or one of its extensions.
|
|
// Class-scoped, so a same-named method on another Swift type (a
|
|
// `syncSettings` on `CaptureSettings` beside the one on `CaptureView`)
|
|
// is never the answer.
|
|
if (/RCT_EXTERN_(?:REMAP_)?MODULE\b/.test(source)) {
|
|
for (const ext of parseObjcRNExterns(source)) {
|
|
if (RN_EMITTER_BUILTINS.has(ext.jsName)) continue;
|
|
const candidates = (swiftMethodsByName.get(ext.nativeSelectorFirstKw) ?? [])
|
|
.filter((c) => c.qualifiedName.split('::').includes(ext.className))
|
|
.sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine);
|
|
const node = candidates[0];
|
|
if (!node) continue;
|
|
const entry: NativeMethod = { moduleName: ext.moduleName, jsName: ext.jsName, node };
|
|
const arr = byJsName.get(ext.jsName);
|
|
if (arr) arr.push(entry);
|
|
else byJsName.set(ext.jsName, [entry]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// JS side: the local names bound to `NativeModules.<Module>`.
|
|
if (/\.(?:[cm]?[jt]sx?)$/.test(file)) {
|
|
const source = context.readFile(file);
|
|
if (source && source.includes('NativeModules')) {
|
|
collectNativeModuleAliases(source, aliases, ambiguousAliases);
|
|
}
|
|
}
|
|
|
|
// 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, aliases };
|
|
nativeMethodMaps.set(context, result);
|
|
return result;
|
|
}
|
|
|
|
// ─── Resolver ───────────────────────────────────────────────────────────────
|
|
|
|
export const reactNativeBridgeResolver: FrameworkResolver = {
|
|
name: 'react-native-bridge',
|
|
// objc/mm included so `extract()` sees the native files — `resolve()` still
|
|
// only redirects JS callers (it returns null for native languages).
|
|
languages: ['javascript', 'typescript', 'tsx', 'jsx', 'objc'],
|
|
|
|
/**
|
|
* Extract `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` declarations as method
|
|
* nodes. These macros parse as a macro-expression (an ERROR node), NOT a
|
|
* `method_definition`, so the ObjC extractor never made a node for them — the
|
|
* iOS half of a native module was invisible, so a JS call couldn't resolve to
|
|
* it and the cross-platform pairing had nothing to pair. The node is named by
|
|
* the JS-visible name (the selector's first keyword, or the explicit
|
|
* `RCT_REMAP_METHOD` JS name) so it matches the Android `@ReactMethod` method.
|
|
*/
|
|
extract(filePath, source) {
|
|
if (!filePath.endsWith('.m') && !filePath.endsWith('.mm')) return { nodes: [], references: [] };
|
|
if (!/RCT_EXPORT_MODULE\b/.test(source)) return { nodes: [], references: [] };
|
|
const exports = parseObjcRNExports(source, findObjcClassName(source));
|
|
const now = Date.now();
|
|
const nodes: Node[] = [];
|
|
const seen = new Set<string>();
|
|
for (const e of exports) {
|
|
if (seen.has(e.jsName)) continue;
|
|
seen.add(e.jsName);
|
|
nodes.push({
|
|
id: `rn-export:${filePath}:${e.moduleName}.${e.jsName}`,
|
|
kind: 'method',
|
|
name: e.jsName,
|
|
qualifiedName: `${filePath}::${e.moduleName}.${e.jsName}`,
|
|
filePath,
|
|
language: 'objc',
|
|
startLine: e.line,
|
|
endLine: e.line,
|
|
startColumn: 0,
|
|
endColumn: 0,
|
|
isExported: true,
|
|
docstring: `RCT_EXPORT_METHOD ${e.nativeSelectorFirstKw} (module ${e.moduleName})`,
|
|
signature: `RCT_EXPORT_METHOD(${e.nativeSelectorFirstKw}:…)`,
|
|
updatedAt: now,
|
|
});
|
|
}
|
|
return { nodes, references: [] };
|
|
},
|
|
|
|
/**
|
|
* Detect: package.json depends on `react-native`, OR any source file
|
|
* uses the `RCT_EXPORT_MODULE` / `RCT_EXTERN_MODULE` /
|
|
* `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|RCT_EXTERN_(?:REMAP_)?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 the receiver to
|
|
// get the JS-visible method name — and keep it, as evidence.
|
|
const raw = ref.referenceName;
|
|
const lastDot = raw.lastIndexOf('.');
|
|
const name = lastDot >= 0 ? raw.slice(lastDot + 1) : raw;
|
|
|
|
const maps = buildRNMaps(context);
|
|
const entries = maps.byJsName.get(name);
|
|
if (!entries || entries.length === 0) return null;
|
|
|
|
// iOS first — the conventional first-class platform for RN library docs
|
|
// and most graph queries; one edge is recorded either way.
|
|
const pick = (list: NativeMethod[]): NativeMethod | undefined =>
|
|
list.find((e) => e.node.language === 'objc') ??
|
|
list.find((e) => e.node.language === 'swift') ??
|
|
list[0];
|
|
|
|
// Receiver evidence: `NativeModules.Mod.method`, or an alias the project
|
|
// bound to `NativeModules.Mod`. The module is named, so the match is to
|
|
// THAT module's method at a confidence the import resolver cannot beat
|
|
// — and a module that has no such method is not ours to guess at.
|
|
if (lastDot >= 0) {
|
|
const receiver = raw.slice(0, lastDot);
|
|
const direct = receiver.match(/^NativeModules\.([A-Z][\w$]*)$/);
|
|
const moduleName = direct ? direct[1]! : maps.aliases.get(receiver) ?? null;
|
|
if (moduleName !== null) {
|
|
const exact = pick(entries.filter((e) => e.moduleName === moduleName));
|
|
if (!exact) return null;
|
|
return {
|
|
original: ref,
|
|
targetNodeId: exact.node.id,
|
|
confidence: 0.95,
|
|
resolvedBy: 'framework',
|
|
metadata: { bridge: 'react-native', module: moduleName },
|
|
};
|
|
}
|
|
}
|
|
|
|
const target = pick(entries);
|
|
if (!target) return null;
|
|
return {
|
|
original: ref,
|
|
targetNodeId: target.node.id,
|
|
confidence: 0.6,
|
|
resolvedBy: 'framework',
|
|
metadata: { bridge: 'react-native', module: target.moduleName },
|
|
};
|
|
},
|
|
};
|