xref: /expo/packages/@expo/cli/src/utils/resolveArgs.ts (revision 9d7b0c19)
1import arg, { Spec } from 'arg';
2
3import { replaceValue } from './array';
4import { CommandError } from './errors';
5
6/** Split up arguments that are formatted like `--foo=bar` or `-f="bar"` to `['--foo', 'bar']` */
7function splitArgs(args: string[]): string[] {
8  const result: string[] = [];
9
10  for (const arg of args) {
11    if (arg.startsWith('-')) {
12      const [key, ...props] = arg.split('=');
13      result.push(key);
14      if (props.length) {
15        result.push(props.join('='));
16      }
17    } else {
18      result.push(arg);
19    }
20  }
21
22  return result;
23}
24
25/**
26 * Enables the resolution of arguments that can either be a string or a boolean.
27 *
28 * @param args arguments that were passed to the command.
29 * @param rawMap raw map of arguments that are passed to the command.
30 * @param extraArgs extra arguments and aliases that should be resolved as string or boolean.
31 * @returns parsed arguments and project root.
32 */
33export async function resolveStringOrBooleanArgsAsync(
34  args: string[],
35  rawMap: arg.Spec,
36  extraArgs: arg.Spec
37) {
38  args = splitArgs(args);
39
40  // Assert any missing arguments
41  assertUnknownArgs(
42    {
43      ...rawMap,
44      ...extraArgs,
45    },
46    args
47  );
48
49  // Collapse aliases into fully qualified arguments.
50  args = collapseAliases(extraArgs, args);
51
52  // Resolve all of the string or boolean arguments and the project root.
53  return _resolveStringOrBooleanArgs({ ...rawMap, ...extraArgs }, args);
54}
55
56/**
57 * Enables the resolution of boolean arguments that can be formatted like `--foo=true` or `--foo false`
58 *
59 * @param args arguments that were passed to the command.
60 * @param rawMap raw map of arguments that are passed to the command.
61 * @param extraArgs extra arguments and aliases that should be resolved as string or boolean.
62 * @returns parsed arguments and project root.
63 */
64export async function resolveCustomBooleanArgsAsync(
65  args: string[],
66  rawMap: arg.Spec,
67  extraArgs: arg.Spec
68) {
69  const results = await resolveStringOrBooleanArgsAsync(args, rawMap, extraArgs);
70
71  return {
72    ...results,
73    args: Object.fromEntries(
74      Object.entries(results.args).map(([key, value]) => {
75        if (extraArgs[key]) {
76          if (typeof value === 'string') {
77            if (!['true', 'false'].includes(value)) {
78              throw new CommandError(
79                'BAD_ARGS',
80                `Invalid boolean argument: ${key}=${value}. Expected one of: true, false`
81              );
82            }
83            return [key, value === 'true'];
84          }
85        }
86        return [key, value];
87      })
88    ),
89  };
90}
91
92export function _resolveStringOrBooleanArgs(arg: Spec, args: string[]) {
93  // Default project root, if a custom one is defined then it will overwrite this.
94  let projectRoot: string = '.';
95  // The resolved arguments.
96  const settings: Record<string, string | boolean | undefined> = {};
97
98  // Create a list of possible arguments, this will filter out aliases.
99  const possibleArgs = Object.entries(arg)
100    .filter(([, value]) => typeof value !== 'string')
101    .map(([key]) => key);
102
103  // Loop over arguments in reverse order so we can resolve if a value belongs to a flag.
104  for (let i = args.length - 1; i > -1; i--) {
105    const value = args[i];
106    // At this point we should have converted all aliases to fully qualified arguments.
107    if (value.startsWith('--')) {
108      // If we ever find an argument then it must be a boolean because we are checking in reverse
109      // and removing arguments from the array if we find a string.
110      settings[value] = true;
111    } else {
112      // Get the previous argument in the array.
113      const nextValue = i > 0 ? args[i - 1] : null;
114      if (nextValue && possibleArgs.includes(nextValue)) {
115        settings[nextValue] = value;
116        i--;
117      } else if (
118        // 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)
119        // then it must be the project root.
120        i ===
121        args.length - 1
122      ) {
123        projectRoot = value;
124      } else {
125        // This will asserts if two strings are passed in a row and not at the end of the line.
126        throw new CommandError('BAD_ARGS', `Unknown argument: ${value}`);
127      }
128    }
129  }
130
131  return {
132    args: settings,
133    projectRoot,
134  };
135}
136
137/** Convert all aliases to fully qualified flag names. */
138export function collapseAliases(arg: Spec, args: string[]): string[] {
139  const aliasMap = getAliasTuples(arg);
140
141  for (const [arg, alias] of aliasMap) {
142    args = replaceValue(args, arg, alias);
143  }
144
145  // Assert if there are duplicate flags after we collapse the aliases.
146  assertDuplicateArgs(args, aliasMap);
147  return args;
148}
149
150/** Assert that the spec has unknown arguments. */
151export function assertUnknownArgs(arg: Spec, args: string[]) {
152  const allowedArgs = Object.keys(arg);
153  const unknownArgs = args.filter((arg) => !allowedArgs.includes(arg) && arg.startsWith('-'));
154  if (unknownArgs.length > 0) {
155    throw new CommandError(`Unknown arguments: ${unknownArgs.join(', ')}`);
156  }
157}
158
159function getAliasTuples(arg: Spec): [string, string][] {
160  return Object.entries(arg).filter(([, value]) => typeof value === 'string') as [string, string][];
161}
162
163/** Asserts that a duplicate flag has been used, this naively throws without knowing if an alias or flag were used as the duplicate. */
164export function assertDuplicateArgs(args: string[], argNameAliasTuple: [string, string][]) {
165  for (const [argName, argNameAlias] of argNameAliasTuple) {
166    if (args.filter((a) => [argName, argNameAlias].includes(a)).length > 1) {
167      throw new CommandError(
168        'BAD_ARGS',
169        `Can only provide one instance of ${argName} or ${argNameAlias}`
170      );
171    }
172  }
173}
174