xref: /expo/packages/@expo/cli/src/utils/resolveArgs.ts (revision bfcd021e)
1import arg, { Spec } from 'arg';
2
3import { replaceValue } from './array';
4import { CommandError } from './errors';
5
6/**
7 * Enables the resolution of arguments that can either be a string or a boolean.
8 *
9 * @param args arguments that were passed to the command.
10 * @param rawMap raw map of arguments that are passed to the command.
11 * @param extraArgs extra arguments and aliases that should be resolved as string or boolean.
12 * @returns parsed arguments and project root.
13 */
14export async function resolveStringOrBooleanArgsAsync(
15  args: string[],
16  rawMap: arg.Spec,
17  extraArgs: arg.Spec
18) {
19  // Assert any missing arguments
20  assertUnknownArgs(
21    {
22      ...rawMap,
23      ...extraArgs,
24    },
25    args
26  );
27
28  // Collapse aliases into fully qualified arguments.
29  args = collapseAliases(extraArgs, args);
30
31  // Resolve all of the string or boolean arguments and the project root.
32  return _resolveStringOrBooleanArgs({ ...rawMap, ...extraArgs }, args);
33}
34
35export function _resolveStringOrBooleanArgs(arg: Spec, args: string[]) {
36  // Default project root, if a custom one is defined then it will overwrite this.
37  let projectRoot: string = '.';
38  // The resolved arguments.
39  const settings: Record<string, string | boolean | undefined> = {};
40
41  // Create a list of possible arguments, this will filter out aliases.
42  const possibleArgs = Object.entries(arg)
43    .filter(([, value]) => typeof value !== 'string')
44    .map(([key]) => key);
45
46  // Loop over arguments in reverse order so we can resolve if a value belongs to a flag.
47  for (let i = args.length - 1; i > -1; i--) {
48    const value = args[i];
49    // At this point we should have converted all aliases to fully qualified arguments.
50    if (value.startsWith('--')) {
51      // If we ever find an argument then it must be a boolean because we are checking in reverse
52      // and removing arguments from the array if we find a string.
53      settings[value] = true;
54    } else {
55      // Get the previous argument in the array.
56      const nextValue = i > 0 ? args[i - 1] : null;
57      if (nextValue && possibleArgs.includes(nextValue)) {
58        settings[nextValue] = value;
59        i--;
60      } else if (
61        // If the last value is not a flag and it doesn't have a recognized flag before it (instead having a string value or nothing)
62        // then it must be the project root.
63        i ===
64        args.length - 1
65      ) {
66        projectRoot = value;
67      } else {
68        // This will asserts if two strings are passed in a row and not at the end of the line.
69        throw new CommandError('BAD_ARGS', `Unknown argument: ${value}`);
70      }
71    }
72  }
73
74  return {
75    args: settings,
76    projectRoot,
77  };
78}
79
80/** Convert all aliases to fully qualified flag names. */
81export function collapseAliases(arg: Spec, args: string[]): string[] {
82  const aliasMap = getAliasTuples(arg);
83
84  for (const [arg, alias] of aliasMap) {
85    args = replaceValue(args, arg, alias);
86  }
87
88  // Assert if there are duplicate flags after we collapse the aliases.
89  assertDuplicateArgs(args, aliasMap);
90  return args;
91}
92
93/** Assert that the spec has unknown arguments. */
94export function assertUnknownArgs(arg: Spec, args: string[]) {
95  const allowedArgs = Object.keys(arg);
96  const unknownArgs = args.filter((arg) => !allowedArgs.includes(arg) && arg.startsWith('-'));
97  if (unknownArgs.length > 0) {
98    throw new CommandError(`Unknown arguments: ${unknownArgs.join(', ')}`);
99  }
100}
101
102function getAliasTuples(arg: Spec): [string, string][] {
103  return Object.entries(arg).filter(([, value]) => typeof value === 'string') as [string, string][];
104}
105
106/** Asserts that a duplicate flag has been used, this naively throws without knowing if an alias or flag were used as the duplicate. */
107export function assertDuplicateArgs(args: string[], argNameAliasTuple: [string, string][]) {
108  for (const [argName, argNameAlias] of argNameAliasTuple) {
109    if (args.filter((a) => [argName, argNameAlias].includes(a)).length > 1) {
110      throw new CommandError(
111        'BAD_ARGS',
112        `Can only provide one instance of ${argName} or ${argNameAlias}`
113      );
114    }
115  }
116}
117