diff --git a/src/IOption.ts b/src/IOption.ts new file mode 100644 index 0000000..368b116 --- /dev/null +++ b/src/IOption.ts @@ -0,0 +1,15 @@ +/** + * A command line option + */ +export default 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; +} diff --git a/src/index.ts b/src/index.ts index cd9c44c..7789aee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,20 +1,7 @@ -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; -} +import initState from './initState'; +import IOption from './IOption'; +import runPreParseHook from './runPreParseHook'; +import verifyAndStoreOptionValue from './verifyAndStoreOptionValue'; /** * 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. @@ -26,102 +13,15 @@ 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 = ''; - } - - //--------------------------------------------------------------- + //Initialize state + const { outputArgs, shortToLongLookup, requiredOptions, failFunction } = initState(spec); + const args = runPreParseHook({ args: process.argv.slice(2), preParseHook, fail: failFunction }); //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. + let option = ''; for (const arg of args) { - if (argumentOption === '') { + if (option === '') { /** We expect a name, must be one of: * - `--name value` * - `--name=value` @@ -129,53 +29,53 @@ export default function parseArguments( * - `-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; + option = arg.substr(2); + let value; //If there's a =, immediately read the value - if (argumentOption.indexOf('=') !== -1) { - [argumentOption, argumentValue] = argumentOption.split('=', 2); + if (option.indexOf('=') !== -1) { + [option, value] = option.split('=', 2); } //Check that argument name is valid - if (spec[argumentOption] === undefined) { - failFunction(`Unknown option "--${argumentOption}".`); + if (spec[option] === undefined) { + failFunction(`Unknown option "--${option}".`); } //If value was provided, check that value is correct and remove name for next loop iteration - if (argumentValue !== undefined) { - parseValue(argumentValue); - argumentOption = ''; + if (value !== undefined) { + verifyAndStoreOptionValue({option, value, verify: spec[option].verify, message: spec[option].message, fail: failFunction, outputArgs, requiredOptions}); + option = ''; } } else if (arg.startsWith('-')) { //short argument - argumentOption = arg.substr(1); - let argumentValue; + option = arg.substr(1); + let value; //If there's a =, immediately read the value - if (argumentOption.indexOf('=') !== -1) { - [argumentOption, argumentValue] = argumentOption.split('=', 2); + if (option.indexOf('=') !== -1) { + [option, value] = option.split('=', 2); } //Check that argument name is valid - if (shortToLongLookup[argumentOption] === undefined) { - failFunction(`Unknown short option "-${argumentOption}".`); + if (shortToLongLookup[option] === undefined) { + failFunction(`Unknown short option "-${option}".`); } - argumentOption = shortToLongLookup[argumentOption]; + option = shortToLongLookup[option]; //If value was provided, check that value is correct and remove name for next loop iteration - if (argumentValue !== undefined) { - parseValue(argumentValue); - argumentOption = ''; + if (value !== undefined) { + verifyAndStoreOptionValue({option, value, verify: spec[option].verify, message: spec[option].message, fail: failFunction, outputArgs, requiredOptions}); + option = ''; } } 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); + verifyAndStoreOptionValue({option, value: arg, verify: spec[option].verify, message: spec[option].message, fail: failFunction, outputArgs, requiredOptions}); + option = ''; } } // 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.`); + if (option !== '') { + failFunction(`Option "${option}" was not followed by a value.`); } //check if any entry in requiredArguments was not set diff --git a/src/initState.ts b/src/initState.ts new file mode 100644 index 0000000..fd9356f --- /dev/null +++ b/src/initState.ts @@ -0,0 +1,49 @@ +import failWithError from './failWithError'; +import IOption from './IOption'; + +const fail = failWithError.bind(null, ''); + +export default function initState(spec: { [key: string]: IOption }) { + //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 === '') { + fail(`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]+)*$/)) { + fail(`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) { + fail(`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; + } + } + } + + 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); + + return { outputArgs, shortToLongLookup, requiredOptions, failFunction }; +} diff --git a/src/runPreParseHook.ts b/src/runPreParseHook.ts new file mode 100644 index 0000000..e364034 --- /dev/null +++ b/src/runPreParseHook.ts @@ -0,0 +1,17 @@ +export default function runPreParseHook({ args, preParseHook, fail }: { + args: string[], + preParseHook?: (args: string[], fail: (message: string) => void) => (string[] | undefined), + fail: (errorMessage: string) => void, +}): string[] { + if (preParseHook === undefined) { + return args; + } + + try { + const modifiedArgs = preParseHook(args, fail); + return modifiedArgs !== undefined ? modifiedArgs : args; + } catch (error) { + fail(`Could not run pre-parse hook, encountered error: ${String(error)}.`); + return []; + } +} diff --git a/src/tslint.json b/src/tslint.json new file mode 100644 index 0000000..5352f67 --- /dev/null +++ b/src/tslint.json @@ -0,0 +1,17 @@ +{ + "defaultSeverity": "error", + "extends": [ + "tslint:recommended" + ], + "jsRules": {}, + "rules": { + "comment-format": false, + "indent": [true, "spaces", 2], + "max-line-length": false, + "no-bitwise": false, + "no-console": false, + "quotemark": [true, "single"], + "object-literal-sort-keys": [true, "match-declaration-order"] + }, + "rulesDirectory": [] +} diff --git a/src/verifyAndStoreOptionValue.ts b/src/verifyAndStoreOptionValue.ts new file mode 100644 index 0000000..f6e17ff --- /dev/null +++ b/src/verifyAndStoreOptionValue.ts @@ -0,0 +1,44 @@ +import IOption from './IOption'; + +export default function verifyAndStoreOptionValue({ + option, + value, + verify, + message, + fail, + outputArgs, + requiredOptions, +}: { + option: string, + value: string, + verify: IOption['verify'], + message: IOption['message'], + fail: (errorMessage: string) => void, + outputArgs: { [key: string]: string }, + requiredOptions: { [key: string]: boolean }, +}) { + //Verify value for correctness + try { + if (verify !== undefined && !verify(value)) { + if (message !== undefined) { + try { + const customMessage = message(value); + if (customMessage !== undefined && customMessage !== '') { + fail(customMessage); + } + } catch (error) { + fail(`Invalid value set for option "${option}": "${value}". Could not show custom error message: ${String(error)}`); + } + } + fail(`Invalid value set for option "${option}": "${value}".`); + } + } catch (error) { + fail(`Invalid value set for option "${option}", the validation of "${value}" failed: ${String(error)}`); + } + + //Remember the value, and if it is a required argument, mark that we have encountered it + outputArgs[option] = value; + if (requiredOptions[option] === false) { + requiredOptions[option] = true; + } +}