argv-parser/opter.js

159 lines
4.8 KiB
JavaScript

/* 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<string, boolean | Array<string>>}
**/
parse(argv) {
this.userOptions = argvToObject(argv);
/**
* @type {Object<string, boolean | Array<string>>}
* 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.<string, boolean | Array<string>>}
**/
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<string, boolean | Array<string>>} **/
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;
}