1// Copyright 2021-present 650 Industries (Expo). All rights reserved.
2
3import chalk from 'chalk';
4import Debug from 'debug';
5import type { BabelTransformer, BabelTransformerArgs } from 'metro-babel-transformer';
6import resolveFrom from 'resolve-from';
7
8import { generateFunctionMap } from './generateFunctionMap';
9import { getBabelConfig } from './getBabelConfig';
10
11const debug = Debug('expo:metro:exotic-babel-transformer');
12
13let babelCore: typeof import('@babel/core') | undefined;
14
15function getBabelCoreFromProject(projectRoot: string) {
16  if (babelCore) return babelCore;
17  babelCore = require(resolveFrom(projectRoot, '@babel/core'));
18  return babelCore!;
19}
20
21let babelParser: typeof import('@babel/parser') | undefined;
22
23function getBabelParserFromProject(projectRoot: string) {
24  if (babelParser) return babelParser;
25  babelParser = require(resolveFrom(projectRoot, '@babel/parser'));
26  return babelParser!;
27}
28
29function sucrase(
30  args: BabelTransformerArgs,
31  {
32    transforms,
33  }: {
34    transforms: string[];
35  }
36): Partial<ReturnType<BabelTransformer['transform']>> {
37  const {
38    src,
39    filename,
40    options: { dev },
41  } = args;
42  const { transform } = require('sucrase');
43
44  const results = transform(src, {
45    filePath: filename,
46    production: !dev,
47    transforms,
48  });
49
50  return {
51    code: results.code,
52    functionMap: null,
53  };
54}
55
56const getExpensiveSucraseTransforms = (filename: string) => [
57  'jsx',
58  'imports',
59  /\.tsx?$/.test(filename) ? 'typescript' : 'flow',
60];
61
62function parseAst(projectRoot: string, sourceCode: string) {
63  const babylon = getBabelParserFromProject(projectRoot);
64
65  return babylon.parse(sourceCode, {
66    sourceType: 'unambiguous',
67  });
68}
69
70export type Rule = {
71  warn?: boolean;
72  type?: 'module' | 'app';
73  name?: string;
74  test: ((args: BabelTransformerArgs) => boolean) | RegExp;
75  transform: BabelTransformer['transform'];
76};
77
78/** Create a transformer that emulates Webpack's loader system. */
79export function createMultiRuleTransformer({
80  getRuleType,
81  rules,
82}: {
83  getRuleType: (args: BabelTransformerArgs) => string;
84  rules: Rule[];
85}): BabelTransformer['transform'] {
86  // const warnings: string[] = [];
87  return function transform(args: BabelTransformerArgs) {
88    const { filename, options } = args;
89    const OLD_BABEL_ENV = process.env.BABEL_ENV;
90    process.env.BABEL_ENV = options?.dev ? 'development' : process.env.BABEL_ENV || 'production';
91
92    try {
93      const ruleType = getRuleType(args);
94
95      for (const rule of rules) {
96        // optimization for checking node modules
97        if (rule.type && rule.type !== ruleType) {
98          continue;
99        }
100
101        const isMatched =
102          typeof rule.test === 'function' ? rule.test(args) : rule.test.test(args.filename);
103        if (isMatched) {
104          const results = rule.transform(args);
105          // @ts-ignore: Add extra property for testing
106          results._ruleName = rule.name;
107          // Perform a basic parse if none exists, this enables us to control the output, but only if it changed.
108          if (results.code && !results.ast) {
109            // Parse AST with babel otherwise Metro transformer will throw away the returned results.
110            results.ast = parseAst(options?.projectRoot, results.code);
111          }
112
113          // TODO: Suboptimal warnings
114          // if (rule.warn) {
115          //   const matchName =
116          //     filename.match(/node_modules\/((:?@[\w\d-]+\/[\w\d-]+)|(:?[\w\d-]+))\/?/)?.[1] ??
117          //     filename;
118          //   if (matchName && !warnings.includes(matchName)) {
119          //     warnings.push(matchName);
120          //     console.warn(chalk.yellow.bold`warn `, matchName);
121          //     console.warn(
122          //       chalk.yellow`untranspiled module is potentially causing bundler slowdown, using modules that support commonjs will make your dev server much faster.`
123          //     );
124          //   }
125          // }
126
127          return results;
128        }
129      }
130      throw new Error('no loader rule to handle file: ' + filename);
131    } finally {
132      if (OLD_BABEL_ENV) {
133        process.env.BABEL_ENV = OLD_BABEL_ENV;
134      }
135    }
136  };
137}
138
139export const loaders: Record<string, (args: BabelTransformerArgs) => any> = {
140  // Perform the standard, and most expensive transpilation sequence.
141  app(args) {
142    debug('app:', args.filename);
143
144    const { filename, options, src, plugins } = args;
145    const babelConfig = {
146      // ES modules require sourceType='module' but OSS may not always want that
147      sourceType: 'unambiguous',
148      ...getBabelConfig(filename, options, plugins),
149      // Variables that are exposed to the user's babel preset.
150      caller: {
151        name: 'metro',
152
153        platform: options.platform,
154      },
155      ast: true,
156    };
157
158    // Surface a warning function so babel linters can be used.
159    Object.defineProperty(babelConfig.caller, 'onWarning', {
160      enumerable: false,
161      writable: false,
162      value: (babelConfig.caller.onWarning = function (msg: any) {
163        // Format the file path first so users know where the warning came from.
164        console.warn(chalk.bold.yellow`warn ` + args.filename);
165        console.warn(msg);
166      }),
167    });
168
169    const { parseSync, transformFromAstSync } = getBabelCoreFromProject(options.projectRoot);
170    const sourceAst = parseSync(src, babelConfig);
171
172    // Should never happen.
173    if (!sourceAst) return { ast: null };
174
175    const result = transformFromAstSync(sourceAst, src, babelConfig);
176
177    // TODO: Disable by default
178    const functionMap = generateFunctionMap(options.projectRoot, sourceAst, { filename });
179    // The result from `transformFromAstSync` can be null (if the file is ignored)
180    if (!result) {
181      return { ast: null, functionMap };
182    }
183
184    return { ast: result.ast, functionMap };
185  },
186
187  // Transpile react-native with sucrase.
188  reactNativeModule(args) {
189    debug('rn:', args.filename);
190    return sucrase(args, {
191      transforms: ['jsx', 'flow', 'imports'],
192    });
193  },
194
195  // Transpile expo modules with sucrase.
196  expoModule(args) {
197    debug('expo:', args.filename);
198    // TODO: Fix all expo packages
199    return sucrase(args, {
200      transforms: [
201        'imports',
202        // TODO: fix expo-processing, expo/vector-icons
203        /(expo-processing|expo\/vector-icons)/.test(args.filename) && 'jsx',
204        // TODO: fix expo-asset-utils
205        /(expo-asset-utils)/.test(args.filename) && 'flow',
206      ].filter(Boolean) as string[],
207    });
208  },
209
210  // Transpile known community modules with the most expensive sucrase
211  untranspiledModule(args) {
212    debug('known issues:', args.filename);
213    return sucrase(args, {
214      transforms: getExpensiveSucraseTransforms(args.filename),
215    });
216  },
217
218  // Pass all modules through without transpiling them.
219  passthroughModule(args) {
220    const { filename, options, src } = args;
221    debug('passthrough:', filename);
222
223    // Perform a basic ast parse, this doesn't matter since the worker will parse and ignore anyways.
224    const ast = parseAst(options.projectRoot, src);
225
226    // TODO: Disable by default
227    const functionMap = generateFunctionMap(options.projectRoot, ast, { filename });
228
229    return {
230      code: src,
231      functionMap,
232      ast,
233    };
234  },
235};
236