Skip to content

Commit

Permalink
feat(option): allow to set options as conflicting (#1678)
Browse files Browse the repository at this point in the history
Problem: I would like to configure options that are mutually exclusive. For example --json (to set the output type), conflicts with --silent (to suppress output).

Solution: Added a new option method conflicts that accepts an array of strings (or a single string) with options that conflicts with the configured option.
  • Loading branch information
erezrokah committed Mar 6, 2022
1 parent 997655d commit fc4fd41
Show file tree
Hide file tree
Showing 9 changed files with 383 additions and 2 deletions.
7 changes: 6 additions & 1 deletion Readme.md
Expand Up @@ -397,7 +397,8 @@ program
.addOption(new Option('-t, --timeout <delay>', 'timeout in seconds').default(60, 'one minute'))
.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('--donate [amount]', 'optional donation in dollars').preset('20').argParser(parseFloat))
.addOption(new Option('--disable-server', 'disables the server').conflicts(['port'])); // or `.conflicts('port')` for a single conflict
```
```bash
Expand All @@ -409,13 +410,17 @@ Options:
-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)
--disable-server disables the server
-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' }
$ extra --disable-server --port 8000
error: option '--disable-server' cannot be used with option '-p, --port <number>'
```
### Custom option processing
Expand Down
4 changes: 3 additions & 1 deletion examples/options-extra.js
Expand Up @@ -12,7 +12,8 @@ program
.addOption(new Option('-t, --timeout <delay>', 'timeout in seconds').default(60, 'one minute'))
.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('--donate [amount]', 'optional donation in dollars').preset('20').argParser(parseFloat))
.addOption(new Option('--disable-server', 'disables the server').conflicts(['port'])); // or `.conflicts('port')` for a single conflict

program.parse();

Expand All @@ -24,3 +25,4 @@ console.log('Options: ', program.opts());
// PORT=80 node options-extra.js
// node options-extra.js --donate
// node options-extra.js --donate 30.50
// node options-extra.js --disable-server --port 8000
69 changes: 69 additions & 0 deletions lib/command.js
Expand Up @@ -1217,6 +1217,7 @@ Expecting one of '${allowedValues.join("', '")}'`);

outputHelpIfRequested(this, parsed.unknown);
this._checkForMissingMandatoryOptions();
this._checkForConflictingOptions();

// We do not always call this check to avoid masking a "better" error, like unknown command.
const checkForUnknownOptions = () => {
Expand Down Expand Up @@ -1309,6 +1310,36 @@ Expecting one of '${allowedValues.join("', '")}'`);
}
}

/**
* Display an error message if conflicting options are used together.
*
* @api private
*/
_checkForConflictingOptions() {
const definedNonDefaultOptions = this.options.filter(
(option) => {
const optionKey = option.attributeName();
if (this.getOptionValue(optionKey) === undefined) {
return false;
}
return this.getOptionValueSource(optionKey) !== 'default';
}
);

const optionsWithConflicting = definedNonDefaultOptions.filter(
(option) => option.conflictsWith.length > 0
);

optionsWithConflicting.forEach((option) => {
const conflictingAndDefined = definedNonDefaultOptions.find((defined) =>
option.conflictsWith.includes(defined.attributeName())
);
if (conflictingAndDefined) {
this._conflictingOption(option, conflictingAndDefined);
}
});
}

/**
* Parse options from `argv` removing known options,
* and return argv split into operands and unknown arguments.
Expand Down Expand Up @@ -1560,6 +1591,44 @@ Expecting one of '${allowedValues.join("', '")}'`);
this.error(message, { code: 'commander.missingMandatoryOptionValue' });
}

/**
* `Option` conflicts with another option.
*
* @param {Option} option
* @param {Option} conflictingOption
* @api private
*/
_conflictingOption(option, conflictingOption) {
// The calling code does not know whether a negated option is the source of the
// value, so do some work to take an educated guess.
const findBestOptionFromValue = (option) => {
const optionKey = option.attributeName();
const optionValue = this.getOptionValue(optionKey);
const negativeOption = this.options.find(target => target.negate && optionKey === target.attributeName());
const positiveOption = this.options.find(target => !target.negate && optionKey === target.attributeName());
if (negativeOption && (
(negativeOption.presetArg === undefined && optionValue === false) ||
(negativeOption.presetArg !== undefined && optionValue === negativeOption.presetArg)
)) {
return negativeOption;
}
return positiveOption || option;
};

const getErrorMessage = (option) => {
const bestOption = findBestOptionFromValue(option);
const optionKey = bestOption.attributeName();
const source = this.getOptionValueSource(optionKey);
if (source === 'env') {
return `environment variable '${bestOption.envVar}'`;
}
return `option '${bestOption.flags}'`;
};

const message = `error: ${getErrorMessage(option)} cannot be used with ${getErrorMessage(conflictingOption)}`;
this.error(message, { code: 'commander.conflictingOption' });
}

/**
* Unknown option `flag`.
*
Expand Down
17 changes: 17 additions & 0 deletions lib/option.js
Expand Up @@ -33,6 +33,7 @@ class Option {
this.parseArg = undefined;
this.hidden = false;
this.argChoices = undefined;
this.conflictsWith = [];
}

/**
Expand Down Expand Up @@ -66,6 +67,22 @@ class Option {
return this;
}

/**
* Set options name(s) that conflict with this option.
*
* @param {string | string[]} names
* @return {Option}
*/

conflicts(names) {
if (!Array.isArray(names) && typeof names !== 'string') {
throw new Error('conflicts() argument must be a string or an array of strings');
}

this.conflictsWith = this.conflictsWith.concat(names);
return this;
}

/**
* Set environment variable to check for option value.
* Priority order of option values is default < env < cli
Expand Down

0 comments on commit fc4fd41

Please sign in to comment.