1import { ExpoConfig } from '@expo/config-types';
2import { JSONObject } from '@expo/json-file';
3import chalk from 'chalk';
4import { boolish } from 'getenv';
5
6import { ExportedConfig, ExportedConfigWithProps, Mod, ModPlatform } from '../Plugin.types';
7import { PluginError } from '../utils/errors';
8
9const EXPO_DEBUG = boolish('EXPO_DEBUG', false);
10
11export type BaseModOptions = {
12  platform: ModPlatform;
13  mod: string;
14  isProvider?: boolean;
15  skipEmptyMod?: boolean;
16  saveToInternal?: boolean;
17  /**
18   * If the mod supports introspection, and avoids making any filesystem modifications during compilation.
19   * By enabling, this mod, and all of its descendants will be run in introspection mode.
20   * This should only be used for static files like JSON or XML, and not for application files that require regexes,
21   * or complex static files that require other files to be generated like Xcode `.pbxproj`.
22   */
23  isIntrospective?: boolean;
24};
25
26/**
27 * Plugin to intercept execution of a given `mod` with the given `action`.
28 * If an action was already set on the given `config` config for `mod`, then it
29 * will be provided to the `action` as `nextMod` when it's evaluated, otherwise
30 * `nextMod` will be an identity function.
31 *
32 * @param config exported config
33 * @param platform platform to target (ios or android)
34 * @param mod name of the platform function to intercept
35 * @param skipEmptyMod should skip running the action if there is no existing mod to intercept
36 * @param saveToInternal should save the results to `_internal.modResults`, only enable this when the results are pure JSON.
37 * @param isProvider should provide data up to the other mods.
38 * @param action method to run on the mod when the config is compiled
39 */
40export function withBaseMod<T>(
41  config: ExportedConfig,
42  {
43    platform,
44    mod,
45    action,
46    skipEmptyMod,
47    isProvider,
48    isIntrospective,
49    saveToInternal,
50  }: BaseModOptions & { action: Mod<T> }
51): ExportedConfig {
52  if (!config.mods) {
53    config.mods = {};
54  }
55  if (!config.mods[platform]) {
56    config.mods[platform] = {};
57  }
58
59  let interceptedMod: Mod<T> = (config.mods[platform] as Record<string, any>)[mod];
60
61  // No existing mod to intercept
62  if (!interceptedMod) {
63    if (skipEmptyMod) {
64      // Skip running the action
65      return config;
66    }
67    // Use a noop mod and continue
68    const noopMod: Mod<T> = (config) => config;
69    interceptedMod = noopMod;
70  }
71
72  // Create a stack trace for debugging ahead of time
73  let debugTrace: string = '';
74  // Use the possibly user defined value. Otherwise fallback to the env variable.
75  // We support the env variable because user mods won't have _internal defined in time.
76  const isDebug = config._internal?.isDebug ?? EXPO_DEBUG;
77  if (isDebug) {
78    // Get a stack trace via the Error API
79    const stack = new Error().stack;
80    // Format the stack trace to create the debug log
81    debugTrace = getDebugPluginStackFromStackTrace(stack);
82    const modStack = chalk.bold(`${platform}.${mod}`);
83
84    debugTrace = `${modStack}: ${debugTrace}`;
85  }
86
87  // Prevent adding multiple providers to a mod.
88  // Base mods that provide files ignore any incoming modResults and therefore shouldn't have provider mods as parents.
89  if (interceptedMod.isProvider) {
90    if (isProvider) {
91      throw new PluginError(
92        `Cannot set provider mod for "${platform}.${mod}" because another is already being used.`,
93        'CONFLICTING_PROVIDER'
94      );
95    } else {
96      throw new PluginError(
97        `Cannot add mod to "${platform}.${mod}" because the provider has already been added. Provider must be the last mod added.`,
98        'INVALID_MOD_ORDER'
99      );
100    }
101  }
102
103  async function interceptingMod({ modRequest, ...config }: ExportedConfigWithProps<T>) {
104    if (isDebug) {
105      // In debug mod, log the plugin stack in the order which they were invoked
106      console.log(debugTrace);
107    }
108    const results = await action({
109      ...config,
110      modRequest: { ...modRequest, nextMod: interceptedMod },
111    });
112
113    if (saveToInternal) {
114      saveToInternalObject(results, platform, mod, results.modResults as unknown as JSONObject);
115    }
116    return results;
117  }
118
119  // Ensure this base mod is registered as the provider.
120  interceptingMod.isProvider = isProvider;
121
122  if (isIntrospective) {
123    // Register the mode as idempotent so introspection doesn't remove it.
124    interceptingMod.isIntrospective = isIntrospective;
125  }
126
127  (config.mods[platform] as any)[mod] = interceptingMod;
128
129  return config;
130}
131
132function saveToInternalObject(
133  config: Pick<ExpoConfig, '_internal'>,
134  platformName: ModPlatform,
135  modName: string,
136  results: JSONObject
137) {
138  if (!config._internal) config._internal = {};
139  if (!config._internal.modResults) config._internal.modResults = {};
140  if (!config._internal.modResults[platformName]) config._internal.modResults[platformName] = {};
141  config._internal.modResults[platformName][modName] = results;
142}
143
144function getDebugPluginStackFromStackTrace(stacktrace?: string): string {
145  if (!stacktrace) {
146    return '';
147  }
148
149  const treeStackLines: string[] = [];
150  for (const line of stacktrace.split('\n')) {
151    const [first, second] = line.trim().split(' ');
152    if (first === 'at') {
153      treeStackLines.push(second);
154    }
155  }
156
157  const plugins = treeStackLines
158    .map((first) => {
159      // Match the first part of the stack trace against the plugin naming convention
160      // "with" followed by a capital letter.
161      return (
162        first?.match(/^(\bwith[A-Z].*?\b)/)?.[1]?.trim() ??
163        first?.match(/\.(\bwith[A-Z].*?\b)/)?.[1]?.trim() ??
164        null
165      );
166    })
167    .filter(Boolean)
168    .filter((plugin) => {
169      // redundant as all debug logs are captured in withBaseMod
170      return !['withMod', 'withBaseMod', 'withExtendedMod'].includes(plugin!);
171    });
172
173  const commonPlugins = ['withPlugins', 'withRunOnce', 'withStaticPlugin'];
174
175  return (
176    (plugins as string[])
177      .reverse()
178      .map((pluginName, index) => {
179        // Base mods indicate a logical section.
180        if (pluginName.includes('BaseMod')) {
181          pluginName = chalk.bold(pluginName);
182        }
183        // highlight dangerous mods
184        if (pluginName.toLowerCase().includes('dangerous')) {
185          pluginName = chalk.red(pluginName);
186        }
187
188        if (index === 0) {
189          return chalk.blue(pluginName);
190        } else if (commonPlugins.includes(pluginName)) {
191          // Common mod names often clutter up the logs, dim them out
192          return chalk.dim(pluginName);
193        }
194        return pluginName;
195      })
196      // Join the results:
197      // withAndroidExpoPlugins ➜ withPlugins ➜ withIcons ➜ withDangerousMod ➜ withMod
198      .join(' ➜ ')
199  );
200}
201
202/**
203 * Plugin to extend a mod function in the plugins config.
204 *
205 * @param config exported config
206 * @param platform platform to target (ios or android)
207 * @param mod name of the platform function to extend
208 * @param action method to run on the mod when the config is compiled
209 */
210export function withMod<T>(
211  config: ExportedConfig,
212  {
213    platform,
214    mod,
215    action,
216  }: {
217    platform: ModPlatform;
218    mod: string;
219    action: Mod<T>;
220  }
221): ExportedConfig {
222  return withBaseMod(config, {
223    platform,
224    mod,
225    isProvider: false,
226    async action({ modRequest: { nextMod, ...modRequest }, modResults, ...config }) {
227      const results = await action({ modRequest, modResults: modResults as T, ...config });
228      return nextMod!(results as any);
229    },
230  });
231}
232