// https://unix.stackexchange.com/questions/364383/confusion-about-changing-meaning-of-arguments-and-options-is-there-an-official // https://pubs.opengroup.org/onlinepubs/9699919799.2018edition/basedefs/V1_chap12.html#tag_12_01 // 'options' are --foo, -f // // 'option-arguments' are (non-option) things that follow an option, and that the option expects // eg. 'filename.txt' in './example --input filename.txt' // // 'operands' are (non-option) things that follow an option, but aren't option-arguments // eg 'filename.txt' in './example --verbose filename.txt' const defaultErrorMessages = { missingRequiredOption: (optionName) => `Missing required option '${optionName}'`, missingRequiredArgument: (optionName) => `Missing argument for option ${optionName}`, tooManyArguments: (optionName) => `Too many arguments for option ${optionName}`, } // TODO figure out how to avoid having to use '.opts to access the output of this' module.exports = class Opts { constructor(argv) { this.opts = get(argv); this.synonyms = []; } // TODO equip prototype with ability to print directly through `console.log(opts)` /** * Declare multiple option flags to be synonymous. * Use this if you have short and long names for the same flag * eg: '-d' and '--debug' * * @param {Array} namesWithDashes * @returns {void} **/ synonymize(...namesWithDashes) { const names = namesWithDashes.map(n => stripDashes(n)); names.forEach((n) => { const others = names.filter(x => x != n) this.synonyms[n] = others; }); } /** * Verify the presence of a required option * @param {string} nameWithDashes * @param {string} [errorMessage] * @returns {Boolean} **True** indicates that the option is present **/ requireOption(nameWithDashes, errorMessage=null) { const name = stripDashes(nameWithDashes); if (name in this.opts) return true; if (name in this.synonyms) { const syns = this.synonyms[name]; const hits = syns.filter(s => s in this.opts ).length; if (hits > 0) { return true; } } throw new Error(errorMessage || defaultErrorMessages.missingRequiredOption(nameWithDashes)); } /** * Verify the presence of arguments for an option that requires them, * if that option is present * @param {string} nameWithDashes * @param {number} [minRequired] * @param {number} [maxRequired] * @param {string} [tooFewMessage] Error message if there are too few arguments provided * @param {string} [tooManyMessage] Error message if there are too many arguments provided * @returns {Boolean} **True** indicates that the arguments are present, or that the option is *not* present **/ requireOptionArgument(nameWithDashes, minRequired=1, maxRequired=1, tooFewMessage=null, tooManyMessage=null) { let name = stripDashes(nameWithDashes); const checkArgs = (name) => { if (typeof this.opts[name] === 'boolean') { throw new Error(tooFewMessage || defaultErrorMessages.missingRequiredArgument(nameWithDashes)); } else if (this.opts[name].length < minRequired) { throw new Error(tooFewMessage || defaultErrorMessages.missingRequiredArgument(nameWithDashes)); } else if (this.opts[name].length > maxRequired) { throw new Error(tooManyMessage || defaultErrorMessages.tooManyArguments(nameWithDashes)); } } if (name in this.opts) { checkArgs(name); } if (name in this.synonyms) { const syn = this.synonyms[name].filter(s => s in this.opts )[0]; checkArgs(syn); } return true; } contains(nameWithDashes) { const name = stripDashes(nameWithDashes); if (name in this.opts) return true; if (name in this.synonyms) { const syns = this.synonyms[name]; const hits = syns.filter(s => s in this.opts).length; if (hits > 0) { return true; } } return false; } } /** * @returns [ string: (boolean | string[]) ] **/ function get(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; }); 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; }