8.57 kBJavaScriptView Raw
1const { InvalidArgumentError } = require('./error.js');
2
3class 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 * @package
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(
184 `Allowed choices are ${this.argChoices.join(', ')}.`,
185 );
186 }
187 if (this.variadic) {
188 return this._concatValue(arg, previous);
189 }
190 return arg;
191 };
192 return this;
193 }
194
195 /**
196 * Return option name.
197 *
198 * @return {string}
199 */
200
201 name() {
202 if (this.long) {
203 return this.long.replace(/^--/, '');
204 }
205 return this.short.replace(/^-/, '');
206 }
207
208 /**
209 * Return option name, in a camelcase format that can be used
210 * as a object attribute key.
211 *
212 * @return {string}
213 */
214
215 attributeName() {
216 return camelcase(this.name().replace(/^no-/, ''));
217 }
218
219 /**
220 * Check if `arg` matches the short or long flag.
221 *
222 * @param {string} arg
223 * @return {boolean}
224 * @package
225 */
226
227 is(arg) {
228 return this.short === arg || this.long === arg;
229 }
230
231 /**
232 * Return whether a boolean option.
233 *
234 * Options are one of boolean, negated, required argument, or optional argument.
235 *
236 * @return {boolean}
237 * @package
238 */
239
240 isBoolean() {
241 return !this.required && !this.optional && !this.negate;
242 }
243}
244
245/**
246 * This class is to make it easier to work with dual options, without changing the existing
247 * implementation. We support separate dual options for separate positive and negative options,
248 * like `--build` and `--no-build`, which share a single option value. This works nicely for some
249 * use cases, but is tricky for others where we want separate behaviours despite
250 * the single shared option value.
251 */
252class DualOptions {
253 /**
254 * @param {Option[]} options
255 */
256 constructor(options) {
257 this.positiveOptions = new Map();
258 this.negativeOptions = new Map();
259 this.dualOptions = new Set();
260 options.forEach((option) => {
261 if (option.negate) {
262 this.negativeOptions.set(option.attributeName(), option);
263 } else {
264 this.positiveOptions.set(option.attributeName(), option);
265 }
266 });
267 this.negativeOptions.forEach((value, key) => {
268 if (this.positiveOptions.has(key)) {
269 this.dualOptions.add(key);
270 }
271 });
272 }
273
274 /**
275 * Did the value come from the option, and not from possible matching dual option?
276 *
277 * @param {*} value
278 * @param {Option} option
279 * @returns {boolean}
280 */
281 valueFromOption(value, option) {
282 const optionKey = option.attributeName();
283 if (!this.dualOptions.has(optionKey)) return true;
284
285 // Use the value to deduce if (probably) came from the option.
286 const preset = this.negativeOptions.get(optionKey).presetArg;
287 const negativeValue = preset !== undefined ? preset : false;
288 return option.negate === (negativeValue === value);
289 }
290}
291
292/**
293 * Convert string from kebab-case to camelCase.
294 *
295 * @param {string} str
296 * @return {string}
297 * @private
298 */
299
300function camelcase(str) {
301 return str.split('-').reduce((str, word) => {
302 return str + word[0].toUpperCase() + word.slice(1);
303 });
304}
305
306/**
307 * Split the short and long flag out of something like '-m,--mixed <value>'
308 *
309 * @private
310 */
311
312function splitOptionFlags(flags) {
313 let shortFlag;
314 let longFlag;
315 // Use original very loose parsing to maintain backwards compatibility for now,
316 // which allowed for example unintended `-sw, --short-word` [sic].
317 const flagParts = flags.split(/[ |,]+/);
318 if (flagParts.length > 1 && !/^[[<]/.test(flagParts[1]))
319 shortFlag = flagParts.shift();
320 longFlag = flagParts.shift();
321 // Add support for lone short flag without significantly changing parsing!
322 if (!shortFlag && /^-[^-]$/.test(longFlag)) {
323 shortFlag = longFlag;
324 longFlag = undefined;
325 }
326 return { shortFlag, longFlag };
327}
328
329exports.Option = Option;
330exports.DualOptions = DualOptions;