diff --git a/packages/cli/src/navigation/__tests__/generators.test.ts b/packages/cli/src/navigation/__tests__/generators.test.ts index 62e60f1f..4acc897d 100644 --- a/packages/cli/src/navigation/__tests__/generators.test.ts +++ b/packages/cli/src/navigation/__tests__/generators.test.ts @@ -24,6 +24,33 @@ const methods: MethodSignature[] = [ }, ]; +const callbackMethods: MethodSignature[] = [ + { + name: 'navigateToProfile', + params: [ + { name: 'userId', type: 'string', optional: false }, + { + name: 'onDismiss', + type: '() => void', + optional: false, + callback: { params: [], returnType: 'void' }, + }, + ], + returnType: 'void', + isAsync: false, + }, +]; + +const asyncMethods: MethodSignature[] = [ + { + name: 'requestPermission', + params: [{ name: 'name', type: 'string', optional: false }], + returnType: 'Promise', + isAsync: true, + promiseReturnType: 'boolean', + }, +]; + const modelMethods: MethodSignature[] = [ { name: 'openSettings', @@ -196,6 +223,79 @@ describe('navigation code generators', () => { ); }); + it('generates iOS bindings for methods with callback parameters', () => { + const swiftDelegate = generateSwiftDelegate(callbackMethods); + const objcImplementation = generateObjCImplementation(callbackMethods); + + expect(swiftDelegate).toContain('import React'); + expect(swiftDelegate).toContain( + '@objc func navigateToProfile(_ userId: String, onDismiss onDismiss: @escaping RCTResponseSenderBlock)' + ); + + expect(objcImplementation).toContain( + '- (void)navigateToProfile:(NSString *)userId onDismiss:(RCTResponseSenderBlock)onDismiss' + ); + expect(objcImplementation).toContain( + '[[[BrownfieldNavigationManager shared] getDelegate] navigateToProfile:userId onDismiss:onDismiss];' + ); + }); + + it('generates Android bindings for methods with callback parameters', () => { + const kotlinPackageName = 'com.callstack.nativebrownfieldnavigation'; + const kotlinDelegate = generateKotlinDelegate(callbackMethods, kotlinPackageName); + const kotlinModule = generateKotlinModule(callbackMethods, kotlinPackageName); + + expect(kotlinDelegate).toContain('import com.facebook.react.bridge.Callback'); + expect(kotlinDelegate).toContain( + 'fun navigateToProfile(userId: String, onDismiss: Callback)' + ); + + expect(kotlinModule).toContain('import com.facebook.react.bridge.Callback'); + expect(kotlinModule).toContain( + 'override fun navigateToProfile(userId: String, onDismiss: Callback)' + ); + expect(kotlinModule).toContain( + 'BrownfieldNavigationManager.getDelegate().navigateToProfile(userId, onDismiss)' + ); + }); + + it('generates iOS async bindings that forward to the delegate instead of the not_implemented stub', () => { + const swiftDelegate = generateSwiftDelegate(asyncMethods); + const objcImplementation = generateObjCImplementation(asyncMethods); + + expect(swiftDelegate).toContain('import React'); + expect(swiftDelegate).toContain( + '@objc func requestPermission(_ name: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock)' + ); + + expect(objcImplementation).toContain( + '- (void)requestPermission:(NSString *)name resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject' + ); + expect(objcImplementation).toContain( + '[[[BrownfieldNavigationManager shared] getDelegate] requestPermission:name resolve:resolve reject:reject];' + ); + expect(objcImplementation).not.toContain('not_implemented'); + }); + + it('generates Android async bindings that forward to the delegate instead of the not_implemented stub', () => { + const kotlinPackageName = 'com.callstack.nativebrownfieldnavigation'; + const kotlinDelegate = generateKotlinDelegate(asyncMethods, kotlinPackageName); + const kotlinModule = generateKotlinModule(asyncMethods, kotlinPackageName); + + expect(kotlinDelegate).toContain('import com.facebook.react.bridge.Promise'); + expect(kotlinDelegate).toContain( + 'fun requestPermission(name: String, promise: Promise)' + ); + + expect(kotlinModule).toContain( + 'override fun requestPermission(name: String, promise: Promise)' + ); + expect(kotlinModule).toContain( + 'BrownfieldNavigationManager.getDelegate().requestPermission(name, promise)' + ); + expect(kotlinModule).not.toContain('not_implemented'); + }); + it('maps known model types for Swift delegate signatures', () => { const swiftDelegate = generateSwiftDelegate(modelMethods, { modelTypeNames: ['DummyType'], diff --git a/packages/cli/src/navigation/__tests__/parser.test.ts b/packages/cli/src/navigation/__tests__/parser.test.ts index 2317b350..d5f4f7ce 100644 --- a/packages/cli/src/navigation/__tests__/parser.test.ts +++ b/packages/cli/src/navigation/__tests__/parser.test.ts @@ -129,6 +129,84 @@ describe('parseNavigationSpec', () => { ]); }); + it('parses function-typed parameters into a callback signature', () => { + const specPath = createTempSpecFile(` + export interface BrownfieldNavigationSpec { + navigateToProfile(userId: string, onDismiss: (reason?: string) => void): void; + } + `); + tempSpecFiles.push(specPath); + + const parsedSpec = parseNavigationSpec(specPath); + + expect(parsedSpec.methods).toEqual([ + { + name: 'navigateToProfile', + params: [ + { name: 'userId', type: 'string', optional: false }, + { + name: 'onDismiss', + type: '(reason?: string) => void', + optional: false, + callback: { + params: [{ name: 'reason', type: 'string', optional: true }], + returnType: 'void', + }, + }, + ], + returnType: 'void', + isAsync: false, + }, + ]); + }); + + it('parses Promise-returning methods into promiseReturnType and isAsync', () => { + const specPath = createTempSpecFile(` + export interface BrownfieldNavigationSpec { + requestPermission(name: string): Promise; + } + `); + tempSpecFiles.push(specPath); + + const parsedSpec = parseNavigationSpec(specPath); + + expect(parsedSpec.methods).toEqual([ + { + name: 'requestPermission', + params: [{ name: 'name', type: 'string', optional: false }], + returnType: 'Promise', + isAsync: true, + promiseReturnType: 'boolean', + }, + ]); + }); + + it('throws when a parameter is typed as a Promise', () => { + const specPath = createTempSpecFile(` + export interface BrownfieldNavigationSpec { + foo(cb: Promise): void; + } + `); + tempSpecFiles.push(specPath); + + expect(() => parseNavigationSpec(specPath)).toThrow( + 'Unsupported Promise parameter "cb" in method "foo": Promise is only supported as a method return type.' + ); + }); + + it('throws when a callback parameter has a non-void return type', () => { + const specPath = createTempSpecFile(` + export interface BrownfieldNavigationSpec { + bar(cb: () => string): void; + } + `); + tempSpecFiles.push(specPath); + + expect(() => parseNavigationSpec(specPath)).toThrow( + 'Unsupported callback parameter "cb" in method "bar": callback return type "string" is not supported. Use a void callback or model the result as a Promise-returning navigation method.' + ); + }); + it('throws when no valid spec interface is present', () => { const specPath = createTempSpecFile(` export interface NavigationSpec { diff --git a/packages/cli/src/navigation/generators/android.ts b/packages/cli/src/navigation/generators/android.ts index 3cfbc9b3..11b46c75 100644 --- a/packages/cli/src/navigation/generators/android.ts +++ b/packages/cli/src/navigation/generators/android.ts @@ -1,4 +1,4 @@ -import type { MethodSignature } from '../types.js'; +import type { MethodParam, MethodSignature } from '../types.js'; const TS_TO_KOTLIN_TYPE: Record = { string: 'String', @@ -12,6 +12,13 @@ interface KotlinTypeMappingOptions { modelTypeNames?: string[]; } +function buildKotlinImports(imports: string[]): string { + if (imports.length === 0) { + return '\n'; + } + return `\n${[...new Set(imports)].sort().join('\n')}\n`; +} + function mapTsTypeToKotlin( tsType: string, optional: boolean = false, @@ -38,21 +45,66 @@ function mapTsTypeToKotlin( return optional ? 'Any?' : 'Any'; } +function mapParamToKotlin( + param: MethodParam, + options: KotlinTypeMappingOptions = {}, + layer: 'delegate' | 'module' = 'delegate' +): string { + return param.callback + ? 'Callback' + : mapTsTypeToKotlin(param.type, param.optional, options, layer); +} + +function prepareKotlinArgs( + method: MethodSignature, + options: KotlinTypeMappingOptions = {} +): { preparedParams: string[]; args: string } { + const preparedParams: string[] = []; + const args = method.params + .map((param) => { + if (options.modelTypeNames?.includes(param.type)) { + const convertedName = `${param.name}Model`; + preparedParams.push( + ` val ${convertedName} = ${param.name}${ + param.optional + ? `?.let(::to${param.type})` + : `.let(::to${param.type})` + }` + ); + return convertedName; + } + return param.name; + }) + .join(', '); + + return { preparedParams, args }; +} + export function generateKotlinDelegate( methods: MethodSignature[], kotlinPackageName: string, options: KotlinTypeMappingOptions = {} ): string { + const hasAsyncMethod = methods.some((method) => method.isAsync); + const hasCallbackParam = methods.some((method) => + method.params.some((param) => param.callback) + ); + const imports = buildKotlinImports([ + ...(hasCallbackParam ? ['import com.facebook.react.bridge.Callback'] : []), + ...(hasAsyncMethod ? ['import com.facebook.react.bridge.Promise'] : []), + ]); + const methodSignatures = methods .map((method) => { - const params = method.params - .map( - (param) => - `${param.name}: ${mapTsTypeToKotlin(param.type, param.optional, options)}` - ) - .join(', '); + const methodParams = method.params.map( + (param) => `${param.name}: ${mapParamToKotlin(param, options)}` + ); + const params = [ + ...methodParams, + ...(method.isAsync ? ['promise: Promise'] : []), + ].join(', '); const returnType = - method.returnType === 'void' + method.returnType === 'void' || method.isAsync ? '' : `: ${mapTsTypeToKotlin(method.returnType, false, options, 'delegate')}`; return ` fun ${method.name}(${params})${returnType}`; @@ -60,8 +112,7 @@ export function generateKotlinDelegate( .join('\n'); return `package ${kotlinPackageName} - -interface BrownfieldNavigationDelegate { +${imports}interface BrownfieldNavigationDelegate { ${methodSignatures} } `; @@ -73,6 +124,9 @@ export function generateKotlinModule( options: KotlinTypeMappingOptions = {} ): string { const hasAsyncMethod = methods.some((method) => method.isAsync); + const hasCallbackParam = methods.some((method) => + method.params.some((param) => param.callback) + ); const hasObjectType = methods.some( (method) => method.returnType.includes('Object') || @@ -94,8 +148,8 @@ export function generateKotlinModule( import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod${ - hasAsyncMethod ? '\nimport com.facebook.react.bridge.Promise' : '' - }${hasObjectType ? '\nimport com.facebook.react.bridge.ReadableMap' : ''} + hasCallbackParam ? '\nimport com.facebook.react.bridge.Callback' : '' + }${hasAsyncMethod ? '\nimport com.facebook.react.bridge.Promise' : ''}${hasObjectType ? '\nimport com.facebook.react.bridge.ReadableMap' : ''} class NativeBrownfieldNavigationModule( reactContext: ReactApplicationContext @@ -115,27 +169,10 @@ function generateSyncKotlinMethod( ): string { const params = method.params .map( - (param) => - `${param.name}: ${mapTsTypeToKotlin(param.type, param.optional, options, 'module')}` + (param) => `${param.name}: ${mapParamToKotlin(param, options, 'module')}` ) .join(', '); - const preparedParams: string[] = []; - const args = method.params - .map((param) => { - if (options.modelTypeNames?.includes(param.type)) { - const convertedName = `${param.name}Model`; - preparedParams.push( - ` val ${convertedName} = ${param.name}${ - param.optional - ? `?.let(::to${param.type})` - : `.let(::to${param.type})` - }` - ); - return convertedName; - } - return param.name; - }) - .join(', '); + const { preparedParams, args } = prepareKotlinArgs(method, options); const signature = ` @ReactMethod\n override fun ${method.name}(${params})${ method.returnType === 'void' @@ -161,17 +198,19 @@ function generateAsyncKotlinMethod( ): string { const paramsWithTypes = method.params .map( - (param) => - `${param.name}: ${mapTsTypeToKotlin(param.type, param.optional, options, 'module')}` + (param) => `${param.name}: ${mapParamToKotlin(param, options, 'module')}` ) .join(', '); const params = paramsWithTypes.length > 0 ? `${paramsWithTypes}, promise: Promise` : 'promise: Promise'; + const { preparedParams, args } = prepareKotlinArgs(method, options); + const delegateArgs = args.length > 0 ? `${args}, promise` : 'promise'; + const preparedPrefix = preparedParams.length > 0 ? `${preparedParams.join('\n')}\n` : ''; return ` @ReactMethod override fun ${method.name}(${params}) { - promise.reject("not_implemented", "${method.name} is not implemented") +${preparedPrefix} BrownfieldNavigationManager.getDelegate().${method.name}(${delegateArgs}) }`; } diff --git a/packages/cli/src/navigation/generators/ios.ts b/packages/cli/src/navigation/generators/ios.ts index 4faf052e..ab2a70e1 100644 --- a/packages/cli/src/navigation/generators/ios.ts +++ b/packages/cli/src/navigation/generators/ios.ts @@ -1,4 +1,4 @@ -import type { MethodSignature } from '../types.js'; +import type { MethodParam, MethodSignature } from '../types.js'; const TS_TO_OBJC_TYPE: Record = { string: 'NSString *', @@ -20,6 +20,10 @@ interface ObjCTypeMappingOptions { modelTypeNames?: string[]; } +function hasCallbackParam(methods: MethodSignature[]): boolean { + return methods.some((method) => method.params.some((param) => param.callback)); +} + function mapTsTypeToObjC( tsType: string, nullable: boolean = false, @@ -44,6 +48,16 @@ function mapTsTypeToObjC( return nullable ? 'id _Nullable' : 'id'; } +function mapParamToObjC( + param: MethodParam, + nullable: boolean = false, + options: ObjCTypeMappingOptions = {} +): string { + return param.callback + ? 'RCTResponseSenderBlock' + : mapTsTypeToObjC(param.type, nullable, options); +} + interface SwiftTypeMappingOptions { modelTypeNames?: string[]; } @@ -72,22 +86,38 @@ function mapTsTypeToSwift( return optional ? 'Any?' : 'Any'; } +function mapParamToSwift( + param: MethodParam, + options: SwiftTypeMappingOptions = {} +): string { + return param.callback + ? '@escaping RCTResponseSenderBlock' + : mapTsTypeToSwift(param.type, param.optional, options); +} + export function generateSwiftDelegate( methods: MethodSignature[], options: SwiftTypeMappingOptions = {} ): string { + const needsReactImport = + methods.some((method) => method.isAsync) || hasCallbackParam(methods); const protocolMethods = methods .map((method) => { - const params = method.params - .map((param, index) => { - const swiftType = mapTsTypeToSwift(param.type, param.optional, options); - const label = index === 0 ? '_' : param.name; - return `${label} ${param.name}: ${swiftType}`; - }) - .join(', '); + const methodParams = method.params.map((param, index) => { + const swiftType = mapParamToSwift(param, options); + const label = index === 0 ? '_' : param.name; + return `${label} ${param.name}: ${swiftType}`; + }); + const promiseParams = method.isAsync + ? [ + `${methodParams.length === 0 ? '_ resolve' : 'resolve'}: @escaping RCTPromiseResolveBlock`, + 'reject: @escaping RCTPromiseRejectBlock', + ] + : []; + const params = [...methodParams, ...promiseParams].join(', '); const returnType = - method.returnType === 'void' + method.returnType === 'void' || method.isAsync ? '' : ` -> ${mapTsTypeToSwift(method.returnType, false, options)}`; @@ -95,7 +125,7 @@ export function generateSwiftDelegate( }) .join('\n'); - return `import Foundation + return `import Foundation${needsReactImport ? '\nimport React' : ''} @objc public protocol BrownfieldNavigationDelegate: AnyObject { ${protocolMethods} @@ -142,25 +172,13 @@ ${methodImplementations} `; } -function generateSyncObjCMethod( +function prepareObjCArgs( method: MethodSignature, options: ObjCGenerationOptions -): string { - const { name, params, returnType } = method; - - let signature = `- (${mapTsTypeToObjC(returnType, false, options)})${name}`; - if (params.length > 0) { - signature += params - .map((param, index) => { - const prefix = index === 0 ? ':' : ` ${param.name}:`; - return `${prefix}(${mapTsTypeToObjC(param.type, param.optional, options)})${param.name}`; - }) - .join(''); - } - +): { preparedParams: string[]; delegateArgs: string[] } { const preparedParams: string[] = []; const delegateArgs: string[] = []; - for (const param of params) { + for (const param of method.params) { if (options.modelTypeNames?.includes(param.type)) { const convertedParamName = `${param.name}Model`; preparedParams.push( @@ -171,7 +189,14 @@ function generateSyncObjCMethod( delegateArgs.push(param.name); } } + return { preparedParams, delegateArgs }; +} +function buildObjCDelegateCall( + name: string, + params: Array<{ name: string; type: string; optional: boolean }>, + delegateArgs: string[] +): string { let delegateCall = `[[[BrownfieldNavigationManager shared] getDelegate] ${name}`; if (delegateArgs.length > 0) { delegateCall += delegateArgs @@ -183,6 +208,27 @@ function generateSyncObjCMethod( .join(' '); } delegateCall += ']'; + return delegateCall; +} + +function generateSyncObjCMethod( + method: MethodSignature, + options: ObjCGenerationOptions +): string { + const { name, params, returnType } = method; + + let signature = `- (${mapTsTypeToObjC(returnType, false, options)})${name}`; + if (params.length > 0) { + signature += params + .map((param, index) => { + const prefix = index === 0 ? ':' : ` ${param.name}:`; + return `${prefix}(${mapParamToObjC(param, param.optional, options)})${param.name}`; + }) + .join(''); + } + + const { preparedParams, delegateArgs } = prepareObjCArgs(method, options); + const delegateCall = buildObjCDelegateCall(name, params, delegateArgs); const returnPrefix = returnType === 'void' ? '' : 'return '; const preparedLines = preparedParams.map((line) => ` ${line}`).join('\n'); @@ -200,27 +246,33 @@ function generateAsyncObjCMethod( const { name, params } = method; let signature = `- (void)${name}`; - const allParams: Array<{ name: string; type: string; optional: boolean }> = [ - ...params, + const promiseParams: Array<{ name: string; type: string; optional: boolean }> = [ { name: 'resolve', type: 'RCTPromiseResolveBlock', optional: false }, { name: 'reject', type: 'RCTPromiseRejectBlock', optional: false }, ]; + const allParams = [...params, ...promiseParams]; signature += ':'; signature += allParams .map((param, index) => { - const prefix = index === 0 ? '' : param.name; + const prefix = index === 0 ? '' : `${param.name}:`; const type = param.type === 'RCTPromiseResolveBlock' ? 'RCTPromiseResolveBlock' : param.type === 'RCTPromiseRejectBlock' ? 'RCTPromiseRejectBlock' - : mapTsTypeToObjC(param.type, param.optional, options); + : mapParamToObjC(param, param.optional, options); return `${prefix}(${type})${param.name}`; }) .join(' '); + const { preparedParams, delegateArgs } = prepareObjCArgs(method, options); + const asyncDelegateArgs = [...delegateArgs, 'resolve', 'reject']; + const delegateCall = buildObjCDelegateCall(name, allParams, asyncDelegateArgs); + const preparedLines = preparedParams.map((line) => ` ${line}`).join('\n'); + const bodyPrefix = preparedLines.length > 0 ? `${preparedLines}\n` : ''; + return `${signature} { - reject(@"not_implemented", @"${name} is not implemented", nil); +${bodyPrefix} ${delegateCall}; }`; } diff --git a/packages/cli/src/navigation/parser.ts b/packages/cli/src/navigation/parser.ts index c7248e03..8e1898d0 100644 --- a/packages/cli/src/navigation/parser.ts +++ b/packages/cli/src/navigation/parser.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import { Project } from 'ts-morph'; import type { + CallbackSignature, ModelDefinition, ModelFieldDefinition, MethodParam, @@ -10,6 +11,7 @@ import type { TypeDeclaration, } from './types.js'; import { Node } from 'ts-morph'; +import type { TypeNode } from 'ts-morph'; const SKIP_TYPE_TOKENS = new Set([ 'Array', @@ -42,6 +44,49 @@ function collectReferencedTypesFromText(typeText: string): string[] { return matches.filter((match) => !SKIP_TYPE_TOKENS.has(match)); } +function getPromiseInnerType(typeText: string): string | undefined { + return typeText.startsWith('Promise<') && typeText.endsWith('>') + ? typeText.slice(8, -1) + : undefined; +} + +function parseCallbackSignature( + typeNode: TypeNode | undefined +): CallbackSignature | undefined { + if (!typeNode || !Node.isFunctionTypeNode(typeNode)) { + return undefined; + } + + return { + params: typeNode.getParameters().map((param) => ({ + name: param.getName(), + type: param.getTypeNode()?.getText() ?? 'unknown', + optional: param.isOptional(), + })), + returnType: typeNode.getReturnTypeNode()?.getText() ?? 'void', + }; +} + +function validateMethodSignature(method: MethodSignature): void { + for (const param of method.params) { + if (getPromiseInnerType(param.type)) { + throw new Error( + `Unsupported Promise parameter "${param.name}" in method "${method.name}": Promise is only supported as a method return type.` + ); + } + + if (!param.callback) { + continue; + } + + if (param.callback.returnType !== 'void') { + throw new Error( + `Unsupported callback parameter "${param.name}" in method "${method.name}": callback return type "${param.callback.returnType}" is not supported. Use a void callback or model the result as a Promise-returning navigation method.` + ); + } + } +} + export function parseNavigationSpec(specPath: string): ParsedNavigationSpec { if (!fs.existsSync(specPath)) { throw new Error(`Spec file not found: ${specPath}`); @@ -63,21 +108,32 @@ export function parseNavigationSpec(specPath: string): ParsedNavigationSpec { const name = method.getName(); const params: MethodParam[] = method.getParameters().map((param) => { const typeNode = param.getTypeNode(); - return { + const callback = parseCallbackSignature(typeNode); + const methodParam: MethodParam = { name: param.getName(), type: typeNode?.getText() ?? 'unknown', optional: param.isOptional(), }; + if (callback) { + methodParam.callback = callback; + } + return methodParam; }); const returnTypeNode = method.getReturnTypeNode(); const returnType = returnTypeNode?.getText() ?? 'void'; + const promiseReturnType = getPromiseInnerType(returnType); - return { + const methodSignature: MethodSignature = { name, params, returnType, - isAsync: returnType.startsWith('Promise<'), + isAsync: Boolean(promiseReturnType), }; + if (promiseReturnType) { + methodSignature.promiseReturnType = promiseReturnType; + } + validateMethodSignature(methodSignature); + return methodSignature; }); const declarationNodes = [ diff --git a/packages/cli/src/navigation/types.ts b/packages/cli/src/navigation/types.ts index 569670e8..41d6384d 100644 --- a/packages/cli/src/navigation/types.ts +++ b/packages/cli/src/navigation/types.ts @@ -1,7 +1,19 @@ +export interface CallbackParam { + name: string; + type: string; + optional: boolean; +} + +export interface CallbackSignature { + params: CallbackParam[]; + returnType: string; +} + export interface MethodParam { name: string; type: string; optional: boolean; + callback?: CallbackSignature; } export interface MethodSignature { @@ -9,6 +21,7 @@ export interface MethodSignature { params: MethodParam[]; returnType: string; isAsync: boolean; + promiseReturnType?: string; } export interface TypeDeclaration {