You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
326 lines
8.2 KiB
326 lines
8.2 KiB
const { InvalidArgumentError } = require('./error.js'); |
|
|
|
// @ts-check |
|
|
|
class Option { |
|
/** |
|
* Initialize a new `Option` with the given `flags` and `description`. |
|
* |
|
* @param {string} flags |
|
* @param {string} [description] |
|
*/ |
|
|
|
constructor(flags, description) { |
|
this.flags = flags; |
|
this.description = description || ''; |
|
|
|
this.required = flags.includes('<'); // A value must be supplied when the option is specified. |
|
this.optional = flags.includes('['); // A value is optional when the option is specified. |
|
// variadic test ignores <value,...> et al which might be used to describe custom splitting of single argument |
|
this.variadic = /\w\.\.\.[>\]]$/.test(flags); // The option can take multiple values. |
|
this.mandatory = false; // The option must have a value after parsing, which usually means it must be specified on command line. |
|
const optionFlags = splitOptionFlags(flags); |
|
this.short = optionFlags.shortFlag; |
|
this.long = optionFlags.longFlag; |
|
this.negate = false; |
|
if (this.long) { |
|
this.negate = this.long.startsWith('--no-'); |
|
} |
|
this.defaultValue = undefined; |
|
this.defaultValueDescription = undefined; |
|
this.presetArg = undefined; |
|
this.envVar = undefined; |
|
this.parseArg = undefined; |
|
this.hidden = false; |
|
this.argChoices = undefined; |
|
this.conflictsWith = []; |
|
this.implied = undefined; |
|
} |
|
|
|
/** |
|
* Set the default value, and optionally supply the description to be displayed in the help. |
|
* |
|
* @param {any} value |
|
* @param {string} [description] |
|
* @return {Option} |
|
*/ |
|
|
|
default(value, description) { |
|
this.defaultValue = value; |
|
this.defaultValueDescription = description; |
|
return this; |
|
} |
|
|
|
/** |
|
* Preset to use when option used without option-argument, especially optional but also boolean and negated. |
|
* The custom processing (parseArg) is called. |
|
* |
|
* @example |
|
* new Option('--color').default('GREYSCALE').preset('RGB'); |
|
* new Option('--donate [amount]').preset('20').argParser(parseFloat); |
|
* |
|
* @param {any} arg |
|
* @return {Option} |
|
*/ |
|
|
|
preset(arg) { |
|
this.presetArg = arg; |
|
return this; |
|
} |
|
|
|
/** |
|
* Add option name(s) that conflict with this option. |
|
* An error will be displayed if conflicting options are found during parsing. |
|
* |
|
* @example |
|
* new Option('--rgb').conflicts('cmyk'); |
|
* new Option('--js').conflicts(['ts', 'jsx']); |
|
* |
|
* @param {string | string[]} names |
|
* @return {Option} |
|
*/ |
|
|
|
conflicts(names) { |
|
this.conflictsWith = this.conflictsWith.concat(names); |
|
return this; |
|
} |
|
|
|
/** |
|
* Specify implied option values for when this option is set and the implied options are not. |
|
* |
|
* The custom processing (parseArg) is not called on the implied values. |
|
* |
|
* @example |
|
* program |
|
* .addOption(new Option('--log', 'write logging information to file')) |
|
* .addOption(new Option('--trace', 'log extra details').implies({ log: 'trace.txt' })); |
|
* |
|
* @param {Object} impliedOptionValues |
|
* @return {Option} |
|
*/ |
|
implies(impliedOptionValues) { |
|
this.implied = Object.assign(this.implied || {}, impliedOptionValues); |
|
return this; |
|
} |
|
|
|
/** |
|
* Set environment variable to check for option value. |
|
* |
|
* An environment variable is only used if when processed the current option value is |
|
* undefined, or the source of the current value is 'default' or 'config' or 'env'. |
|
* |
|
* @param {string} name |
|
* @return {Option} |
|
*/ |
|
|
|
env(name) { |
|
this.envVar = name; |
|
return this; |
|
} |
|
|
|
/** |
|
* Set the custom handler for processing CLI option arguments into option values. |
|
* |
|
* @param {Function} [fn] |
|
* @return {Option} |
|
*/ |
|
|
|
argParser(fn) { |
|
this.parseArg = fn; |
|
return this; |
|
} |
|
|
|
/** |
|
* Whether the option is mandatory and must have a value after parsing. |
|
* |
|
* @param {boolean} [mandatory=true] |
|
* @return {Option} |
|
*/ |
|
|
|
makeOptionMandatory(mandatory = true) { |
|
this.mandatory = !!mandatory; |
|
return this; |
|
} |
|
|
|
/** |
|
* Hide option in help. |
|
* |
|
* @param {boolean} [hide=true] |
|
* @return {Option} |
|
*/ |
|
|
|
hideHelp(hide = true) { |
|
this.hidden = !!hide; |
|
return this; |
|
} |
|
|
|
/** |
|
* @api private |
|
*/ |
|
|
|
_concatValue(value, previous) { |
|
if (previous === this.defaultValue || !Array.isArray(previous)) { |
|
return [value]; |
|
} |
|
|
|
return previous.concat(value); |
|
} |
|
|
|
/** |
|
* Only allow option value to be one of choices. |
|
* |
|
* @param {string[]} values |
|
* @return {Option} |
|
*/ |
|
|
|
choices(values) { |
|
this.argChoices = values.slice(); |
|
this.parseArg = (arg, previous) => { |
|
if (!this.argChoices.includes(arg)) { |
|
throw new InvalidArgumentError(`Allowed choices are ${this.argChoices.join(', ')}.`); |
|
} |
|
if (this.variadic) { |
|
return this._concatValue(arg, previous); |
|
} |
|
return arg; |
|
}; |
|
return this; |
|
} |
|
|
|
/** |
|
* Return option name. |
|
* |
|
* @return {string} |
|
*/ |
|
|
|
name() { |
|
if (this.long) { |
|
return this.long.replace(/^--/, ''); |
|
} |
|
return this.short.replace(/^-/, ''); |
|
} |
|
|
|
/** |
|
* Return option name, in a camelcase format that can be used |
|
* as a object attribute key. |
|
* |
|
* @return {string} |
|
* @api private |
|
*/ |
|
|
|
attributeName() { |
|
return camelcase(this.name().replace(/^no-/, '')); |
|
} |
|
|
|
/** |
|
* Check if `arg` matches the short or long flag. |
|
* |
|
* @param {string} arg |
|
* @return {boolean} |
|
* @api private |
|
*/ |
|
|
|
is(arg) { |
|
return this.short === arg || this.long === arg; |
|
} |
|
|
|
/** |
|
* Return whether a boolean option. |
|
* |
|
* Options are one of boolean, negated, required argument, or optional argument. |
|
* |
|
* @return {boolean} |
|
* @api private |
|
*/ |
|
|
|
isBoolean() { |
|
return !this.required && !this.optional && !this.negate; |
|
} |
|
} |
|
|
|
/** |
|
* This class is to make it easier to work with dual options, without changing the existing |
|
* implementation. We support separate dual options for separate positive and negative options, |
|
* like `--build` and `--no-build`, which share a single option value. This works nicely for some |
|
* use cases, but is tricky for others where we want separate behaviours despite |
|
* the single shared option value. |
|
*/ |
|
class DualOptions { |
|
/** |
|
* @param {Option[]} options |
|
*/ |
|
constructor(options) { |
|
this.positiveOptions = new Map(); |
|
this.negativeOptions = new Map(); |
|
this.dualOptions = new Set(); |
|
options.forEach(option => { |
|
if (option.negate) { |
|
this.negativeOptions.set(option.attributeName(), option); |
|
} else { |
|
this.positiveOptions.set(option.attributeName(), option); |
|
} |
|
}); |
|
this.negativeOptions.forEach((value, key) => { |
|
if (this.positiveOptions.has(key)) { |
|
this.dualOptions.add(key); |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* Did the value come from the option, and not from possible matching dual option? |
|
* |
|
* @param {any} value |
|
* @param {Option} option |
|
* @returns {boolean} |
|
*/ |
|
valueFromOption(value, option) { |
|
const optionKey = option.attributeName(); |
|
if (!this.dualOptions.has(optionKey)) return true; |
|
|
|
// Use the value to deduce if (probably) came from the option. |
|
const preset = this.negativeOptions.get(optionKey).presetArg; |
|
const negativeValue = (preset !== undefined) ? preset : false; |
|
return option.negate === (negativeValue === value); |
|
} |
|
} |
|
|
|
/** |
|
* Convert string from kebab-case to camelCase. |
|
* |
|
* @param {string} str |
|
* @return {string} |
|
* @api private |
|
*/ |
|
|
|
function camelcase(str) { |
|
return str.split('-').reduce((str, word) => { |
|
return str + word[0].toUpperCase() + word.slice(1); |
|
}); |
|
} |
|
|
|
/** |
|
* Split the short and long flag out of something like '-m,--mixed <value>' |
|
* |
|
* @api private |
|
*/ |
|
|
|
function splitOptionFlags(flags) { |
|
let shortFlag; |
|
let longFlag; |
|
// Use original very loose parsing to maintain backwards compatibility for now, |
|
// which allowed for example unintended `-sw, --short-word` [sic]. |
|
const flagParts = flags.split(/[ |,]+/); |
|
if (flagParts.length > 1 && !/^[[<]/.test(flagParts[1])) shortFlag = flagParts.shift(); |
|
longFlag = flagParts.shift(); |
|
// Add support for lone short flag without significantly changing parsing! |
|
if (!shortFlag && /^-[^-]$/.test(longFlag)) { |
|
shortFlag = longFlag; |
|
longFlag = undefined; |
|
} |
|
return { shortFlag, longFlag }; |
|
} |
|
|
|
exports.Option = Option; |
|
exports.splitOptionFlags = splitOptionFlags; |
|
exports.DualOptions = DualOptions;
|
|
|