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