• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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