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