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