1const { InvalidArgumentError } = require('./error.js'); 2 3// @ts-check 4 5class Option { 6 /** 7 * Initialize a new `Option` with the given `flags` and `description`. 8 * 9 * @param {string} flags 10 * @param {string} [description] 11 */ 12 13 constructor(flags, description) { 14 this.flags = flags; 15 this.description = description || ''; 16 17 this.required = flags.includes('<'); // A value must be supplied when the option is specified. 18 this.optional = flags.includes('['); // A value is optional when the option is specified. 19 // variadic test ignores <value,...> et al which might be used to describe custom splitting of single argument 20 this.variadic = /\w\.\.\.[>\]]$/.test(flags); // The option can take multiple values. 21 this.mandatory = false; // The option must have a value after parsing, which usually means it must be specified on command line. 22 const optionFlags = splitOptionFlags(flags); 23 this.short = optionFlags.shortFlag; 24 this.long = optionFlags.longFlag; 25 this.negate = false; 26 if (this.long) { 27 this.negate = this.long.startsWith('--no-'); 28 } 29 this.defaultValue = undefined; 30 this.defaultValueDescription = undefined; 31 this.presetArg = undefined; 32 this.envVar = undefined; 33 this.parseArg = undefined; 34 this.hidden = false; 35 this.argChoices = undefined; 36 this.conflictsWith = []; 37 this.implied = undefined; 38 } 39 40 /** 41 * Set the default value, and optionally supply the description to be displayed in the help. 42 * 43 * @param {any} value 44 * @param {string} [description] 45 * @return {Option} 46 */ 47 48 default(value, description) { 49 this.defaultValue = value; 50 this.defaultValueDescription = description; 51 return this; 52 } 53 54 /** 55 * Preset to use when option used without option-argument, especially optional but also boolean and negated. 56 * The custom processing (parseArg) is called. 57 * 58 * @example 59 * new Option('--color').default('GREYSCALE').preset('RGB'); 60 * new Option('--donate [amount]').preset('20').argParser(parseFloat); 61 * 62 * @param {any} arg 63 * @return {Option} 64 */ 65 66 preset(arg) { 67 this.presetArg = arg; 68 return this; 69 } 70 71 /** 72 * Add option name(s) that conflict with this option. 73 * An error will be displayed if conflicting options are found during parsing. 74 * 75 * @example 76 * new Option('--rgb').conflicts('cmyk'); 77 * new Option('--js').conflicts(['ts', 'jsx']); 78 * 79 * @param {string | string[]} names 80 * @return {Option} 81 */ 82 83 conflicts(names) { 84 this.conflictsWith = this.conflictsWith.concat(names); 85 return this; 86 } 87 88 /** 89 * Specify implied option values for when this option is set and the implied options are not. 90 * 91 * The custom processing (parseArg) is not called on the implied values. 92 * 93 * @example 94 * program 95 * .addOption(new Option('--log', 'write logging information to file')) 96 * .addOption(new Option('--trace', 'log extra details').implies({ log: 'trace.txt' })); 97 * 98 * @param {Object} impliedOptionValues 99 * @return {Option} 100 */ 101 implies(impliedOptionValues) { 102 this.implied = Object.assign(this.implied || {}, impliedOptionValues); 103 return this; 104 } 105 106 /** 107 * Set environment variable to check for option value. 108 * 109 * An environment variable is only used if when processed the current option value is 110 * undefined, or the source of the current value is 'default' or 'config' or 'env'. 111 * 112 * @param {string} name 113 * @return {Option} 114 */ 115 116 env(name) { 117 this.envVar = name; 118 return this; 119 } 120 121 /** 122 * Set the custom handler for processing CLI option arguments into option values. 123 * 124 * @param {Function} [fn] 125 * @return {Option} 126 */ 127 128 argParser(fn) { 129 this.parseArg = fn; 130 return this; 131 } 132 133 /** 134 * Whether the option is mandatory and must have a value after parsing. 135 * 136 * @param {boolean} [mandatory=true] 137 * @return {Option} 138 */ 139 140 makeOptionMandatory(mandatory = true) { 141 this.mandatory = !!mandatory; 142 return this; 143 } 144 145 /** 146 * Hide option in help. 147 * 148 * @param {boolean} [hide=true] 149 * @return {Option} 150 */ 151 152 hideHelp(hide = true) { 153 this.hidden = !!hide; 154 return this; 155 } 156 157 /** 158 * @api private 159 */ 160 161 _concatValue(value, previous) { 162 if (previous === this.defaultValue || !Array.isArray(previous)) { 163 return [value]; 164 } 165 166 return previous.concat(value); 167 } 168 169 /** 170 * Only allow option value to be one of choices. 171 * 172 * @param {string[]} values 173 * @return {Option} 174 */ 175 176 choices(values) { 177 this.argChoices = values.slice(); 178 this.parseArg = (arg, previous) => { 179 if (!this.argChoices.includes(arg)) { 180 throw new InvalidArgumentError(`Allowed choices are ${this.argChoices.join(', ')}.`); 181 } 182 if (this.variadic) { 183 return this._concatValue(arg, previous); 184 } 185 return arg; 186 }; 187 return this; 188 } 189 190 /** 191 * Return option name. 192 * 193 * @return {string} 194 */ 195 196 name() { 197 if (this.long) { 198 return this.long.replace(/^--/, ''); 199 } 200 return this.short.replace(/^-/, ''); 201 } 202 203 /** 204 * Return option name, in a camelcase format that can be used 205 * as a object attribute key. 206 * 207 * @return {string} 208 * @api private 209 */ 210 211 attributeName() { 212 return camelcase(this.name().replace(/^no-/, '')); 213 } 214 215 /** 216 * Check if `arg` matches the short or long flag. 217 * 218 * @param {string} arg 219 * @return {boolean} 220 * @api private 221 */ 222 223 is(arg) { 224 return this.short === arg || this.long === arg; 225 } 226 227 /** 228 * Return whether a boolean option. 229 * 230 * Options are one of boolean, negated, required argument, or optional argument. 231 * 232 * @return {boolean} 233 * @api private 234 */ 235 236 isBoolean() { 237 return !this.required && !this.optional && !this.negate; 238 } 239} 240 241/** 242 * This class is to make it easier to work with dual options, without changing the existing 243 * implementation. We support separate dual options for separate positive and negative options, 244 * like `--build` and `--no-build`, which share a single option value. This works nicely for some 245 * use cases, but is tricky for others where we want separate behaviours despite 246 * the single shared option value. 247 */ 248class DualOptions { 249 /** 250 * @param {Option[]} options 251 */ 252 constructor(options) { 253 this.positiveOptions = new Map(); 254 this.negativeOptions = new Map(); 255 this.dualOptions = new Set(); 256 options.forEach(option => { 257 if (option.negate) { 258 this.negativeOptions.set(option.attributeName(), option); 259 } else { 260 this.positiveOptions.set(option.attributeName(), option); 261 } 262 }); 263 this.negativeOptions.forEach((value, key) => { 264 if (this.positiveOptions.has(key)) { 265 this.dualOptions.add(key); 266 } 267 }); 268 } 269 270 /** 271 * Did the value come from the option, and not from possible matching dual option? 272 * 273 * @param {any} value 274 * @param {Option} option 275 * @returns {boolean} 276 */ 277 valueFromOption(value, option) { 278 const optionKey = option.attributeName(); 279 if (!this.dualOptions.has(optionKey)) return true; 280 281 // Use the value to deduce if (probably) came from the option. 282 const preset = this.negativeOptions.get(optionKey).presetArg; 283 const negativeValue = (preset !== undefined) ? preset : false; 284 return option.negate === (negativeValue === value); 285 } 286} 287 288/** 289 * Convert string from kebab-case to camelCase. 290 * 291 * @param {string} str 292 * @return {string} 293 * @api private 294 */ 295 296function camelcase(str) { 297 return str.split('-').reduce((str, word) => { 298 return str + word[0].toUpperCase() + word.slice(1); 299 }); 300} 301 302/** 303 * Split the short and long flag out of something like '-m,--mixed <value>' 304 * 305 * @api private 306 */ 307 308function splitOptionFlags(flags) { 309 let shortFlag; 310 let longFlag; 311 // Use original very loose parsing to maintain backwards compatibility for now, 312 // which allowed for example unintended `-sw, --short-word` [sic]. 313 const flagParts = flags.split(/[ |,]+/); 314 if (flagParts.length > 1 && !/^[[<]/.test(flagParts[1])) shortFlag = flagParts.shift(); 315 longFlag = flagParts.shift(); 316 // Add support for lone short flag without significantly changing parsing! 317 if (!shortFlag && /^-[^-]$/.test(longFlag)) { 318 shortFlag = longFlag; 319 longFlag = undefined; 320 } 321 return { shortFlag, longFlag }; 322} 323 324exports.Option = Option; 325exports.splitOptionFlags = splitOptionFlags; 326exports.DualOptions = DualOptions; 327