Skip to content

Commit

Permalink
Add Option.implies() (#1724)
Browse files Browse the repository at this point in the history
  • Loading branch information
shadowspawn committed May 11, 2022
1 parent 1b492d9 commit d660967
Show file tree
Hide file tree
Showing 9 changed files with 430 additions and 25 deletions.
48 changes: 25 additions & 23 deletions Readme.md
Expand Up @@ -57,7 +57,7 @@ For information about terms used in this document see: [terminology](./docs/term

## Installation

```bash
```sh
npm install commander
```

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

```sh
```console
$ node split.js -s / --fits a/b/c
error: unknown option '--fits'
(Did you mean --first?)
Expand Down Expand Up @@ -120,7 +120,7 @@ program.command('split')
program.parse();
```

```sh
```console
$ node string-util.js help split
Usage: string-util split [options] <string>

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

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.

```bash
```sh
serve -p 80
serve -p80
serve --port 80
Expand Down Expand Up @@ -219,7 +219,7 @@ if (options.small) console.log('- small pizza size');
if (options.pizzaType) console.log(`- ${options.pizzaType}`);
```

```bash
```console
$ pizza-options -p
error: option '-p, --pizza-type <type>' argument missing
$ pizza-options -d -s -p vegetarian
Expand Down Expand Up @@ -255,7 +255,7 @@ program.parse();
console.log(`cheese: ${program.opts().cheese}`);
```

```bash
```console
$ pizza-options
cheese: blue
$ pizza-options --cheese stilton
Expand Down Expand Up @@ -285,7 +285,7 @@ const cheeseStr = (options.cheese === false) ? 'no cheese' : `${options.cheese}
console.log(`You ordered a pizza with ${sauceStr} and ${cheeseStr}`);
```

```bash
```console
$ pizza-options
You ordered a pizza with sauce and mozzarella cheese
$ pizza-options --sauce
Expand Down Expand Up @@ -313,7 +313,7 @@ else if (options.cheese === true) console.log('add cheese');
else console.log(`add cheese type ${options.cheese}`);
```

```bash
```console
$ pizza-options
no cheese
$ pizza-options --cheese
Expand All @@ -340,7 +340,7 @@ program
program.parse();
```

```bash
```console
$ pizza
error: required option '-c, --cheese <type>' not specified
```
Expand All @@ -365,7 +365,7 @@ console.log('Options: ', program.opts());
console.log('Remaining arguments: ', program.args);
```

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

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

Example files: [options-extra.js](./examples/options-extra.js), [options-env.js](./examples/options-env.js), [options-conflicts.js](./examples/options-conflicts.js)
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)

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

```bash
```console
$ extra --help
Usage: help [options]

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

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

$ PORT=80 extra --donate
Options: { timeout: 60, donate: 20, port: '80' }
$ PORT=80 extra --donate --free-drink
Options: { timeout: 60, donate: 20, port: '80', freeDrink: true, drink: 'small' }

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

```bash
```console
$ custom -f 1e2
float: 100
$ custom --integer 2
Expand Down Expand Up @@ -728,7 +730,7 @@ help option is `-h,--help`.

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

```bash
```console
$ node ./examples/pizza --help
Usage: pizza [options]

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

```bash
```sh
shell help
shell --help

Expand Down Expand Up @@ -806,7 +808,7 @@ program.showHelpAfterError();
program.showHelpAfterError('(add --help for additional information)');
```

```sh
```console
$ pizza --unknown
error: unknown option '--unknown'
(add --help for additional information)
Expand All @@ -818,7 +820,7 @@ You can also show suggestions after an error for an unknown command or option.
program.showSuggestionAfterError();
```

```sh
```console
$ pizza --hepl
error: unknown option '--hepl'
(Did you mean --help?)
Expand Down Expand Up @@ -991,7 +993,7 @@ program

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.

```bash
```sh
node -r ts-node/register pm.ts
```

Expand Down
4 changes: 3 additions & 1 deletion examples/options-extra.js
Expand Up @@ -14,7 +14,8 @@ program
.addOption(new Option('-d, --drink <size>', 'drink cup size').choices(['small', 'medium', 'large']))
.addOption(new Option('-p, --port <number>', 'port number').env('PORT'))
.addOption(new Option('--donate [amount]', 'optional donation in dollars').preset('20').argParser(parseFloat))
.addOption(new Option('--disable-server', 'disables the server').conflicts('port'));
.addOption(new Option('--disable-server', 'disables the server').conflicts('port'))
.addOption(new Option('--free-drink', 'small drink included free ').implies({ drink: 'small' }));

program.parse();

Expand All @@ -23,6 +24,7 @@ console.log('Options: ', program.opts());
// Try the following:
// node options-extra.js --help
// node options-extra.js --drink huge
// node options-extra.js --free-drink
// PORT=80 node options-extra.js
// node options-extra.js --donate
// node options-extra.js --donate 30.50
Expand Down
22 changes: 22 additions & 0 deletions examples/options-implies.js
@@ -0,0 +1,22 @@
#!/usr/bin/env node
// const { Command, Option } = require('commander'); // (normal include)
const { Command, Option } = require('../'); // include commander in git clone of commander repo
const program = new Command();

// You can use .conflicts() with a single string, which is the camel-case name of the conflicting option.
program
.addOption(new Option('--quiet').implies({ logLevel: 'off' }))
.addOption(new Option('--log-level <level>').choices(['info', 'warning', 'error', 'off']).default('info'))
.addOption(new Option('-c, --cheese <type>', 'Add the specified type of cheese').implies({ dairy: true }))
.addOption(new Option('--no-cheese', 'You do not want any cheese').implies({ dairy: false }))
.addOption(new Option('--dairy', 'May contain dairy'));

program.parse();
console.log(program.opts());

// Try the following:
// node options-implies.js
// node options-implies.js --quiet
// node options-implies.js --log-level=warning --quiet
// node options-implies.js --cheese=cheddar
// node options-implies.js --no-cheese
26 changes: 25 additions & 1 deletion lib/command.js
Expand Up @@ -7,7 +7,7 @@ const process = require('process');
const { Argument, humanReadableArgName } = require('./argument.js');
const { CommanderError } = require('./error.js');
const { Help } = require('./help.js');
const { Option, splitOptionFlags } = require('./option.js');
const { Option, splitOptionFlags, DualOptions } = require('./option.js');
const { suggestSimilar } = require('./suggestSimilar');

// @ts-check
Expand Down Expand Up @@ -1194,6 +1194,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
_parseCommand(operands, unknown) {
const parsed = this.parseOptions(unknown);
this._parseOptionsEnv(); // after cli, so parseArg not called on both cli and env
this._parseOptionsImplied();
operands = operands.concat(parsed.operands);
unknown = parsed.unknown;
this.args = operands.concat(unknown);
Expand Down Expand Up @@ -1569,6 +1570,29 @@ Expecting one of '${allowedValues.join("', '")}'`);
});
}

/**
* Apply any implied option values, if option is undefined or default value.
*
* @api private
*/
_parseOptionsImplied() {
const dualHelper = new DualOptions(this.options);
const hasCustomOptionValue = (optionKey) => {
return this.getOptionValue(optionKey) !== undefined && !['default', 'implied'].includes(this.getOptionValueSource(optionKey));
};
this.options
.filter(option => (option.implied !== undefined) &&
hasCustomOptionValue(option.attributeName()) &&
dualHelper.valueFromOption(this.getOptionValue(option.attributeName()), option))
.forEach((option) => {
Object.keys(option.implied)
.filter(impliedKey => !hasCustomOptionValue(impliedKey))
.forEach(impliedKey => {
this.setOptionValueWithSource(impliedKey, option.implied[impliedKey], 'implied');
});
});
}

/**
* Argument `name` is missing.
*
Expand Down
67 changes: 67 additions & 0 deletions lib/option.js
Expand Up @@ -34,6 +34,7 @@ class Option {
this.hidden = false;
this.argChoices = undefined;
this.conflictsWith = [];
this.implied = undefined;
}

/**
Expand Down Expand Up @@ -84,6 +85,24 @@ class Option {
return this;
}

/**
* Specify implied option values for when this option is set and the implied options are not.
*
* The custom processing (parseArg) is not called on the implied values.
*
* @example
* program
* .addOption(new Option('--log', 'write logging information to file'))
* .addOption(new Option('--trace', 'log extra details').implies({ log: 'trace.txt' }));
*
* @param {Object} impliedOptionValues
* @return {Option}
*/
implies(impliedOptionValues) {
this.implied = Object.assign(this.implied || {}, impliedOptionValues);
return this;
}

/**
* Set environment variable to check for option value.
* Priority order of option values is default < env < cli
Expand Down Expand Up @@ -217,6 +236,53 @@ class Option {
}
}

/**
* This class is to make it easier to work with dual options, without changing the existing
* implementation. We support separate dual options for separate positive and negative options,
* like `--build` and `--no-build`, which share a single option value. This works nicely for some
* use cases, but is tricky for others where we want separate behaviours despite
* the single shared option value.
*/
class DualOptions {
/**
* @param {Option[]} options
*/
constructor(options) {
this.positiveOptions = new Map();
this.negativeOptions = new Map();
this.dualOptions = new Set();
options.forEach(option => {
if (option.negate) {
this.negativeOptions.set(option.attributeName(), option);
} else {
this.positiveOptions.set(option.attributeName(), option);
}
});
this.negativeOptions.forEach((value, key) => {
if (this.positiveOptions.has(key)) {
this.dualOptions.add(key);
}
});
}

/**
* Did the value come from the option, and not from possible matching dual option?
*
* @param {any} value
* @param {Option} option
* @returns {boolean}
*/
valueFromOption(value, option) {
const optionKey = option.attributeName();
if (!this.dualOptions.has(optionKey)) return true;

// Use the value to deduce if (probably) came from the option.
const preset = this.negativeOptions.get(optionKey).presetArg;
const negativeValue = (preset !== undefined) ? preset : false;
return option.negate === (negativeValue === value);
}
}

/**
* Convert string from kebab-case to camelCase.
*
Expand Down Expand Up @@ -255,3 +321,4 @@ function splitOptionFlags(flags) {

exports.Option = Option;
exports.splitOptionFlags = splitOptionFlags;
exports.DualOptions = DualOptions;

0 comments on commit d660967

Please sign in to comment.