import failWithError from './failWithError'; /** * A command line option */ interface IOption { /** The default value to use, if this argument is missing. If set to `undefined`, this argument is considered to be required and the process will terminate whenever it is missing. */ default?: string; /** The short version option as an alias for the long option, must be a single character. Can be set to an empty string or `undefined` if there should be no short option. */ short?: string; /** A human readable description of this option, for display in the usage message. Not displayed if `undefined` or an empty string. */ description?: string; /** A function that verifies if the given value is a valid value for this option. If set to `undefined`, any value is accepted for this option. */ verify?: (value: string) => boolean; /** A function returning a custom error message that is shown if the verify function returns false. If the function is `undefined`, or it returns `undefined` or an empty string, a default error message is shown instead. */ message?: (value: string) => string; } /** * Checks the command line arguments against the given specification, and returns the arguments as a JavaScript object, as well as a failure function that prints the usage instructions before terminating. * If an error is found, outputs an error message and terminates with a non-zero exit code. * @param spec A specification of what arguments are expected. * @param preParseHook Optionally, a function to modify the command line arguments before parsing them. */ export default function parseArguments( spec: { [key: string]: IOption }, preParseHook?: (args: string[], fail: (message: string) => void) => (string[] | undefined), ): { fail: (errorMessage: string) => void, args: { [key: string]: string } } { //The parsed arguments that are returned by this function const outputArgs: { [key: string]: string } = {}; //Create a lookup table to find a long option when given the short option, and to check if all required options are set const shortToLongLookup: { [key: string]: string } = {}; const requiredOptions: { [key: string]: boolean } = {}; for (const longOption in spec) { if (spec.hasOwnProperty(longOption)) { if (longOption === '') { failWithError('', `The long option may not be an empty string. This is a bug in the source code of the script that you tried to call.`); } if (!longOption.match(/^[a-z0-9]+(-[a-z0-9]+)*$/)) { failWithError('', `The long option "${longOption}" must only contain alpha-numeric characters, optionally separated by hyphens. This is a bug in the source code of the script that you tried to call.`); } const shortOption = spec[longOption].short; if (shortOption !== undefined && shortOption !== '') { if (shortOption.length > 1) { failWithError('', `Short options must only be one character long but the short option "${shortOption}" for "${longOption}" was ${shortOption.length} characters long. This is a bug in the source code of the script that you tried to call.`); } shortToLongLookup[shortOption] = longOption; } //If a default value is given, use the default value (it can be overridden later), otherwise mark argument as being required const defaultValue = spec[longOption].default; if (defaultValue !== undefined) { outputArgs[longOption] = defaultValue; } else { requiredOptions[longOption] = false; } } } //--------------------------------------------------------------- let argumentOption = ''; const usage = `./${process.argv[1].substr(process.argv[1].lastIndexOf('/') + 1)} [options]\nOPTIONS\n${ Object.entries(spec).map(([key, {default: defaultValue, short, description}]) => ( ` ${(defaultValue === undefined) ? '' : '['}${(short !== undefined && short !== '') ? `-${short}, ` : ''}--${key} ${(defaultValue === undefined) ? '' : `], defaults to "${defaultValue}"`}${(description !== undefined && description !== '') ? `\n ${description}` : ''}` )).join('\n') }`; const failFunction = failWithError.bind(null, usage); //The original arguments from the command line let args = process.argv.slice(2); //If defined, run the pre-parse hook to modify the arguments if (preParseHook !== undefined) { try { const modifiedArgs = preParseHook(args, failFunction); if (modifiedArgs !== undefined) { args = modifiedArgs; } } catch (error) { failFunction(`Could not run pre-parse hook, encountered error: ${String(error)}.`); } } function parseValue(argumentValue: string) { //Verify value for correctness const verifyFunc = spec[argumentOption].verify; const messageFunc = spec[argumentOption].message; try { if (verifyFunc !== undefined && !verifyFunc(argumentValue)) { if (messageFunc !== undefined) { try { const customMessage = messageFunc(argumentValue); if (customMessage !== undefined && customMessage !== '') { failFunction(customMessage); } } catch (error) { failFunction(`Invalid value set for option "${argumentOption}": "${argumentValue}". Could not show custom error message: ${String(error)}`); } } failFunction(`Invalid value set for option "${argumentOption}": "${argumentValue}".`); } } catch (error) { failFunction(`Invalid value set for option "${argumentOption}", the validation of "${argumentValue}" failed: ${String(error)}`); } //Remember the value, and if it is a required argument, mark that we have encountered it outputArgs[argumentOption] = argumentValue; if (requiredOptions[argumentOption] === false) { requiredOptions[argumentOption] = true; } argumentOption = ''; } //--------------------------------------------------------------- //Iterate through all command line arguments. When we have read both name and value, verify the argument for correctness. //Show error if a name has no value afterwards, or a value has no name in front of it. for (const arg of args) { if (argumentOption === '') { /** We expect a name, must be one of: * - `--name value` * - `--name=value` * - `-n value` * - `-n=value` */ if (arg === '-' || arg === '--') { //TODO: what about the -- option? skip parsing of all following options? failFunction(`Empty option "-" or "--" is not supported.`); } else if (arg.startsWith('--')) { //long argument argumentOption = arg.substr(2); let argumentValue; //If there's a =, immediately read the value if (argumentOption.indexOf('=') !== -1) { [argumentOption, argumentValue] = argumentOption.split('=', 2); } //Check that argument name is valid if (spec[argumentOption] === undefined) { failFunction(`Unknown option "--${argumentOption}".`); } //If value was provided, check that value is correct and remove name for next loop iteration if (argumentValue !== undefined) { parseValue(argumentValue); argumentOption = ''; } } else if (arg.startsWith('-')) { //short argument argumentOption = arg.substr(1); let argumentValue; //If there's a =, immediately read the value if (argumentOption.indexOf('=') !== -1) { [argumentOption, argumentValue] = argumentOption.split('=', 2); } //Check that argument name is valid if (shortToLongLookup[argumentOption] === undefined) { failFunction(`Unknown short option "-${argumentOption}".`); } argumentOption = shortToLongLookup[argumentOption]; //If value was provided, check that value is correct and remove name for next loop iteration if (argumentValue !== undefined) { parseValue(argumentValue); argumentOption = ''; } } else { failFunction(`Arguments must be preceded by an option but there was no option in front of "${arg}".`); } } else { //We expect a value, can be anything parseValue(arg); } } // argumentName must be cleared to '' after the value is read, so if it is not an empty string, the value was missing if (argumentOption !== '') { failFunction(`Option "${argumentOption}" was not followed by a value.`); } //check if any entry in requiredArguments was not set for (const optName in requiredOptions) { if (spec.hasOwnProperty(optName) && requiredOptions[optName] === false) { failFunction(`Missing option "${optName}" even though it is required.`); } } return { fail: failFunction, args: outputArgs, }; }