381 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			381 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const { InvalidArgumentError } = require('./error.js');
 | |
| 
 | |
| 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; // May be a short flag, undefined, or even a long flag (if option has two long flags).
 | |
|     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;
 | |
|     this.helpGroupHeading = undefined; // soft initialised when option added to command
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Set the default value, and optionally supply the description to be displayed in the help.
 | |
|    *
 | |
|    * @param {*} 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 {*} 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;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * @package
 | |
|    */
 | |
| 
 | |
|   _collectValue(value, previous) {
 | |
|     if (previous === this.defaultValue || !Array.isArray(previous)) {
 | |
|       return [value];
 | |
|     }
 | |
| 
 | |
|     previous.push(value);
 | |
|     return previous;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * 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._collectValue(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 an object attribute key.
 | |
|    *
 | |
|    * @return {string}
 | |
|    */
 | |
| 
 | |
|   attributeName() {
 | |
|     if (this.negate) {
 | |
|       return camelcase(this.name().replace(/^no-/, ''));
 | |
|     }
 | |
|     return camelcase(this.name());
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Set the help group heading.
 | |
|    *
 | |
|    * @param {string} heading
 | |
|    * @return {Option}
 | |
|    */
 | |
|   helpGroup(heading) {
 | |
|     this.helpGroupHeading = heading;
 | |
|     return this;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Check if `arg` matches the short or long flag.
 | |
|    *
 | |
|    * @param {string} arg
 | |
|    * @return {boolean}
 | |
|    * @package
 | |
|    */
 | |
| 
 | |
|   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}
 | |
|    * @package
 | |
|    */
 | |
| 
 | |
|   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 {*} 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}
 | |
|  * @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>'
 | |
|  *
 | |
|  * @private
 | |
|  */
 | |
| 
 | |
| function splitOptionFlags(flags) {
 | |
|   let shortFlag;
 | |
|   let longFlag;
 | |
|   // short flag, single dash and single character
 | |
|   const shortFlagExp = /^-[^-]$/;
 | |
|   // long flag, double dash and at least one character
 | |
|   const longFlagExp = /^--[^-]/;
 | |
| 
 | |
|   const flagParts = flags.split(/[ |,]+/).concat('guard');
 | |
|   // Normal is short and/or long.
 | |
|   if (shortFlagExp.test(flagParts[0])) shortFlag = flagParts.shift();
 | |
|   if (longFlagExp.test(flagParts[0])) longFlag = flagParts.shift();
 | |
|   // Long then short. Rarely used but fine.
 | |
|   if (!shortFlag && shortFlagExp.test(flagParts[0]))
 | |
|     shortFlag = flagParts.shift();
 | |
|   // Allow two long flags, like '--ws, --workspace'
 | |
|   // This is the supported way to have a shortish option flag.
 | |
|   if (!shortFlag && longFlagExp.test(flagParts[0])) {
 | |
|     shortFlag = longFlag;
 | |
|     longFlag = flagParts.shift();
 | |
|   }
 | |
| 
 | |
|   // Check for unprocessed flag. Fail noisily rather than silently ignore.
 | |
|   if (flagParts[0].startsWith('-')) {
 | |
|     const unsupportedFlag = flagParts[0];
 | |
|     const baseError = `option creation failed due to '${unsupportedFlag}' in option flags '${flags}'`;
 | |
|     if (/^-[^-][^-]/.test(unsupportedFlag))
 | |
|       throw new Error(
 | |
|         `${baseError}
 | |
| - a short flag is a single dash and a single character
 | |
|   - either use a single dash and a single character (for a short flag)
 | |
|   - or use a double dash for a long option (and can have two, like '--ws, --workspace')`,
 | |
|       );
 | |
|     if (shortFlagExp.test(unsupportedFlag))
 | |
|       throw new Error(`${baseError}
 | |
| - too many short flags`);
 | |
|     if (longFlagExp.test(unsupportedFlag))
 | |
|       throw new Error(`${baseError}
 | |
| - too many long flags`);
 | |
| 
 | |
|     throw new Error(`${baseError}
 | |
| - unrecognised flag format`);
 | |
|   }
 | |
|   if (shortFlag === undefined && longFlag === undefined)
 | |
|     throw new Error(
 | |
|       `option creation failed due to no flags found in '${flags}'.`,
 | |
|     );
 | |
| 
 | |
|   return { shortFlag, longFlag };
 | |
| }
 | |
| 
 | |
| exports.Option = Option;
 | |
| exports.DualOptions = DualOptions;
 |