From 708527ed128f80d2c3b1a7c5b761a6df566b8e90 Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 27 May 2021 08:47:37 +1200 Subject: [PATCH] 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 --- Readme.md | 24 ++++++++++++---- examples/arguments-extra.js | 23 +++++++++++++++ lib/argument.js | 36 ++++++++++++++++++++++++ lib/help.js | 11 +++++++- tests/argument.chain.test.js | 21 ++++++++++++++ tests/argument.custom-processing.test.js | 12 ++++++++ tests/argument.variadic.test.js | 22 +++++++++++++++ tests/command.exitOverride.test.js | 17 +++++++++++ tests/command.help.test.js | 16 +++++++++++ tests/help.argumentDescription.test.js | 6 ++++ typings/index.d.ts | 24 +++++++++++++--- typings/index.test-d.ts | 12 ++++++++ 12 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 examples/arguments-extra.js create mode 100644 tests/argument.chain.test.js diff --git a/Readme.md b/Readme.md index 50250fed7..248bcf2d0 100644 --- a/Readme.md +++ b/Readme.md @@ -22,8 +22,9 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md) - [More configuration](#more-configuration) - [Custom option processing](#custom-option-processing) - [Commands](#commands) - - [Specify the argument syntax](#specify-the-argument-syntax) - - [Custom argument processing](#custom-argument-processing) + - [Command-arguments](#command-arguments) + - [More configuration](#more-configuration-1) + - [Custom argument processing](#custom-argument-processing) - [Action handler](#action-handler) - [Stand-alone executable (sub)commands](#stand-alone-executable-subcommands) - [Life cycle hooks](#life-cycle-hooks) @@ -33,7 +34,7 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md) - [.usage and .name](#usage-and-name) - [.helpOption(flags, description)](#helpoptionflags-description) - [.addHelpCommand()](#addhelpcommand) - - [More configuration](#more-configuration-1) + - [More configuration](#more-configuration-2) - [Custom event listeners](#custom-event-listeners) - [Bits and pieces](#bits-and-pieces) - [.parse() and .parseAsync()](#parse-and-parseasync) @@ -428,7 +429,7 @@ Configuration options can be passed with the call to `.command()` and `.addComma remove the command from the generated help output. Specifying `isDefault: true` will run the subcommand if no other subcommand is specified ([example](./examples/defaultCommand.js)). -### Specify the argument syntax +### Command-arguments For subcommands, you can specify the argument syntax in the call to `.command()` (as shown above). This 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 You supply the argument name and an optional description. The argument may be `` or `[optional]`. You can specify a default value for an optional command-argument. - Example file: [argument.js](./examples/argument.js) ```js @@ -474,7 +474,19 @@ program .arguments(' '); ``` -### Custom argument processing +#### More configuration + +There are some additional features available by constructing an `Argument` explicitly for less common cases. + +Example file: [arguments-extra.js](./examples/arguments-extra.js) + +```js +program + .addArgument(new commander.Argument('', 'drink cup size').choices(['small', 'medium', 'large'])) + .addArgument(new commander.Argument('[timeout]', 'timeout in seconds').default(60, 'one minute')) +``` + +#### Custom argument processing You may specify a function to do custom processing of command-arguments before they are passed to the action handler. The callback function receives two parameters, the user specified command-argument and the previous value for the argument. diff --git a/examples/arguments-extra.js b/examples/arguments-extra.js new file mode 100644 index 000000000..80cebbe81 --- /dev/null +++ b/examples/arguments-extra.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +// This is used as an example in the README for extra argument features. + +// const commander = require('commander'); // (normal include) +const commander = require('../'); // include commander in git clone of commander repo +const program = new commander.Command(); + +program + .addArgument(new commander.Argument('', 'drink cup size').choices(['small', 'medium', 'large'])) + .addArgument(new commander.Argument('[timeout]', 'timeout in seconds').default(60, 'one minute')) + .action((drinkSize, timeout) => { + console.log(`Drink size: ${drinkSize}`); + console.log(`Timeout (s): ${timeout}`); + }); + +program.parse(); + +// Try the following: +// node arguments-extra.js --help +// node arguments-extra.js huge +// node arguments-extra.js small +// node arguments-extra.js medium 30 diff --git a/lib/argument.js b/lib/argument.js index 8f8ab419a..ac37ce476 100644 --- a/lib/argument.js +++ b/lib/argument.js @@ -1,3 +1,5 @@ +const { InvalidArgumentError } = require('./error.js'); + // @ts-check class Argument { @@ -16,6 +18,7 @@ class Argument { this.parseArg = undefined; this.defaultValue = undefined; this.defaultValueDescription = undefined; + this.argChoices = undefined; switch (name[0]) { case '<': // e.g. @@ -48,6 +51,18 @@ class Argument { return this._name; }; + /** + * @api private + */ + + _concatValue(value, previous) { + if (previous === this.defaultValue || !Array.isArray(previous)) { + return [value]; + } + + return previous.concat(value); + } + /** * Set the default value, and optionally supply the description to be displayed in the help. * @@ -73,6 +88,27 @@ class Argument { this.parseArg = fn; return this; }; + + /** + * Only allow option value to be one of choices. + * + * @param {string[]} values + * @return {Argument} + */ + + choices(values) { + this.argChoices = values; + this.parseArg = (arg, previous) => { + if (!values.includes(arg)) { + throw new InvalidArgumentError(`Allowed choices are ${values.join(', ')}.`); + } + if (this.variadic) { + return this._concatValue(arg, previous); + } + return arg; + }; + return this; + }; } /** diff --git a/lib/help.js b/lib/help.js index 99d8f0ee7..61895258f 100644 --- a/lib/help.js +++ b/lib/help.js @@ -260,11 +260,20 @@ class Help { argumentDescription(argument) { const extraInfo = []; + if (argument.argChoices) { + extraInfo.push( + // use stringify to match the display of the default value + `choices: ${argument.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`); + } if (argument.defaultValue !== undefined) { extraInfo.push(`default: ${argument.defaultValueDescription || JSON.stringify(argument.defaultValue)}`); } if (extraInfo.length > 0) { - return `${argument.description} (${extraInfo.join(', ')})`; + const extraDescripton = `(${extraInfo.join(', ')})`; + if (argument.description) { + return `${argument.description} ${extraDescripton}`; + } + return extraDescripton; } return argument.description; } diff --git a/tests/argument.chain.test.js b/tests/argument.chain.test.js new file mode 100644 index 000000000..55f855772 --- /dev/null +++ b/tests/argument.chain.test.js @@ -0,0 +1,21 @@ +const { Argument } = require('../'); + +describe('Argument methods that should return this for chaining', () => { + test('when call .default() then returns this', () => { + const argument = new Argument(''); + const result = argument.default(3); + expect(result).toBe(argument); + }); + + test('when call .argParser() then returns this', () => { + const argument = new Argument(''); + const result = argument.argParser(() => { }); + expect(result).toBe(argument); + }); + + test('when call .choices() then returns this', () => { + const argument = new Argument(''); + const result = argument.choices(['a']); + expect(result).toBe(argument); + }); +}); diff --git a/tests/argument.custom-processing.test.js b/tests/argument.custom-processing.test.js index 509d9c111..bbc11b864 100644 --- a/tests/argument.custom-processing.test.js +++ b/tests/argument.custom-processing.test.js @@ -163,3 +163,15 @@ test('when custom processing for argument throws plain error then not CommanderE expect(caughtErr).toBeInstanceOf(Error); expect(caughtErr).not.toBeInstanceOf(commander.CommanderError); }); + +// this is the happy path, testing failure case in command.exitOverride.test.js +test('when argument argument in choices then argument set', () => { + const program = new commander.Command(); + let shade; + program + .exitOverride() + .addArgument(new commander.Argument('').choices(['red', 'blue'])) + .action((shadeParam) => { shade = shadeParam; }); + program.parse(['red'], { from: 'user' }); + expect(shade).toBe('red'); +}); diff --git a/tests/argument.variadic.test.js b/tests/argument.variadic.test.js index e1c452b1d..5a7a19874 100644 --- a/tests/argument.variadic.test.js +++ b/tests/argument.variadic.test.js @@ -81,4 +81,26 @@ describe('variadic argument', () => { expect(program.usage()).toBe('[options] [args...]'); }); + + test('when variadic used with choices and one value then set in array', () => { + const program = new commander.Command(); + let passedArg; + program + .addArgument(new commander.Argument('').choices(['one', 'two'])) + .action((value) => { passedArg = value; }); + + program.parse(['one'], { from: 'user' }); + expect(passedArg).toEqual(['one']); + }); + + test('when variadic used with choices and two values then set in array', () => { + const program = new commander.Command(); + let passedArg; + program + .addArgument(new commander.Argument('').choices(['one', 'two'])) + .action((value) => { passedArg = value; }); + + program.parse(['one', 'two'], { from: 'user' }); + expect(passedArg).toEqual(['one', 'two']); + }); }); diff --git a/tests/command.exitOverride.test.js b/tests/command.exitOverride.test.js index e40856047..dd6a5b1d9 100644 --- a/tests/command.exitOverride.test.js +++ b/tests/command.exitOverride.test.js @@ -275,6 +275,23 @@ describe('.exitOverride and error details', () => { expectCommanderError(caughtErr, 1, 'commander.invalidArgument', "error: option '--colour ' argument 'green' is invalid. Allowed choices are red, blue."); }); + test('when command argument not in choices then throw CommanderError', () => { + const program = new commander.Command(); + program + .exitOverride() + .addArgument(new commander.Argument('').choices(['red', 'blue'])) + .action(() => {}); + + let caughtErr; + try { + program.parse(['green'], { from: 'user' }); + } catch (err) { + caughtErr = err; + } + + expectCommanderError(caughtErr, 1, 'commander.invalidArgument', "error: command-argument value 'green' is invalid for argument 'shade'. Allowed choices are red, blue."); + }); + test('when custom processing for option throws InvalidArgumentError then catch CommanderError', () => { function justSayNo(value) { throw new commander.InvalidArgumentError('NO'); diff --git a/tests/command.help.test.js b/tests/command.help.test.js index ce76cbfa4..c7745bfd7 100644 --- a/tests/command.help.test.js +++ b/tests/command.help.test.js @@ -281,3 +281,19 @@ test('when arguments described in deprecated way and empty description then argu const helpInformation = program.helpInformation(); expect(helpInformation).toMatch(/Arguments:\n +file +input source/); }); + +test('when argument has choices then choices included in helpInformation', () => { + const program = new commander.Command(); + program + .addArgument(new commander.Argument('', 'preferred colour').choices(['red', 'blue'])); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch('(choices: "red", "blue")'); +}); + +test('when argument has choices and default then both included in helpInformation', () => { + const program = new commander.Command(); + program + .addArgument(new commander.Argument('', 'preferred colour').choices(['red', 'blue']).default('red')); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch('(choices: "red", "blue", default: "red")'); +}); diff --git a/tests/help.argumentDescription.test.js b/tests/help.argumentDescription.test.js index a2ada4ec9..14d1ffa5b 100644 --- a/tests/help.argumentDescription.test.js +++ b/tests/help.argumentDescription.test.js @@ -27,4 +27,10 @@ describe('argumentDescription', () => { const helper = new commander.Help(); expect(helper.argumentDescription(argument)).toEqual('description (default: custom)'); }); + + test('when an argument has default value and no description then still return default value', () => { + const argument = new commander.Argument('[n]').default('default'); + const helper = new commander.Help(); + expect(helper.argumentDescription(argument)).toEqual('(default: "default")'); + }); }); diff --git a/typings/index.d.ts b/typings/index.d.ts index abeab2be9..0814eb050 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -46,10 +46,26 @@ export class Argument { */ constructor(arg: string, description?: string); - /** - * Return argument name. - */ - name(): string; + /** + * Return argument name. + */ + name(): string; + + /** + * Set the default value, and optionally supply the description to be displayed in the help. + */ + default(value: unknown, description?: string): this; + + /** + * Set the custom handler for processing CLI command arguments into argument values. + */ + argParser(fn: (value: string, previous: T) => T): this; + + /** + * Only allow argument value to be one of choices. + */ + choices(values: string[]): this; + } export class Option { diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index a5edb6cf4..bc45289ee 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -361,9 +361,21 @@ expectType(baseArgument.required); expectType(baseArgument.variadic); // Argument methods + // name expectType(baseArgument.name()); +// default +expectType(baseArgument.default(3)); +expectType(baseArgument.default(60, 'one minute')); + +// argParser +expectType(baseArgument.argParser((value: string) => parseInt(value))); +expectType(baseArgument.argParser((value: string, previous: string[]) => { return previous.concat(value); })); + +// choices +expectType(baseArgument.choices(['a', 'b'])); + // createArgument expectType(program.createArgument('')); expectType(program.createArgument('', 'description'));