UNPKG

@jil/args

Version:

A convention based argument parsing and formatting library, with strict validation checks

270 lines (225 loc) 8.44 kB
import {Checker} from './Checker'; import {DEFAULT_STRING_VALUE} from './constants'; import {debug} from './debug'; import {ArgsError} from './errors'; import {Scope} from './Scope'; import { AliasMap, ArgList, Arguments, Argv, MapParamType, OptionMap, ParamConfig, ParserOptions, PrimitiveType, ShortOptionName, UnknownOptionMap, } from './types'; import {castValue} from './utils/castValue'; import {createScope} from './utils/createScope'; import {expandShortOption} from './utils/expandShortOption'; import {formatValue} from './utils/formatValue'; import {getDefaultValue} from './utils/getDefaultValue'; import {isCommand} from './utils/isCommand'; import {isLongOption} from './utils/isLongOption'; import {isOptionLike} from './utils/isOptionLike'; import {isShortOption} from './utils/isShortOption'; import {isShortOptionGroup} from './utils/isShortOptionGroup'; import {mapParserOptions} from './utils/mapParserOptions'; import {processShortOptionGroup} from './utils/processShortOptionGroup'; // TERMINOLOGY // command line - The entire line that encompasses the following parts. // arg - Each type of argument (or part) passed on the command line, separated by a space. // command - An optional "command" being ran that allows for branching functionality. // Sub-commands are separated with ":". // option - An optional argument that requires a value(s). Starts with "--" (long) or "-" (short). // flag - A specialized option that only supports booleans. Can be toggled on an off (default). // param - An optional or required argument, that is not an option or option value, // Supports any raw value, and enforces a defined order. // rest - All remaining arguments that appear after a stand alone "--". // Usually passed to subsequent scripts. // scope - Argument currently being parsed. // FEATURES // Short name - A short name (single character) for an existing option or flag: --verbose, -v // Option grouping - When multiple short options are passed under a single option: -abc // Inline values - Option values that are immediately set using an equals sign: --foo=bar // Group count - Increment a number each time a short option is found in a group: -vvv // Arity count - Required number of argument values to consume for multiples. // Choices - List of valid values to choose from. Errors otherwise. /** * Parse a list of command line arguments (typically from `process.argv`) into an arguments * object. Will extract commands, options, flags, and params based on the defined parser options. */ export function parse<O extends object = {}, P extends PrimitiveType[] = ArgList>( argv: Argv, parserOptions: ParserOptions<O, P>, ): Arguments<O, P> { const { commands: commandConfigs = [], loose: looseMode = false, options: optionConfigs, params: paramConfigs = [], unknown: allowUnknown = false, variadic: allowVariadic = true, } = parserOptions; const checker = new Checker(optionConfigs); const options: OptionMap = {}; const params: PrimitiveType[] = []; const rest: ArgList = []; const unknown: UnknownOptionMap = {}; const mapping: AliasMap = {}; let command = ''; let currentScope: Scope | null = null; debug('Parsing arguments: %s', argv.join(' ')); function commitScope() { if (!currentScope) { return; } const {name, value, finalValue} = currentScope; // Support loose mode if (looseMode) { if (value === undefined) { options[name] = !currentScope.negated; } else { options[name] = finalValue; } // Set an unknown value } else if (currentScope.unknown) { if (allowUnknown) { unknown[name] = value === undefined ? DEFAULT_STRING_VALUE : String(finalValue); } // Set and cast value if defined } else if (value !== undefined) { options[name] = finalValue; } currentScope = null; } // Run validations and map defaults checker.validateParamOrder(paramConfigs); mapParserOptions(parserOptions, options, params, { onCommand(cmd) { checker.validateCommandFormat(cmd); }, onOption(config, value, name) { const {short} = config; if (short) { checker.validateUniqueShortName(name, short, mapping); mapping[short] = name; } options[name] = getDefaultValue(config); checker.validateDefaultValue(name, options[name], config); checker.validateNumberCount(name, config); }, onParam(config) { checker.validateRequiredParamNoDefault(config); }, }); // Process each argument for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; checker.arg = arg; checker.argIndex = i; // Rest arguments found, extract remaining and exit if (arg === '--') { rest.push(...argv.slice(i + 1)); break; } try { // Options if (isOptionLike(arg)) { let optionName = arg; let inlineValue; // Commit previous scope commitScope(); // Extract option and inline value if (optionName.includes('=')) { [optionName, inlineValue] = optionName.split('=', 2); } // Short option group "-frl" if (isShortOptionGroup(optionName)) { checker.checkNoInlineValue(inlineValue); processShortOptionGroup(optionName.slice(1), optionConfigs, options, mapping, looseMode); continue; // Short option "-f" } else if (isShortOption(optionName)) { optionName = expandShortOption(optionName.slice(1) as ShortOptionName, mapping, looseMode); // Long option "--foo" } else if (isLongOption(optionName)) { optionName = optionName.slice(2); } // Parse and create next scope const scope = createScope(optionName, optionConfigs, options); // Unknown option found, handle accordingly if (scope.unknown && !allowUnknown && !looseMode) { checker.checkUnknownOption(arg); // Flag found, so set value immediately and discard scope } else if (scope.flag) { options[scope.name] = !scope.negated; checker.checkNoInlineValue(inlineValue); // Otherwise keep scope open, to capture next value } else { currentScope = scope; // Update scope value if an inline value exists if (inlineValue !== undefined) { currentScope.captureValue(inlineValue, commitScope); } } // Option values } else if (currentScope) { currentScope.captureValue(arg, commitScope); // Commands } else if (isCommand(arg, commandConfigs)) { checker.checkCommandOrder(arg, command, params.length); if (!command) { command = arg; } // Params } else if (paramConfigs[params.length]) { const config = paramConfigs[params.length] as ParamConfig; params.push(formatValue(castValue(arg, config.type), config.format) as PrimitiveType); } else if (allowVariadic) { params.push(arg); } else { throw new ArgsError('PARAM_UNKNOWN', [arg]); } } catch (error: unknown) { currentScope = null; checker.logFailure((error as Error).message); } } // Commit final scope commitScope(); // Fill missing params for (let i = params.length; i < paramConfigs.length; i += 1) { const config = paramConfigs[i]; if (config.required) { break; } params.push(getDefaultValue(config) as PrimitiveType); } // Run final checks mapParserOptions(parserOptions, options, params, { onOption(config, value, name) { checker.validateParsedOption(name, config, value); checker.validateArityIsMet(name, config, value); checker.validateChoiceIsMet(name, config, value); // Since default values avoid scope, // they are not cast. Do it manually after parsing. if (value === getDefaultValue(config)) { options[name] = castValue(value, config.type, config.multiple); } }, onParam(config, value) { checker.validateParsedParam(config, value); }, }); return { command: command === '' ? [] : command.split(':'), errors: [...checker.parseErrors, ...checker.validationErrors], options: options as O, params: params as MapParamType<P>, rest, unknown, }; }