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 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) { let newImplied = impliedOptionValues; if (typeof impliedOptionValues === 'string') { // string is not documented, but easy mistake and we can do what user probably intended. newImplied = { [impliedOptionValues]: true }; } this.implied = Object.assign(this.implied || {}, newImplied); 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 ' * * @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;