Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Option.implies() #1724

Merged
merged 13 commits into from May 11, 2022
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;