import fs from 'fs'; import path from 'path'; import ts from 'typescript'; import { BasicType, ClassDeclaration, EnumType, FileTypeInformation, FunctionDeclaration, getFileTypeInformation, IdentifierKind, ModuleClassDeclaration, OptionalType, RecordType, SumType, Type, TypeKind, ViewDeclaration, } from './typeInformation'; import { getBasicTypesIdentifiers, buildEnumTypeDeclaration, buildViewPropsInterface, buildRecordTypeAlias, buildClass, buildFunction, getViewPropsTypeName, joinTSNodesWithNewlines, prettifyCode, buildUnknownTypeAlias, } from './typescriptGeneration'; import { taskAll } from './utils'; const prefix = `Automatically generated by expo-type-information. This autogenerated file provides a mock for native Expo module, and works out of the box with the expo jest preset. `; function getPrefix() { return [ts.factory.createJSDocComment(prefix)]; } let freeId = 0; function getNextFreeId() { freeId += 1; return freeId; } function maybeWrapWithReturnStatement( type: Type, fileTypeInformation: FileTypeInformation ): ts.ReturnStatement[] { if (type.kind === TypeKind.BASIC) { const basicType = type.type as BasicType; if (basicType === BasicType.VOID || basicType === BasicType.ANY) { return []; } } // TODO(@HubertBer): maybe add a comment when we couldn't create a mock for the return type const returnExpression = getMockedValueForType(type, fileTypeInformation); if (returnExpression || type.kind !== TypeKind.BASIC || type.type === BasicType.UNRESOLVED) { return [ts.factory.createReturnStatement(returnExpression)]; } return []; } function getBasicTypeMockLiteral(type: BasicType): ts.PrimaryExpression | undefined { switch (type) { case BasicType.STRING: return ts.factory.createStringLiteral(''); case BasicType.BOOLEAN: return ts.factory.createFalse(); case BasicType.NUMBER: return ts.factory.createNumericLiteral(0); case BasicType.VOID: case BasicType.UNDEFINED: case BasicType.ANY: default: return undefined; } } function getBasicTypeFromString(basicType: string): BasicType | undefined { switch (basicType) { case 'any': return BasicType.ANY; case 'number': return BasicType.NUMBER; case 'string': return BasicType.STRING; case 'boolean': return BasicType.BOOLEAN; } return undefined; } function getMockedEnumInstance(enumType: EnumType): ts.Expression | undefined { const firstCase = enumType.cases[0]; if (!firstCase) { return undefined; } return ts.factory.createPropertyAccessExpression( ts.factory.createRegularExpressionLiteral(enumType.name), firstCase ); } function getMockedRecordInstance(recordType: RecordType, fileTypeInformation: FileTypeInformation) { return ts.factory.createObjectLiteralExpression( recordType.fields.map((f) => ts.factory.createPropertyAssignment( f.name ?? '_' + getNextFreeId(), getMockedValueForType(f.type, fileTypeInformation) ?? ts.factory.createNull() ) ) ); } function getMockValueForIdentifier( identifier: string, fileTypeInformation: FileTypeInformation ): ts.Expression | undefined { if (!fileTypeInformation.typeIdentifierDefinitionMap.has(identifier)) { return undefined; } const typeDefinition = fileTypeInformation.typeIdentifierDefinitionMap.get(identifier); switch (typeDefinition?.kind) { case IdentifierKind.BASIC: { const basicType = getBasicTypeFromString(typeDefinition.definition as string); if (basicType) { return getBasicTypeMockLiteral(basicType); } return undefined; } case IdentifierKind.ENUM: return getMockedEnumInstance(typeDefinition.definition as EnumType); case IdentifierKind.RECORD: return getMockedRecordInstance(typeDefinition.definition as RecordType, fileTypeInformation); } return undefined; } function getMockedValueForType( type: Type, fileTypeInformation: FileTypeInformation ): ts.Expression | undefined { switch (type.kind) { case TypeKind.BASIC: return getBasicTypeMockLiteral(type.type as BasicType); case TypeKind.IDENTIFIER: return getMockValueForIdentifier(type.type as string, fileTypeInformation); case TypeKind.SUM: { const firstType = (type.type as SumType).types[0]; return getMockedValueForType( firstType ?? { kind: TypeKind.BASIC, type: BasicType.UNDEFINED }, fileTypeInformation ); } case TypeKind.PARAMETRIZED: return ts.factory.createNull(); case TypeKind.OPTIONAL: return getMockedValueForType(type.type as OptionalType, fileTypeInformation); case TypeKind.ARRAY: return ts.factory.createArrayLiteralExpression(); case TypeKind.DICTIONARY: return ts.factory.createObjectLiteralExpression(); } } function getFunctionReturnBlock( functionDeclaration: FunctionDeclaration, fileTypeInformation: FileTypeInformation ): ts.ReturnStatement[] { return maybeWrapWithReturnStatement(functionDeclaration.returnType, fileTypeInformation); } function getMockedFunctionDeclaration( functionDeclaration: FunctionDeclaration, fileTypeInformation: FileTypeInformation, async: boolean, exported: boolean ): ts.FunctionDeclaration { return buildFunction({ functionDeclaration, async, method: false, exported, declaration: false, returnStatement: maybeWrapWithReturnStatement( functionDeclaration.returnType, fileTypeInformation ), }) as ts.FunctionDeclaration; } function getMockedClass( classDeclaration: ClassDeclaration, fileInfo: FileTypeInformation ): ts.ClassDeclaration { return buildClass({ classDeclaration, exported: true, getFunctionReturnBlock: (func) => getFunctionReturnBlock(func, fileInfo), }); } function getMockedView(viewDeclaration: ViewDeclaration): ts.Node[] { const propsTypeName = getViewPropsTypeName(viewDeclaration); const propsType = buildViewPropsInterface(viewDeclaration, { exported: true }); const propsParameter = ts.factory.createParameterDeclaration( undefined, undefined, 'props', undefined, ts.factory.createTypeReferenceNode(propsTypeName, undefined), undefined ); const viewFunction = ts.factory.createFunctionDeclaration( [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], undefined, (viewDeclaration.name?.length ?? 0) > 0 ? viewDeclaration.name : 'View', undefined, [propsParameter], undefined, ts.factory.createBlock([]) ); return [...propsType, viewFunction]; } function getMockForModule( module: ModuleClassDeclaration, typeInfo: FileTypeInformation ): ts.Node[] { const { usedTypeIdentifiers, declaredTypeIdentifiers, inferredTypeParametersCount, records, enums, } = typeInfo; const undeclaredTypes: Set = usedTypeIdentifiers .difference(declaredTypeIdentifiers) .difference(getBasicTypesIdentifiers()); const sections = [ getPrefix(), [...undeclaredTypes].map((identifier) => buildUnknownTypeAlias(identifier, true, inferredTypeParametersCount) ), records.flatMap((record: RecordType) => buildRecordTypeAlias(record, true)), enums.flatMap((e: EnumType) => buildEnumTypeDeclaration(e, true, false)), module.functions.map((f) => getMockedFunctionDeclaration(f, typeInfo, false, true)), module.asyncFunctions.map((f) => getMockedFunctionDeclaration(f, typeInfo, true, true)), module.classes.map((c) => getMockedClass(c, typeInfo)), module.views.flatMap((v) => getMockedView(v)), ]; return joinTSNodesWithNewlines(sections).flat(); } /** * This function generates a JavaScript/TypeScript mock content string for a given ModuleClassDeclaration object which is also part of the FileTypeInformation object. * @param module a ModuleClassDeclaration object which is a child of the fileTypeInformation object. * @param fileTypeInformation a FileTypeInformation object which has the context of the whole file in which the module was declared. * @param includeTypes when set to true, the function emits TypeScript code otherwise it emits JavaScript. * @returns a string which is the generated mock content. * @header Mocks */ export function generateTSMockForModule( module: ModuleClassDeclaration, fileTypeInformation: FileTypeInformation, includeTypes: boolean ): string { const mockFileName = module.name + (includeTypes ? '.ts' : '.js'); const mock = ts.factory.createNodeArray(getMockForModule(module, fileTypeInformation)); const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); // get ts nodearray from getMockForModule(m) array const resultFile = ts.createSourceFile( mockFileName, '', ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX ); const printedTs = printer.printList( ts.ListFormat.MultiLine + ts.ListFormat.PreserveLines, mock, resultFile ); if (includeTypes) { return printedTs; } return ts.transpileModule(printedTs, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext, }, }).outputText; } const directoryPath = process.cwd(); const swiftFilesGlob = `${directoryPath}/**/*.swift`; /** * This function generates JavaScript/TypeScript mocks for each provided `FileTypeInformation` object. * * @param files A list of `FileTypeInformation` objects with generated type information for multiple modules * @param outputLanguage the language to emit the mocks in * @returns nothing * @header Mocks */ export async function generateMocks( files: FileTypeInformation[], outputLanguage: 'javascript' | 'typescript' = 'javascript' ) { const modules = files.flatMap((file) => file.moduleClasses.map((module) => ({ module, file }))); if (modules.length === 0) { return; } const isTypeScript = outputLanguage === 'typescript'; const extension = isTypeScript ? '.ts' : '.js'; const mocksDir = path.join(directoryPath, 'mocks'); await fs.promises.mkdir(mocksDir, { recursive: true }); taskAll(modules, async ({ module, file }) => { const code = generateTSMockForModule(module, file, isTypeScript); const prettified = await prettifyCode(code, isTypeScript ? 'typescript' : undefined); await fs.promises.writeFile(path.join(mocksDir, module.name + extension), prettified); }); } /** * * @returns * @header Mocks */ export async function getAllExpoModulesInWorkingDirectory(): Promise { const files = fs.globSync(swiftFilesGlob); return ( await Promise.all( files.map((file) => getFileTypeInformation({ input: { type: 'file', inputFileAbsolutePaths: [fs.realpathSync(file)], }, mapUnicodeCharacters: true, }) ) ) ).filter((f) => f) as FileTypeInformation[]; }