Skip to content

Commit 708527e

Browse files
authored
Choices for arguments (#1525)
* Add Argument choices, and missing types and tests for default and argParse * Only make arguments visible if some description, not just default or choices * Improve format for partial argument descriptions * Add argument choices to README * Add test for edge case in argumentDescription
1 parent d282f20 commit 708527e

12 files changed

+213
-11
lines changed

Readme.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md)
2222
- [More configuration](#more-configuration)
2323
- [Custom option processing](#custom-option-processing)
2424
- [Commands](#commands)
25-
- [Specify the argument syntax](#specify-the-argument-syntax)
26-
- [Custom argument processing](#custom-argument-processing)
25+
- [Command-arguments](#command-arguments)
26+
- [More configuration](#more-configuration-1)
27+
- [Custom argument processing](#custom-argument-processing)
2728
- [Action handler](#action-handler)
2829
- [Stand-alone executable (sub)commands](#stand-alone-executable-subcommands)
2930
- [Life cycle hooks](#life-cycle-hooks)
@@ -33,7 +34,7 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md)
3334
- [.usage and .name](#usage-and-name)
3435
- [.helpOption(flags, description)](#helpoptionflags-description)
3536
- [.addHelpCommand()](#addhelpcommand)
36-
- [More configuration](#more-configuration-1)
37+
- [More configuration](#more-configuration-2)
3738
- [Custom event listeners](#custom-event-listeners)
3839
- [Bits and pieces](#bits-and-pieces)
3940
- [.parse() and .parseAsync()](#parse-and-parseasync)
@@ -428,7 +429,7 @@ Configuration options can be passed with the call to `.command()` and `.addComma
428429
remove the command from the generated help output. Specifying `isDefault: true` will run the subcommand if no other
429430
subcommand is specified ([example](./examples/defaultCommand.js)).
430431
431-
### Specify the argument syntax
432+
### Command-arguments
432433
433434
For subcommands, you can specify the argument syntax in the call to `.command()` (as shown above). This
434435
is the only method usable for subcommands implemented using a stand-alone executable, but for other subcommands
@@ -438,7 +439,6 @@ To configure a command, you can use `.argument()` to specify each expected comma
438439
You supply the argument name and an optional description. The argument may be `<required>` or `[optional]`.
439440
You can specify a default value for an optional command-argument.
440441
441-
442442
Example file: [argument.js](./examples/argument.js)
443443
444444
```js
@@ -474,7 +474,19 @@ program
474474
.arguments('<username> <password>');
475475
```
476476
477-
### Custom argument processing
477+
#### More configuration
478+
479+
There are some additional features available by constructing an `Argument` explicitly for less common cases.
480+
481+
Example file: [arguments-extra.js](./examples/arguments-extra.js)
482+
483+
```js
484+
program
485+
.addArgument(new commander.Argument('<drink-size>', 'drink cup size').choices(['small', 'medium', 'large']))
486+
.addArgument(new commander.Argument('[timeout]', 'timeout in seconds').default(60, 'one minute'))
487+
```
488+
489+
#### Custom argument processing
478490
479491
You may specify a function to do custom processing of command-arguments before they are passed to the action handler.
480492
The callback function receives two parameters, the user specified command-argument and the previous value for the argument.

examples/arguments-extra.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env node
2+
3+
// This is used as an example in the README for extra argument features.
4+
5+
// const commander = require('commander'); // (normal include)
6+
const commander = require('../'); // include commander in git clone of commander repo
7+
const program = new commander.Command();
8+
9+
program
10+
.addArgument(new commander.Argument('<drink-size>', 'drink cup size').choices(['small', 'medium', 'large']))
11+
.addArgument(new commander.Argument('[timeout]', 'timeout in seconds').default(60, 'one minute'))
12+
.action((drinkSize, timeout) => {
13+
console.log(`Drink size: ${drinkSize}`);
14+
console.log(`Timeout (s): ${timeout}`);
15+
});
16+
17+
program.parse();
18+
19+
// Try the following:
20+
// node arguments-extra.js --help
21+
// node arguments-extra.js huge
22+
// node arguments-extra.js small
23+
// node arguments-extra.js medium 30

lib/argument.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const { InvalidArgumentError } = require('./error.js');
2+
13
// @ts-check
24

35
class Argument {
@@ -16,6 +18,7 @@ class Argument {
1618
this.parseArg = undefined;
1719
this.defaultValue = undefined;
1820
this.defaultValueDescription = undefined;
21+
this.argChoices = undefined;
1922

2023
switch (name[0]) {
2124
case '<': // e.g. <required>
@@ -48,6 +51,18 @@ class Argument {
4851
return this._name;
4952
};
5053

54+
/**
55+
* @api private
56+
*/
57+
58+
_concatValue(value, previous) {
59+
if (previous === this.defaultValue || !Array.isArray(previous)) {
60+
return [value];
61+
}
62+
63+
return previous.concat(value);
64+
}
65+
5166
/**
5267
* Set the default value, and optionally supply the description to be displayed in the help.
5368
*
@@ -73,6 +88,27 @@ class Argument {
7388
this.parseArg = fn;
7489
return this;
7590
};
91+
92+
/**
93+
* Only allow option value to be one of choices.
94+
*
95+
* @param {string[]} values
96+
* @return {Argument}
97+
*/
98+
99+
choices(values) {
100+
this.argChoices = values;
101+
this.parseArg = (arg, previous) => {
102+
if (!values.includes(arg)) {
103+
throw new InvalidArgumentError(`Allowed choices are ${values.join(', ')}.`);
104+
}
105+
if (this.variadic) {
106+
return this._concatValue(arg, previous);
107+
}
108+
return arg;
109+
};
110+
return this;
111+
};
76112
}
77113

78114
/**

lib/help.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,20 @@ class Help {
260260

261261
argumentDescription(argument) {
262262
const extraInfo = [];
263+
if (argument.argChoices) {
264+
extraInfo.push(
265+
// use stringify to match the display of the default value
266+
`choices: ${argument.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`);
267+
}
263268
if (argument.defaultValue !== undefined) {
264269
extraInfo.push(`default: ${argument.defaultValueDescription || JSON.stringify(argument.defaultValue)}`);
265270
}
266271
if (extraInfo.length > 0) {
267-
return `${argument.description} (${extraInfo.join(', ')})`;
272+
const extraDescripton = `(${extraInfo.join(', ')})`;
273+
if (argument.description) {
274+
return `${argument.description} ${extraDescripton}`;
275+
}
276+
return extraDescripton;
268277
}
269278
return argument.description;
270279
}

tests/argument.chain.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const { Argument } = require('../');
2+
3+
describe('Argument methods that should return this for chaining', () => {
4+
test('when call .default() then returns this', () => {
5+
const argument = new Argument('<value>');
6+
const result = argument.default(3);
7+
expect(result).toBe(argument);
8+
});
9+
10+
test('when call .argParser() then returns this', () => {
11+
const argument = new Argument('<value>');
12+
const result = argument.argParser(() => { });
13+
expect(result).toBe(argument);
14+
});
15+
16+
test('when call .choices() then returns this', () => {
17+
const argument = new Argument('<value>');
18+
const result = argument.choices(['a']);
19+
expect(result).toBe(argument);
20+
});
21+
});

tests/argument.custom-processing.test.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,15 @@ test('when custom processing for argument throws plain error then not CommanderE
163163
expect(caughtErr).toBeInstanceOf(Error);
164164
expect(caughtErr).not.toBeInstanceOf(commander.CommanderError);
165165
});
166+
167+
// this is the happy path, testing failure case in command.exitOverride.test.js
168+
test('when argument argument in choices then argument set', () => {
169+
const program = new commander.Command();
170+
let shade;
171+
program
172+
.exitOverride()
173+
.addArgument(new commander.Argument('<shade>').choices(['red', 'blue']))
174+
.action((shadeParam) => { shade = shadeParam; });
175+
program.parse(['red'], { from: 'user' });
176+
expect(shade).toBe('red');
177+
});

tests/argument.variadic.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,26 @@ describe('variadic argument', () => {
8181

8282
expect(program.usage()).toBe('[options] [args...]');
8383
});
84+
85+
test('when variadic used with choices and one value then set in array', () => {
86+
const program = new commander.Command();
87+
let passedArg;
88+
program
89+
.addArgument(new commander.Argument('<value...>').choices(['one', 'two']))
90+
.action((value) => { passedArg = value; });
91+
92+
program.parse(['one'], { from: 'user' });
93+
expect(passedArg).toEqual(['one']);
94+
});
95+
96+
test('when variadic used with choices and two values then set in array', () => {
97+
const program = new commander.Command();
98+
let passedArg;
99+
program
100+
.addArgument(new commander.Argument('<value...>').choices(['one', 'two']))
101+
.action((value) => { passedArg = value; });
102+
103+
program.parse(['one', 'two'], { from: 'user' });
104+
expect(passedArg).toEqual(['one', 'two']);
105+
});
84106
});

tests/command.exitOverride.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,23 @@ describe('.exitOverride and error details', () => {
275275
expectCommanderError(caughtErr, 1, 'commander.invalidArgument', "error: option '--colour <shade>' argument 'green' is invalid. Allowed choices are red, blue.");
276276
});
277277

278+
test('when command argument not in choices then throw CommanderError', () => {
279+
const program = new commander.Command();
280+
program
281+
.exitOverride()
282+
.addArgument(new commander.Argument('<shade>').choices(['red', 'blue']))
283+
.action(() => {});
284+
285+
let caughtErr;
286+
try {
287+
program.parse(['green'], { from: 'user' });
288+
} catch (err) {
289+
caughtErr = err;
290+
}
291+
292+
expectCommanderError(caughtErr, 1, 'commander.invalidArgument', "error: command-argument value 'green' is invalid for argument 'shade'. Allowed choices are red, blue.");
293+
});
294+
278295
test('when custom processing for option throws InvalidArgumentError then catch CommanderError', () => {
279296
function justSayNo(value) {
280297
throw new commander.InvalidArgumentError('NO');

tests/command.help.test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,19 @@ test('when arguments described in deprecated way and empty description then argu
281281
const helpInformation = program.helpInformation();
282282
expect(helpInformation).toMatch(/Arguments:\n +file +input source/);
283283
});
284+
285+
test('when argument has choices then choices included in helpInformation', () => {
286+
const program = new commander.Command();
287+
program
288+
.addArgument(new commander.Argument('<colour>', 'preferred colour').choices(['red', 'blue']));
289+
const helpInformation = program.helpInformation();
290+
expect(helpInformation).toMatch('(choices: "red", "blue")');
291+
});
292+
293+
test('when argument has choices and default then both included in helpInformation', () => {
294+
const program = new commander.Command();
295+
program
296+
.addArgument(new commander.Argument('<colour>', 'preferred colour').choices(['red', 'blue']).default('red'));
297+
const helpInformation = program.helpInformation();
298+
expect(helpInformation).toMatch('(choices: "red", "blue", default: "red")');
299+
});

tests/help.argumentDescription.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,10 @@ describe('argumentDescription', () => {
2727
const helper = new commander.Help();
2828
expect(helper.argumentDescription(argument)).toEqual('description (default: custom)');
2929
});
30+
31+
test('when an argument has default value and no description then still return default value', () => {
32+
const argument = new commander.Argument('[n]').default('default');
33+
const helper = new commander.Help();
34+
expect(helper.argumentDescription(argument)).toEqual('(default: "default")');
35+
});
3036
});

0 commit comments

Comments
 (0)