Skip to content

Commit d660967

Browse files
authored
Add Option.implies() (#1724)
1 parent 1b492d9 commit d660967

File tree

9 files changed

+430
-25
lines changed

9 files changed

+430
-25
lines changed

Readme.md

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ For information about terms used in this document see: [terminology](./docs/term
5757

5858
## Installation
5959

60-
```bash
60+
```sh
6161
npm install commander
6262
```
6363

@@ -86,7 +86,7 @@ const limit = options.first ? 1 : undefined;
8686
console.log(program.args[0].split(options.separator, limit));
8787
```
8888

89-
```sh
89+
```console
9090
$ node split.js -s / --fits a/b/c
9191
error: unknown option '--fits'
9292
(Did you mean --first?)
@@ -120,7 +120,7 @@ program.command('split')
120120
program.parse();
121121
```
122122

123-
```sh
123+
```console
124124
$ node string-util.js help split
125125
Usage: string-util split [options] <string>
126126

@@ -180,7 +180,7 @@ Multi-word options such as "--template-engine" are camel-cased, becoming `progra
180180

181181
An option and its option-argument can be separated by a space, or combined into the same argument. The option-argument can follow the short option directly or follow an `=` for a long option.
182182

183-
```bash
183+
```sh
184184
serve -p 80
185185
serve -p80
186186
serve --port 80
@@ -219,7 +219,7 @@ if (options.small) console.log('- small pizza size');
219219
if (options.pizzaType) console.log(`- ${options.pizzaType}`);
220220
```
221221

222-
```bash
222+
```console
223223
$ pizza-options -p
224224
error: option '-p, --pizza-type <type>' argument missing
225225
$ pizza-options -d -s -p vegetarian
@@ -255,7 +255,7 @@ program.parse();
255255
console.log(`cheese: ${program.opts().cheese}`);
256256
```
257257

258-
```bash
258+
```console
259259
$ pizza-options
260260
cheese: blue
261261
$ pizza-options --cheese stilton
@@ -285,7 +285,7 @@ const cheeseStr = (options.cheese === false) ? 'no cheese' : `${options.cheese}
285285
console.log(`You ordered a pizza with ${sauceStr} and ${cheeseStr}`);
286286
```
287287

288-
```bash
288+
```console
289289
$ pizza-options
290290
You ordered a pizza with sauce and mozzarella cheese
291291
$ pizza-options --sauce
@@ -313,7 +313,7 @@ else if (options.cheese === true) console.log('add cheese');
313313
else console.log(`add cheese type ${options.cheese}`);
314314
```
315315

316-
```bash
316+
```console
317317
$ pizza-options
318318
no cheese
319319
$ pizza-options --cheese
@@ -340,7 +340,7 @@ program
340340
program.parse();
341341
```
342342

343-
```bash
343+
```console
344344
$ pizza
345345
error: required option '-c, --cheese <type>' not specified
346346
```
@@ -365,7 +365,7 @@ console.log('Options: ', program.opts());
365365
console.log('Remaining arguments: ', program.args);
366366
```
367367

368-
```bash
368+
```console
369369
$ collect -n 1 2 3 --letter a b c
370370
Options: { number: [ '1', '2', '3' ], letter: [ 'a', 'b', 'c' ] }
371371
Remaining arguments: []
@@ -387,7 +387,7 @@ The optional `version` method adds handling for displaying the command version.
387387
program.version('0.0.1');
388388
```
389389

390-
```bash
390+
```console
391391
$ ./examples/pizza -V
392392
0.0.1
393393
```
@@ -404,7 +404,7 @@ program.version('0.0.1', '-v, --vers', 'output the current version');
404404
You can add most options using the `.option()` method, but there are some additional features available
405405
by constructing an `Option` explicitly for less common cases.
406406

407-
Example files: [options-extra.js](./examples/options-extra.js), [options-env.js](./examples/options-env.js), [options-conflicts.js](./examples/options-conflicts.js)
407+
Example files: [options-extra.js](./examples/options-extra.js), [options-env.js](./examples/options-env.js), [options-conflicts.js](./examples/options-conflicts.js), [options-implies.js](./examples/options-implies.js)
408408

409409
```js
410410
program
@@ -413,26 +413,28 @@ program
413413
.addOption(new Option('-d, --drink <size>', 'drink size').choices(['small', 'medium', 'large']))
414414
.addOption(new Option('-p, --port <number>', 'port number').env('PORT'))
415415
.addOption(new Option('--donate [amount]', 'optional donation in dollars').preset('20').argParser(parseFloat))
416-
.addOption(new Option('--disable-server', 'disables the server').conflicts('port'));
416+
.addOption(new Option('--disable-server', 'disables the server').conflicts('port'))
417+
.addOption(new Option('--free-drink', 'small drink included free ').implies({ drink: 'small' }));
417418
```
418419

419-
```bash
420+
```console
420421
$ extra --help
421422
Usage: help [options]
422423

423424
Options:
424425
-t, --timeout <delay> timeout in seconds (default: one minute)
425426
-d, --drink <size> drink cup size (choices: "small", "medium", "large")
426427
-p, --port <number> port number (env: PORT)
427-
--donate [amount] optional donation in dollars (preset: 20)
428+
--donate [amount] optional donation in dollars (preset: "20")
428429
--disable-server disables the server
430+
--free-drink small drink included free
429431
-h, --help display help for command
430432

431433
$ extra --drink huge
432434
error: option '-d, --drink <size>' argument 'huge' is invalid. Allowed choices are small, medium, large.
433435

434-
$ PORT=80 extra --donate
435-
Options: { timeout: 60, donate: 20, port: '80' }
436+
$ PORT=80 extra --donate --free-drink
437+
Options: { timeout: 60, donate: 20, port: '80', freeDrink: true, drink: 'small' }
436438

437439
$ extra --disable-server --port 8000
438440
error: option '--disable-server' cannot be used with option '-p, --port <number>'
@@ -489,7 +491,7 @@ if (options.collect.length > 0) console.log(options.collect);
489491
if (options.list !== undefined) console.log(options.list);
490492
```
491493

492-
```bash
494+
```console
493495
$ custom -f 1e2
494496
float: 100
495497
$ custom --integer 2
@@ -728,7 +730,7 @@ help option is `-h,--help`.
728730

729731
Example file: [pizza](./examples/pizza)
730732

731-
```bash
733+
```console
732734
$ node ./examples/pizza --help
733735
Usage: pizza [options]
734736

@@ -744,7 +746,7 @@ Options:
744746
A `help` command is added by default if your command has subcommands. It can be used alone, or with a subcommand name to show
745747
further help for the subcommand. These are effectively the same if the `shell` program has implicit help:
746748

747-
```bash
749+
```sh
748750
shell help
749751
shell --help
750752

@@ -806,7 +808,7 @@ program.showHelpAfterError();
806808
program.showHelpAfterError('(add --help for additional information)');
807809
```
808810

809-
```sh
811+
```console
810812
$ pizza --unknown
811813
error: unknown option '--unknown'
812814
(add --help for additional information)
@@ -818,7 +820,7 @@ You can also show suggestions after an error for an unknown command or option.
818820
program.showSuggestionAfterError();
819821
```
820822

821-
```sh
823+
```console
822824
$ pizza --hepl
823825
error: unknown option '--hepl'
824826
(Did you mean --help?)
@@ -991,7 +993,7 @@ program
991993

992994
If you use `ts-node` and stand-alone executable subcommands written as `.ts` files, you need to call your program through node to get the subcommands called correctly. e.g.
993995

994-
```bash
996+
```sh
995997
node -r ts-node/register pm.ts
996998
```
997999

examples/options-extra.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ program
1414
.addOption(new Option('-d, --drink <size>', 'drink cup size').choices(['small', 'medium', 'large']))
1515
.addOption(new Option('-p, --port <number>', 'port number').env('PORT'))
1616
.addOption(new Option('--donate [amount]', 'optional donation in dollars').preset('20').argParser(parseFloat))
17-
.addOption(new Option('--disable-server', 'disables the server').conflicts('port'));
17+
.addOption(new Option('--disable-server', 'disables the server').conflicts('port'))
18+
.addOption(new Option('--free-drink', 'small drink included free ').implies({ drink: 'small' }));
1819

1920
program.parse();
2021

@@ -23,6 +24,7 @@ console.log('Options: ', program.opts());
2324
// Try the following:
2425
// node options-extra.js --help
2526
// node options-extra.js --drink huge
27+
// node options-extra.js --free-drink
2628
// PORT=80 node options-extra.js
2729
// node options-extra.js --donate
2830
// node options-extra.js --donate 30.50

examples/options-implies.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/usr/bin/env node
2+
// const { Command, Option } = require('commander'); // (normal include)
3+
const { Command, Option } = require('../'); // include commander in git clone of commander repo
4+
const program = new Command();
5+
6+
// You can use .conflicts() with a single string, which is the camel-case name of the conflicting option.
7+
program
8+
.addOption(new Option('--quiet').implies({ logLevel: 'off' }))
9+
.addOption(new Option('--log-level <level>').choices(['info', 'warning', 'error', 'off']).default('info'))
10+
.addOption(new Option('-c, --cheese <type>', 'Add the specified type of cheese').implies({ dairy: true }))
11+
.addOption(new Option('--no-cheese', 'You do not want any cheese').implies({ dairy: false }))
12+
.addOption(new Option('--dairy', 'May contain dairy'));
13+
14+
program.parse();
15+
console.log(program.opts());
16+
17+
// Try the following:
18+
// node options-implies.js
19+
// node options-implies.js --quiet
20+
// node options-implies.js --log-level=warning --quiet
21+
// node options-implies.js --cheese=cheddar
22+
// node options-implies.js --no-cheese

lib/command.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const process = require('process');
77
const { Argument, humanReadableArgName } = require('./argument.js');
88
const { CommanderError } = require('./error.js');
99
const { Help } = require('./help.js');
10-
const { Option, splitOptionFlags } = require('./option.js');
10+
const { Option, splitOptionFlags, DualOptions } = require('./option.js');
1111
const { suggestSimilar } = require('./suggestSimilar');
1212

1313
// @ts-check
@@ -1194,6 +1194,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
11941194
_parseCommand(operands, unknown) {
11951195
const parsed = this.parseOptions(unknown);
11961196
this._parseOptionsEnv(); // after cli, so parseArg not called on both cli and env
1197+
this._parseOptionsImplied();
11971198
operands = operands.concat(parsed.operands);
11981199
unknown = parsed.unknown;
11991200
this.args = operands.concat(unknown);
@@ -1569,6 +1570,29 @@ Expecting one of '${allowedValues.join("', '")}'`);
15691570
});
15701571
}
15711572

1573+
/**
1574+
* Apply any implied option values, if option is undefined or default value.
1575+
*
1576+
* @api private
1577+
*/
1578+
_parseOptionsImplied() {
1579+
const dualHelper = new DualOptions(this.options);
1580+
const hasCustomOptionValue = (optionKey) => {
1581+
return this.getOptionValue(optionKey) !== undefined && !['default', 'implied'].includes(this.getOptionValueSource(optionKey));
1582+
};
1583+
this.options
1584+
.filter(option => (option.implied !== undefined) &&
1585+
hasCustomOptionValue(option.attributeName()) &&
1586+
dualHelper.valueFromOption(this.getOptionValue(option.attributeName()), option))
1587+
.forEach((option) => {
1588+
Object.keys(option.implied)
1589+
.filter(impliedKey => !hasCustomOptionValue(impliedKey))
1590+
.forEach(impliedKey => {
1591+
this.setOptionValueWithSource(impliedKey, option.implied[impliedKey], 'implied');
1592+
});
1593+
});
1594+
}
1595+
15721596
/**
15731597
* Argument `name` is missing.
15741598
*

lib/option.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class Option {
3434
this.hidden = false;
3535
this.argChoices = undefined;
3636
this.conflictsWith = [];
37+
this.implied = undefined;
3738
}
3839

3940
/**
@@ -84,6 +85,24 @@ class Option {
8485
return this;
8586
}
8687

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+
87106
/**
88107
* Set environment variable to check for option value.
89108
* Priority order of option values is default < env < cli
@@ -217,6 +236,53 @@ class Option {
217236
}
218237
}
219238

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+
*/
246+
class 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+
220286
/**
221287
* Convert string from kebab-case to camelCase.
222288
*
@@ -255,3 +321,4 @@ function splitOptionFlags(flags) {
255321

256322
exports.Option = Option;
257323
exports.splitOptionFlags = splitOptionFlags;
324+
exports.DualOptions = DualOptions;

0 commit comments

Comments
 (0)