UNPKG

8.29 kBJavaScriptView Raw
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 * Priority order of option values is default < env < cli
109 *
110 * @param {string} name
111 * @return {Option}
112 */
113
114 env(name) {
115 this.envVar = name;
116 return this;
117 }
118
119 /**
120 * Set the custom handler for processing CLI option arguments into option values.
121 *
122 * @param {Function} [fn]
123 * @return {Option}
124 */
125
126 argParser(fn) {
127 this.parseArg = fn;
128 return this;
129 }
130
131 /**
132 * Whether the option is mandatory and must have a value after parsing.
133 *
134 * @param {boolean} [mandatory=true]
135 * @return {Option}
136 */
137
138 makeOptionMandatory(mandatory = true) {
139 this.mandatory = !!mandatory;
140 return this;
141 }
142
143 /**
144 * Hide option in help.
145 *
146 * @param {boolean} [hide=true]
147 * @return {Option}
148 */
149
150 hideHelp(hide = true) {
151 this.hidden = !!hide;
152 return this;
153 }
154
155 /**
156 * @api private
157 */
158
159 _concatValue(value, previous) {
160 if (previous === this.defaultValue || !Array.isArray(previous)) {
161 return [value];
162 }
163
164 return previous.concat(value);
165 }
166
167 /**
168 * Only allow option value to be one of choices.
169 *
170 * @param {string[]} values
171 * @return {Option}
172 */
173
174 choices(values) {
175 this.argChoices = values.slice();
176 this.parseArg = (arg, previous) => {
177 if (!this.argChoices.includes(arg)) {
178 throw new InvalidArgumentError(`Allowed choices are ${this.argChoices.join(', ')}.`);
179 }
180 if (this.variadic) {
181 return this._concatValue(arg, previous);
182 }
183 return arg;
184 };
185 return this;
186 }
187
188 /**
189 * Return option name.
190 *
191 * @return {string}
192 */
193
194 name() {
195 if (this.long) {
196 return this.long.replace(/^--/, '');
197 }
198 return this.short.replace(/^-/, '');
199 }
200
201 /**
202 * Return option name, in a camelcase format that can be used
203 * as a object attribute key.
204 *
205 * @return {string}
206 * @api private
207 */
208
209 attributeName() {
210 return camelcase(this.name().replace(/^no-/, ''));
211 }
212
213 /**
214 * Check if `arg` matches the short or long flag.
215 *
216 * @param {string} arg
217 * @return {boolean}
218 * @api private
219 */
220
221 is(arg) {
222 return this.short === arg || this.long === arg;
223 }
224
225 /**
226 * Return whether a boolean option.
227 *
228 * Options are one of boolean, negated, required argument, or optional argument.
229 *
230 * @return {boolean}
231 * @api private
232 */
233
234 isBoolean() {
235 return !this.required && !this.optional && !this.negate;
236 }
237}
238
239/**
240 * This class is to make it easier to work with dual options, without changing the existing
241 * implementation. We support separate dual options for separate positive and negative options,
242 * like `--build` and `--no-build`, which share a single option value. This works nicely for some
243 * use cases, but is tricky for others where we want separate behaviours despite
244 * the single shared option value.
245 */
246class DualOptions {
247 /**
248 * @param {Option[]} options
249 */
250 constructor(options) {
251 this.positiveOptions = new Map();
252 this.negativeOptions = new Map();
253 this.dualOptions = new Set();
254 options.forEach(option => {
255 if (option.negate) {
256 this.negativeOptions.set(option.attributeName(), option);
257 } else {
258 this.positiveOptions.set(option.attributeName(), option);
259 }
260 });
261 this.negativeOptions.forEach((value, key) => {
262 if (this.positiveOptions.has(key)) {
263 this.dualOptions.add(key);
264 }
265 });
266 }
267
268 /**
269 * Did the value come from the option, and not from possible matching dual option?
270 *
271 * @param {any} value
272 * @param {Option} option
273 * @returns {boolean}
274 */
275 valueFromOption(value, option) {
276 const optionKey = option.attributeName();
277 if (!this.dualOptions.has(optionKey)) return true;
278
279 // Use the value to deduce if (probably) came from the option.
280 const preset = this.negativeOptions.get(optionKey).presetArg;
281 const negativeValue = (preset !== undefined) ? preset : false;
282 return option.negate === (negativeValue === value);
283 }
284}
285
286/**
287 * Convert string from kebab-case to camelCase.
288 *
289 * @param {string} str
290 * @return {string}
291 * @api private
292 */
293
294function camelcase(str) {
295 return str.split('-').reduce((str, word) => {
296 return str + word[0].toUpperCase() + word.slice(1);
297 });
298}
299
300/**
301 * Split the short and long flag out of something like '-m,--mixed <value>'
302 *
303 * @api private
304 */
305
306function splitOptionFlags(flags) {
307 let shortFlag;
308 let longFlag;
309 // Use original very loose parsing to maintain backwards compatibility for now,
310 // which allowed for example unintended `-sw, --short-word` [sic].
311 const flagParts = flags.split(/[ |,]+/);
312 if (flagParts.length > 1 && !/^[[<]/.test(flagParts[1])) shortFlag = flagParts.shift();
313 longFlag = flagParts.shift();
314 // Add support for lone short flag without significantly changing parsing!
315 if (!shortFlag && /^-[^-]$/.test(longFlag)) {
316 shortFlag = longFlag;
317 longFlag = undefined;
318 }
319 return { shortFlag, longFlag };
320}
321
322exports.Option = Option;
323exports.splitOptionFlags = splitOptionFlags;
324exports.DualOptions = DualOptions;