xref: /expo/packages/@expo/cli/src/utils/resolveArgs.ts (revision b6b91c50)
13d6e487dSEvan Baconimport arg, { Spec } from 'arg';
23d6e487dSEvan Bacon
33d6e487dSEvan Baconimport { replaceValue } from './array';
43d6e487dSEvan Baconimport { CommandError } from './errors';
53d6e487dSEvan Bacon
6*b6b91c50SEvan Bacon/** Split up arguments that are formatted like `--foo=bar` or `-f="bar"` to `['--foo', 'bar']` */
7*b6b91c50SEvan Baconfunction splitArgs(args: string[]): string[] {
8*b6b91c50SEvan Bacon  const result: string[] = [];
9*b6b91c50SEvan Bacon
10*b6b91c50SEvan Bacon  for (const arg of args) {
11*b6b91c50SEvan Bacon    if (arg.startsWith('-')) {
12*b6b91c50SEvan Bacon      const [key, ...props] = arg.split('=');
13*b6b91c50SEvan Bacon      result.push(key);
14*b6b91c50SEvan Bacon      if (props.length) {
15*b6b91c50SEvan Bacon        result.push(props.join('='));
16*b6b91c50SEvan Bacon      }
17*b6b91c50SEvan Bacon    } else {
18*b6b91c50SEvan Bacon      result.push(arg);
19*b6b91c50SEvan Bacon    }
20*b6b91c50SEvan Bacon  }
21*b6b91c50SEvan Bacon
22*b6b91c50SEvan Bacon  return result;
23*b6b91c50SEvan Bacon}
24*b6b91c50SEvan Bacon
253d6e487dSEvan Bacon/**
263d6e487dSEvan Bacon * Enables the resolution of arguments that can either be a string or a boolean.
273d6e487dSEvan Bacon *
283d6e487dSEvan Bacon * @param args arguments that were passed to the command.
293d6e487dSEvan Bacon * @param rawMap raw map of arguments that are passed to the command.
303d6e487dSEvan Bacon * @param extraArgs extra arguments and aliases that should be resolved as string or boolean.
313d6e487dSEvan Bacon * @returns parsed arguments and project root.
323d6e487dSEvan Bacon */
333d6e487dSEvan Baconexport async function resolveStringOrBooleanArgsAsync(
343d6e487dSEvan Bacon  args: string[],
353d6e487dSEvan Bacon  rawMap: arg.Spec,
363d6e487dSEvan Bacon  extraArgs: arg.Spec
373d6e487dSEvan Bacon) {
38*b6b91c50SEvan Bacon  args = splitArgs(args);
39*b6b91c50SEvan Bacon
403d6e487dSEvan Bacon  // Assert any missing arguments
413d6e487dSEvan Bacon  assertUnknownArgs(
423d6e487dSEvan Bacon    {
433d6e487dSEvan Bacon      ...rawMap,
443d6e487dSEvan Bacon      ...extraArgs,
453d6e487dSEvan Bacon    },
463d6e487dSEvan Bacon    args
473d6e487dSEvan Bacon  );
483d6e487dSEvan Bacon
493d6e487dSEvan Bacon  // Collapse aliases into fully qualified arguments.
503d6e487dSEvan Bacon  args = collapseAliases(extraArgs, args);
513d6e487dSEvan Bacon
523d6e487dSEvan Bacon  // Resolve all of the string or boolean arguments and the project root.
53c4ef02aeSEvan Bacon  return _resolveStringOrBooleanArgs({ ...rawMap, ...extraArgs }, args);
543d6e487dSEvan Bacon}
553d6e487dSEvan Bacon
56*b6b91c50SEvan Bacon/**
57*b6b91c50SEvan Bacon * Enables the resolution of boolean arguments that can be formatted like `--foo=true` or `--foo false`
58*b6b91c50SEvan Bacon *
59*b6b91c50SEvan Bacon * @param args arguments that were passed to the command.
60*b6b91c50SEvan Bacon * @param rawMap raw map of arguments that are passed to the command.
61*b6b91c50SEvan Bacon * @param extraArgs extra arguments and aliases that should be resolved as string or boolean.
62*b6b91c50SEvan Bacon * @returns parsed arguments and project root.
63*b6b91c50SEvan Bacon */
64*b6b91c50SEvan Baconexport async function resolveCustomBooleanArgsAsync(
65*b6b91c50SEvan Bacon  args: string[],
66*b6b91c50SEvan Bacon  rawMap: arg.Spec,
67*b6b91c50SEvan Bacon  extraArgs: arg.Spec
68*b6b91c50SEvan Bacon) {
69*b6b91c50SEvan Bacon  const results = await resolveStringOrBooleanArgsAsync(args, rawMap, extraArgs);
70*b6b91c50SEvan Bacon
71*b6b91c50SEvan Bacon  return {
72*b6b91c50SEvan Bacon    ...results,
73*b6b91c50SEvan Bacon    args: Object.fromEntries(
74*b6b91c50SEvan Bacon      Object.entries(results.args).map(([key, value]) => {
75*b6b91c50SEvan Bacon        if (extraArgs[key]) {
76*b6b91c50SEvan Bacon          if (typeof value === 'string') {
77*b6b91c50SEvan Bacon            if (!['true', 'false'].includes(value)) {
78*b6b91c50SEvan Bacon              throw new CommandError(
79*b6b91c50SEvan Bacon                'BAD_ARGS',
80*b6b91c50SEvan Bacon                `Invalid boolean argument: ${key}=${value}. Expected one of: true, false`
81*b6b91c50SEvan Bacon              );
82*b6b91c50SEvan Bacon            }
83*b6b91c50SEvan Bacon            return [key, value === 'true'];
84*b6b91c50SEvan Bacon          }
85*b6b91c50SEvan Bacon        }
86*b6b91c50SEvan Bacon        return [key, value];
87*b6b91c50SEvan Bacon      })
88*b6b91c50SEvan Bacon    ),
89*b6b91c50SEvan Bacon  };
90*b6b91c50SEvan Bacon}
91*b6b91c50SEvan Bacon
923d6e487dSEvan Baconexport function _resolveStringOrBooleanArgs(arg: Spec, args: string[]) {
933d6e487dSEvan Bacon  // Default project root, if a custom one is defined then it will overwrite this.
943d6e487dSEvan Bacon  let projectRoot: string = '.';
953d6e487dSEvan Bacon  // The resolved arguments.
963d6e487dSEvan Bacon  const settings: Record<string, string | boolean | undefined> = {};
973d6e487dSEvan Bacon
983d6e487dSEvan Bacon  // Create a list of possible arguments, this will filter out aliases.
993d6e487dSEvan Bacon  const possibleArgs = Object.entries(arg)
1003d6e487dSEvan Bacon    .filter(([, value]) => typeof value !== 'string')
1013d6e487dSEvan Bacon    .map(([key]) => key);
1023d6e487dSEvan Bacon
1033d6e487dSEvan Bacon  // Loop over arguments in reverse order so we can resolve if a value belongs to a flag.
1043d6e487dSEvan Bacon  for (let i = args.length - 1; i > -1; i--) {
1053d6e487dSEvan Bacon    const value = args[i];
1063d6e487dSEvan Bacon    // At this point we should have converted all aliases to fully qualified arguments.
1073d6e487dSEvan Bacon    if (value.startsWith('--')) {
1083d6e487dSEvan Bacon      // If we ever find an argument then it must be a boolean because we are checking in reverse
1093d6e487dSEvan Bacon      // and removing arguments from the array if we find a string.
1103d6e487dSEvan Bacon      settings[value] = true;
1113d6e487dSEvan Bacon    } else {
1123d6e487dSEvan Bacon      // Get the previous argument in the array.
1133d6e487dSEvan Bacon      const nextValue = i > 0 ? args[i - 1] : null;
1143d6e487dSEvan Bacon      if (nextValue && possibleArgs.includes(nextValue)) {
1153d6e487dSEvan Bacon        settings[nextValue] = value;
1163d6e487dSEvan Bacon        i--;
1173d6e487dSEvan Bacon      } else if (
1183d6e487dSEvan Bacon        // 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)
1193d6e487dSEvan Bacon        // then it must be the project root.
1203d6e487dSEvan Bacon        i ===
1213d6e487dSEvan Bacon        args.length - 1
1223d6e487dSEvan Bacon      ) {
1233d6e487dSEvan Bacon        projectRoot = value;
1243d6e487dSEvan Bacon      } else {
1253d6e487dSEvan Bacon        // This will asserts if two strings are passed in a row and not at the end of the line.
1263d6e487dSEvan Bacon        throw new CommandError('BAD_ARGS', `Unknown argument: ${value}`);
1273d6e487dSEvan Bacon      }
1283d6e487dSEvan Bacon    }
1293d6e487dSEvan Bacon  }
1303d6e487dSEvan Bacon
1313d6e487dSEvan Bacon  return {
1323d6e487dSEvan Bacon    args: settings,
1333d6e487dSEvan Bacon    projectRoot,
1343d6e487dSEvan Bacon  };
1353d6e487dSEvan Bacon}
1363d6e487dSEvan Bacon
1373d6e487dSEvan Bacon/** Convert all aliases to fully qualified flag names. */
1383d6e487dSEvan Baconexport function collapseAliases(arg: Spec, args: string[]): string[] {
1393d6e487dSEvan Bacon  const aliasMap = getAliasTuples(arg);
1403d6e487dSEvan Bacon
1413d6e487dSEvan Bacon  for (const [arg, alias] of aliasMap) {
1423d6e487dSEvan Bacon    args = replaceValue(args, arg, alias);
1433d6e487dSEvan Bacon  }
1443d6e487dSEvan Bacon
1453d6e487dSEvan Bacon  // Assert if there are duplicate flags after we collapse the aliases.
1463d6e487dSEvan Bacon  assertDuplicateArgs(args, aliasMap);
1473d6e487dSEvan Bacon  return args;
1483d6e487dSEvan Bacon}
1493d6e487dSEvan Bacon
1503d6e487dSEvan Bacon/** Assert that the spec has unknown arguments. */
1513d6e487dSEvan Baconexport function assertUnknownArgs(arg: Spec, args: string[]) {
1523d6e487dSEvan Bacon  const allowedArgs = Object.keys(arg);
1533d6e487dSEvan Bacon  const unknownArgs = args.filter((arg) => !allowedArgs.includes(arg) && arg.startsWith('-'));
1543d6e487dSEvan Bacon  if (unknownArgs.length > 0) {
1553d6e487dSEvan Bacon    throw new CommandError(`Unknown arguments: ${unknownArgs.join(', ')}`);
1563d6e487dSEvan Bacon  }
1573d6e487dSEvan Bacon}
1583d6e487dSEvan Bacon
1593d6e487dSEvan Baconfunction getAliasTuples(arg: Spec): [string, string][] {
1603d6e487dSEvan Bacon  return Object.entries(arg).filter(([, value]) => typeof value === 'string') as [string, string][];
1613d6e487dSEvan Bacon}
1623d6e487dSEvan Bacon
1633d6e487dSEvan Bacon/** Asserts that a duplicate flag has been used, this naively throws without knowing if an alias or flag were used as the duplicate. */
1643d6e487dSEvan Baconexport function assertDuplicateArgs(args: string[], argNameAliasTuple: [string, string][]) {
1653d6e487dSEvan Bacon  for (const [argName, argNameAlias] of argNameAliasTuple) {
1663d6e487dSEvan Bacon    if (args.filter((a) => [argName, argNameAlias].includes(a)).length > 1) {
1673d6e487dSEvan Bacon      throw new CommandError(
1683d6e487dSEvan Bacon        'BAD_ARGS',
1693d6e487dSEvan Bacon        `Can only provide one instance of ${argName} or ${argNameAlias}`
1703d6e487dSEvan Bacon      );
1713d6e487dSEvan Bacon    }
1723d6e487dSEvan Bacon  }
1733d6e487dSEvan Bacon}
174