/* For reference: - 'options' are flags preceded by one or two dashs: eg. -f, --foo - 'option-arguments' are (non-option) strings that follow an option (and go with it) eg. 'filename.txt' in './example --input filename.txt' - 'operands' are (non-option) strings that follow an option, but aren't option-arguments eg 'filename.txt' in './example --verbose filename.txt' see https://unix.stackexchange.com/questions/364383/confusion-about-changing-meaning-of-arguments-and-options-is-there-an-official and https://pubs.opengroup.org/onlinepubs/9699919799.2018edition/basedefs/V1_chap12.html#tag_12_01 */ module.exports = class Opter { synonyms = {}; definedOptions = {}; userOptions = {}; /** * @param {string} shortFlag * @param {string} longFlag * @param {boolean} [required] * @param {boolean} [requiresArgument] * @param {number} [maxArguments] **/ addOption(shortFlag, longFlag, required=false, requiresArgument=false, maxArguments=Infinity) { let reqs = { required: required, requiresArgument: requiresArgument, maxArguments: maxArguments }; const short = stripDashes(shortFlag); const long = stripDashes(longFlag); this.definedOptions[long] = reqs; this.synonyms[long] = short; } /** * @param {object} argv An argv object (`process.argv`) * @returns {Object>} **/ parse(argv) { this.userOptions = argvToObject(argv); /** * @type {Object>} * The user-provided with ther long names as the keys **/ let normalizedUserOptions = {}; Object.keys(this.definedOptions).forEach(longName => { let shortName = this.synonyms[longName]; let reqs = this.definedOptions[longName]; let providedLong = typeof this.userOptions[longName] !== 'undefined'; let providedShort = typeof this.userOptions[shortName] !== 'undefined'; let provided = providedLong || providedShort; let userArgs = null; // arrays have type 'object': if (typeof this.userOptions[longName] === 'object') { userArgs = this.userOptions[longName]; } if (typeof this.userOptions[shortName] === 'object') { userArgs = this.userOptions[shortName]; } if (provided) { normalizedUserOptions[longName] = typeof userArgs === 'object' ? userArgs : true; } /* Check that required options are provided */ if (reqs.required && !provided) { throw new Error(`Missing required option '${longName}'`); } /* Check the number of arguments */ if (provided && reqs.requiresArgument) { // Make sure required arguments are provided if (userArgs === null) { throw new Error(`Missing required argument for '${longName}'`); } // And make sure there aren't too many if (userArgs.length > reqs.maxArguments) { throw new Error(`Too many arguments for '${longName}'`); } } }) return normalizedUserOptions; } } /** * @returns {Object.>} **/ function argvToObject(argv) { argv = process.argv.slice(2); // Label everything in argv as either // an 'option' or an 'arg_or_operand' let mapped = argv.flatMap((a) => { // A single long option if (a.startsWith('--')) { return { arg: a.substring(2), type: 'option' } } // A single short option if (a.startsWith('-') && (a.length === 2)) { return { arg: a.substring(1), type: 'option' } } // Multiple short options if (a.startsWith('-')) { let splitGroup = a.substring(1).split(''); return splitGroup.map((sg) => { return {arg: sg, type: 'option'} }); } // An option-argument or operand return { arg: a, type: 'arg_or_operand' }; }); // Group consecutive option-args/operands let wrapped = mapped.map((a) => [a]); let grouped = []; wrapped.forEach((a, i) => { if (i === 0 || a[0].type === 'option') { grouped.push(a); return; } let prev = grouped.pop(); if (prev[0].type === 'option') { grouped.push(prev); grouped.push(a); return; } grouped.push(prev.concat(a)); return; }); /** @type {Object>} **/ let out = {}; grouped.forEach((a, i) => { let next = grouped[i+1]; if ((a[0].type === 'option') && next && (next[0].type === 'arg_or_operand')) { let next_shortened = next.map(n => n.arg); out[a[0].arg] = next_shortened; grouped.splice(i, 1); } else { let pair = {}; pair[a[0].arg] = true; a.forEach((b) => { out[b.arg] = true; }); } }); return out; } function stripDashes(optionName) { if (optionName.startsWith('--')) return optionName.substring(2); if (optionName.startsWith('-')) return optionName.substring(1); return optionName; }