diff --git a/CHANGELOG.md b/CHANGELOG.md index 037f61f83..c0c959e44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Calls into an Expo module now reach the module's native functions even when the JavaScript names the module something else, like expo-camera's `CameraManager` for `requireNativeModule('ExpoCamera')`. Both the iOS and the Android implementations are linked. expo-camera's calls used to link back to the same-named method making the call, so a method looked like it called itself. +- A JavaScript or TypeScript call on something the file imports, like `fireEvent.click(…)` or `util.omit(…)`, no longer links to a same-named method declared in the calling file itself. +- TypeScript's `import type { X }` no longer reads as an import of something named `type`, and `import { a, type B }` binds `B`, not `type B`. So a type-only import now links to the module it names, where it used to link to whatever project symbol had that name. A type imported from a package, like `RsbuildConfig` from `@rsbuild/core`, no longer links to a project symbol, and JavaScript and TypeScript names no longer match a symbol whose name differs only in case (`Mock` is not `mock`). - Angular apps split across Nx libraries, like angular-spotify, now have their routes and navigation. A lazily loaded route written `async () => (await import('@app/home')).HomeModule` is followed through the library's `index.ts` to the module it re-exports, and a `routerLink` in one library now reaches a screen declared in another. Route paths built from a class constant (`RouterUtil.Configuration.Lyrics`, `` `issue/:${ProjectConst.IssueId}` ``) or an enum are now read. A redirect in a routes file that only lazy-loads others now counts, and `router.navigate(['project', 'issue', id])` without `relativeTo` is read from the root, as Angular does. Re-index Angular projects after upgrading. - React Router apps now show navigation written as React Router v5's `` and through a styled link, like react-boilerplate's `HeaderLink = styled(Link)` used as ``. Apps built this way had routes but no links between their screens. - SvelteKit routes are now named by the address a browser asks for. A `(group)` folder like `(app)` or `(marketing)` is no longer part of a route's path, and a parameter with a matcher, like `[id=integer]`, is just `:id`. So `goto('/blocks')` and `` now reach a page that lives in `src/routes/(app)/blocks/`, where before they reached nothing and the app's screens showed no navigation. Re-index SvelteKit projects after upgrading. diff --git a/README.md b/README.md index 23a046f54..284027063 100644 --- a/README.md +++ b/README.md @@ -363,7 +363,7 @@ Real iOS and React Native codebases live across multiple languages — a Swift c | **React Native legacy bridge** | JS `NativeModules.X.fn(...)` | ObjC `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` · Java/Kotlin `@ReactMethod` | Parses macro/annotation declarations to build a JS-name → native-method map | | **React Native TurboModules** | JS `import M from './NativeM'; M.fn(...)` | Native impl matching the Codegen spec | Treats the `Native.ts` spec interface as ground truth | | **RN native → JS events** | JS `new NativeEventEmitter(...).addListener('e', cb)` | ObjC `[self sendEventWithName:@"e" body:...]` · Swift `sendEvent(withName: "e", ...)` · Java/Kotlin `.emit("e", ...)` | Synthesized cross-language event channel keyed by literal event name | -| **Expo Modules** | JS `requireNativeModule('X').fn(...)` | Swift / Kotlin `Module { Name("X"); AsyncFunction("fn") { ... } }` | Parses the Expo DSL literals; synthetic method nodes resolve via existing name-match | +| **Expo Modules** | JS `requireNativeModule('X').fn(...)`, directly or through a binding (`export default requireNativeModule('X')`) | Swift / Kotlin `Module { Name("X"); AsyncFunction("fn") { ... } }` | Parses the Expo DSL literals into method nodes; a call on a binding resolves to module `X`'s `fn` on both platforms, else to the method on the binding's declared type | | **Fabric view components** | JSX `` | TS Codegen spec + native impl class | Spec → `component` node; convention-based name+suffix lookup (`View`/`ComponentView`/`Manager`/`ViewManager`) bridges to native | | **Legacy Paper view managers** | JSX `` | ObjC `RCT_EXPORT_VIEW_PROPERTY` · Java/Kotlin `@ReactProp` | Same as Fabric — Paper-era declarations also produce `component` + `property` nodes | diff --git a/__tests__/expo-modules.test.ts b/__tests__/expo-modules.test.ts index eab16c1c3..4df758a2e 100644 --- a/__tests__/expo-modules.test.ts +++ b/__tests__/expo-modules.test.ts @@ -205,3 +205,80 @@ class BatteryModule : Module() { expect(pair.c).toBeGreaterThanOrEqual(2); // swift->kotlin AND kotlin->swift }); }); + +describe('Expo Modules — a JS binding named for its role, not its module', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-modules-binding-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('expo-camera: `CameraManager.fn()` on `export default requireNativeModule("ExpoCamera")` reaches the module’s functions', async () => { + const write = (rel: string, content: string) => { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + }; + write('package.json', '{"dependencies":{"expo-modules-core":"^1.0.0","expo":"*"}}'); + write('ios/CameraViewModule.swift', `import ExpoModulesCore +public final class CameraViewModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoCamera") + AsyncFunction("launchScanner") { (options: ScannerOptions) in } + AsyncFunction("dismissScanner") { } + } +} +`); + write('android/src/main/java/expo/modules/camera/CameraViewModule.kt', `package expo.modules.camera +class CameraViewModule : Module() { + override fun definition() = ModuleDefinition { + Name("ExpoCamera") + AsyncFunction("launchScanner") { options: ScannerOptions -> } + AsyncFunction("dismissScanner") { } + } +} +`); + write('src/Camera.types.ts', `import { NativeModule } from 'expo'; +export declare class CameraNativeModule extends NativeModule { + readonly isAvailableAsync: () => Promise; +} +`); + write('src/ExpoCameraManager.ts', `import { requireNativeModule } from 'expo'; +import type { CameraNativeModule } from './Camera.types'; + +export default requireNativeModule('ExpoCamera'); +`); + write('src/CameraView.tsx', `import CameraManager from './ExpoCameraManager'; + +export default class CameraView { + static async isAvailableAsync(): Promise { + return CameraManager.isAvailableAsync(); + } + static async launchScanner(options: object): Promise { + await CameraManager.launchScanner(options); + } +} +`); + const cg = await CodeGraph.init(dir, { index: true }); + try { + const view = cg.getNodesInFile('src/CameraView.tsx'); + const from = (name: string) => view.find((n) => n.name === name && n.kind === 'method')!; + const callees = (name: string) => + cg + .getOutgoingEdges(from(name).id) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!) + .map((n) => `${n.language}:${n.qualifiedName.split('::').pop()}`) + .sort(); + // Both platforms' `launchScanner`, not CameraView's own. + expect(callees('launchScanner')).toEqual(['kotlin:ExpoCamera.launchScanner', 'swift:ExpoCamera.launchScanner']); + // Declared natively nowhere here: the type the binding is given, never the caller itself. + expect(callees('isAvailableAsync')).toEqual(['typescript:isAvailableAsync']); + const target = cg.getOutgoingEdges(from('isAvailableAsync').id).find((e) => e.kind === 'calls')!; + expect(cg.getNode(target.target)!.filePath).toBe('src/Camera.types.ts'); + } finally { + cg.close(); + } + }); +}); diff --git a/__tests__/import-type-modifier.test.ts b/__tests__/import-type-modifier.test.ts new file mode 100644 index 000000000..a74b43070 --- /dev/null +++ b/__tests__/import-type-modifier.test.ts @@ -0,0 +1,75 @@ +/** + * TypeScript's `type` modifiers are not bindings. + * + * `import type { X } from './x'` used to read as a default import named + * `type` beside `X` — every type-only import line bound `type` — so zod's + * `type.innerType()` (a parameter named `type`) was taken for a call on an + * import. An inline `{ util, type objectUtil }` named its binding + * `type objectUtil`. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractImportMappings } from '../src/resolution/import-resolver'; + +const bindings = (source: string) => + extractImportMappings('src/a.ts', source, 'typescript').map((m) => `${m.localName}<${m.exportedName}${m.isNamespace ? ' ns' : ''}`); + +describe('import mappings and TypeScript type modifiers', () => { + it('a type-only import binds its names, never `type`', () => { + expect(bindings(`import type { enumUtil } from './helpers/enumUtil.js';\n`)).toEqual(['enumUtil { + expect(bindings(`import { util, type objectUtil } from './helpers/util.js';\n`)).toEqual(['util { + expect(bindings(`import type from './type';\n`)).toEqual(['type { + const roots: string[] = []; + afterAll(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); + }); + + it('halo: `type RsbuildConfig` and `type Command` are not a local `rsbuildConfig` or `command`', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-import-type-')); + roots.push(root); + const files: Record = { + 'package.json': JSON.stringify({ name: 'console', dependencies: { '@rsbuild/core': '*', '@tiptap/core': '*' } }), + 'src/rsbuild.ts': `import { defineConfig, type RsbuildConfig } from '@rsbuild/core'; +export function rsbuildConfig(): RsbuildConfig { return defineConfig({}); } +`, + 'src/menu.ts': `export function command() { return 1; } +`, + 'src/gap.ts': `import type { Command } from '@tiptap/core'; +export function gapCursor(): Command { return () => true; } +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + const cg = await CodeGraph.init(root, { index: true }); + try { + const targets = (file: string) => + cg + .getOutgoingEdgesFrom(cg.getNodesInFile(file).map((n) => n.id), ['references', 'imports', 'type_of', 'returns']) + .map((e) => cg.getNode(e.target)!) + .filter((n) => n.kind !== 'file' && n.kind !== 'import') + .map((n) => n.name); + expect(targets('src/rsbuild.ts')).not.toContain('rsbuildConfig'); + expect(targets('src/gap.ts')).not.toContain('command'); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/frameworks/expo-modules.ts b/src/resolution/frameworks/expo-modules.ts index a9f60174a..c70d1d5ac 100644 --- a/src/resolution/frameworks/expo-modules.ts +++ b/src/resolution/frameworks/expo-modules.ts @@ -42,7 +42,12 @@ import type { Node } from '../../types'; import { FrameworkExtractionResult, FrameworkResolver, + ResolutionContext, + ResolvedRef, + UnresolvedRef, } from '../types'; +import { resolveImportPath } from '../import-resolver'; +import { stripCommentsForRegex } from '../strip-comments'; /** * Match `Function("name")`, `AsyncFunction("name")`, or `Property("name")` @@ -196,3 +201,103 @@ export const expoModulesResolver: FrameworkResolver = { return null; }, }; + +// ============================================================================= +// The JS side: `ExpoCamera.scanFromURLAsync(…)` → the module's function +// ============================================================================= + +/** `requireNativeModule('ExpoCamera')`, typed or not, optional or not. */ +const REQUIRE_NATIVE = /\brequire(?:Optional)?NativeModule\s*(?:<\s*([A-Za-z_$][\w$]*)[^>]*>)?\s*\(\s*['"]([A-Za-z_$][\w$]*)['"]\s*\)/; + +interface NativeBinding { + /** The `Name("…")` the module declares. */ + module: string; + /** The declared JS type, from `requireNativeModule(…)`. */ + type: string | null; + /** The file the binding (and its type) is written in. */ + file: string; +} + +const bindingMemo = new WeakMap>(); + +/** + * What `name` is bound to in `file`: `const X = requireNativeModule('N')` + * here, or the import of a module whose default export (or a named export) + * is one — expo-camera's `import CameraManager from './ExpoCameraManager'`, + * whose whole body is `export default requireNativeModule('ExpoCamera')`. + */ +function nativeBinding(name: string, file: string, context: ResolutionContext): NativeBinding | null { + let memo = bindingMemo.get(context); + if (!memo) bindingMemo.set(context, (memo = new Map())); + const key = `${file}\0${name}`; + if (memo.has(key)) return memo.get(key)!; + let found: NativeBinding | null = null; + const own = context.readFile(file); + if (own && own.includes('NativeModule')) { + const local = new RegExp(String.raw`\b(?:const|let|var)\s+${name}\s*(?::[^=]+)?=\s*` + REQUIRE_NATIVE.source).exec(stripCommentsForRegex(own, 'typescript')); + if (local) found = { type: local[1] ?? null, module: local[2]!, file }; + } + if (!found) { + const language = /\.tsx$/.test(file) ? 'tsx' : /\.[cm]?ts$/.test(file) ? 'typescript' : /\.jsx$/.test(file) ? 'jsx' : 'javascript'; + const binding = context.getImportMappings(file, language).find((m) => m.localName === name); + const target = binding && binding.source.startsWith('.') ? resolveImportPath(binding.source, file, language, context) : null; + const content = target ? context.readFile(target) : null; + if (target && content && content.includes('NativeModule')) { + const safe = stripCommentsForRegex(content, 'typescript'); + const pattern = binding!.isDefault + ? new RegExp(String.raw`\bexport\s+default\s+` + REQUIRE_NATIVE.source) + : new RegExp(String.raw`\bexport\s+(?:const|let)\s+${binding!.exportedName}\s*(?::[^=]+)?=\s*` + REQUIRE_NATIVE.source); + const m = pattern.exec(safe); + if (m) found = { type: m[1] ?? null, module: m[2]!, file: target }; + } + } + memo.set(key, found); + return found; +} + +/** + * JS calls into an Expo module, bound by the module's NAME, not by the + * method name alone: expo-camera calls its module `CameraManager`, so + * `CameraManager.isAvailableAsync()` went to `CameraView`'s own static + * `isAvailableAsync` in the same file. The native functions the module + * declares are the targets — Swift first, Kotlin beside it — and when it + * declares none of that name, the method on the type the binding is given + * (`requireNativeModule`). + */ +export const expoModulesJsResolver: FrameworkResolver = { + name: 'expo-modules-js', + languages: ['typescript', 'tsx', 'javascript', 'jsx'], + + detect(context) { + return expoModulesResolver.detect(context); + }, + + resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; + const m = /^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/.exec(ref.referenceName); + if (!m) return null; + const binding = nativeBinding(m[1]!, ref.filePath, context); + if (!binding) return null; + const fn = m[2]!; + const declared = context + .getNodesByName(fn) + .filter((n) => n.id.startsWith('expo-module:') && n.qualifiedName.endsWith(`::${binding.module}.${fn}`)) + .sort((a, b) => (a.language === 'swift' ? 0 : 1) - (b.language === 'swift' ? 0 : 1)); + if (declared.length > 0) { + return { + original: ref, + targetNodeId: declared[0]!.id, + ...(declared.length > 1 ? { alsoTargets: declared.slice(1).map((n) => ({ targetNodeId: n.id })) } : {}), + confidence: 0.95, + resolvedBy: 'framework', + }; + } + if (binding.type) { + const typed = context + .getNodesByName(fn) + .find((n) => (n.kind === 'method' || n.kind === 'property' || n.kind === 'field') && n.qualifiedName.endsWith(`${binding.type}::${fn}`)); + if (typed) return { original: ref, targetNodeId: typed.id, confidence: 0.9, resolvedBy: 'framework' }; + } + return null; + }, +}; diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 88221fdfa..da76403c3 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -31,7 +31,7 @@ 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 { expoModulesResolver, expoModulesJsResolver } from './expo-modules'; import { expoRouterResolver } from './expo-router'; import { fabricViewResolver } from './fabric'; import { cicsResolver } from './cics'; @@ -90,6 +90,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ reactNativeBridgeResolver, // Expo Modules — Function/AsyncFunction/Property DSL on Swift/Kotlin expoModulesResolver, + // Expo Modules, JS side — `M.fn()` on a `requireNativeModule('N')` binding → module N's `fn` + expoModulesJsResolver, // Expo Router — `app/` screen files → route nodes; `router.push('/x')` → navigates edges expoRouterResolver, // React Native Fabric / Codegen view components — TS spec → component nodes @@ -177,6 +179,6 @@ 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 { expoModulesResolver, expoModulesJsResolver } from './expo-modules'; export { expoRouterResolver } from './expo-router'; export { fabricViewResolver } from './fabric'; diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 1d2e71630..01f3dc244 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -933,8 +933,11 @@ export function extractImportMappings( function extractJSImports(content: string): ImportMapping[] { const mappings: ImportMapping[] = []; - // ES6 imports - const importRegex = /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]+)\})?\s*(?:(\*)\s+as\s+(\w+))?\s*from\s*['"]([^'"]+)['"]/g; + // ES6 imports. `import type { X }` / `import type * as ns` is TypeScript's + // type-only form, not a default import named `type` — which every such + // line used to add, making `type.innerType()` a call on an import. + // (`import type from './x'` still binds `type`: backtracking gives it back.) + const importRegex = /import\s+(?:type\s+(?=[{*]|(?!from\b)\w))?(?:(\w+)\s*,?\s*)?(?:\{([^}]+)\})?\s*(?:(\*)\s+as\s+(\w+))?\s*from\s*['"]([^'"]+)['"]/g; let match; while ((match = importRegex.exec(content)) !== null) { @@ -953,7 +956,8 @@ function extractJSImports(content: string): ImportMapping[] { // Named imports if (namedImports) { - const names = namedImports.split(',').map((s) => s.trim()); + // `{ util, type objectUtil }`: an inline `type` modifier is not part of the name. + const names = namedImports.split(',').map((s) => s.trim().replace(/^type\s+(?=\w)/, '')); for (const name of names) { const aliasMatch = name.match(/(\w+)\s+as\s+(\w+)/); if (aliasMatch) { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index ea09c6c01..a35fcf753 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1509,6 +1509,12 @@ export function matchByExactName( const bareJs = isBareJsCall(ref, context); const bareGo = isBareGoCall(ref, context); const barePhp = isBarePhpCall(ref, context); + // A type, a value or an import the file binds from a package outside the + // repository names nothing in it, whatever kind of reference it is. + if (!bareJs && JS_FAMILY.has(ref.language) && ref.referenceKind !== 'calls' && + /^[A-Za-z_$][\w$]*$/.test(ref.referenceName) && isOutOfRepoBinding(ref.referenceName, ref, context)) { + return null; + } if (bareJs) { const storeAction = matchJsStoreBindingCall(ref, context); if (storeAction) return storeAction; @@ -3598,10 +3604,22 @@ export function matchMethodCall( // Filter to same-language candidates first const sameLanguageMethods = methods.filter(m => m.language === ref.language); - const targetMethods = sameLanguageMethods.length > 0 ? sameLanguageMethods : methods; + let targetMethods = sameLanguageMethods.length > 0 ? sameLanguageMethods : methods; + // A receiver the file imports is another module's value: never a method + // declared in the calling file. expo-camera's `CameraManager.isAvailableAsync()` + // (`import CameraManager from './ExpoCameraManager'`) went to `CameraView`'s + // own static `isAvailableAsync` — the method making the call. + // Ruling the caller's file out may reject a guess; it must never + // manufacture one — the one method left is then no likelier than before. + let narrowed = false; + if (JS_FAMILY.has(ref.language) && isImportBinding(objectOrClass!, ref, context)) { + const kept = targetMethods.filter((m) => m.filePath !== ref.filePath); + narrowed = kept.length !== targetMethods.length; + targetMethods = kept; + } // If only one same-language method with this name exists, use it - if (targetMethods.length === 1 && targetMethods[0]!.language === ref.language) { + if (targetMethods.length === 1 && !narrowed && targetMethods[0]!.language === ref.language) { return { original: ref, targetNodeId: targetMethods[0]!.id, @@ -3660,6 +3678,12 @@ function isImportedModuleReceiver(receiver: string, ref: UnresolvedRef, context: return binding.isNamespace || context.isOutOfRepoImport?.(binding.source, ref.filePath, ref.language) === true; } +/** Is the root of a member call's receiver (`CameraManager` in `CameraManager.x`) one of the file's imports? */ +function isImportBinding(receiver: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + const root = receiver.split('.')[0]!; + return context.getImportMappings?.(ref.filePath, ref.language)?.some((m) => m.localName === root) === true; +} + /** Does the file bind `name` by importing it from outside the repository? */ function isOutOfRepoBinding(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean { const binding = context.getImportMappings?.(ref.filePath, ref.language)?.find((m) => m.localName === name); @@ -4558,6 +4582,9 @@ export function matchFuzzy( const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind) && !(typeRef && !canNameInTypePosition(n)) && !(rustBare && (n.name !== ref.referenceName || !isRustNameInScope(n, ref, context))) && !(ref.language === 'python' && n.name !== ref.referenceName) && + // JS and TS too: halo's `type RsbuildConfig` (imported from @rsbuild/core) + // is not its local `rsbuildConfig`, kit's vitest `Mock` not a `mock`. + !(JS_FAMILY.has(ref.language) && n.name !== ref.referenceName) && !(pythonShape && !fitsPythonCallShape(n, pythonShape, ref, context)) && !(javaBare && n.kind === 'method' && !isJavaMethodInScope(n, ref, context))) .filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') || @@ -4587,8 +4614,9 @@ export function matchFuzzy( isVisibleAcrossFiles(finalCandidates[0]!, ref, context) && isCrossFileReachable(finalCandidates[0]!, ref, context) && !(isBareJsCall(ref, context) && - (TYPE_MEMBER_KINDS.has(finalCandidates[0]!.kind) || isOutOfRepoBinding(ref.referenceName, ref, context) || + (TYPE_MEMBER_KINDS.has(finalCandidates[0]!.kind) || (finalCandidates[0]!.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)))) && + !(JS_FAMILY.has(ref.language) && isOutOfRepoBinding(ref.referenceName, ref, context)) && !(finalCandidates[0]!.kind === 'method' && isBareGoCall(ref, context)) && // A bare PHP call is a function call (case-insensitive, so fuzzy may find // one) — never the class `View` for `view(…)`, never a method.