From 50963376160199a94212d10fa187501cb475e79c Mon Sep 17 00:00:00 2001 From: John Gee Date: Wed, 21 Apr 2021 00:38:41 +1200 Subject: [PATCH 01/16] First cut at adding default value and parse for Argument --- index.js | 70 +++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/index.js b/index.js index 3b17151c9..e528b7eac 100644 --- a/index.js +++ b/index.js @@ -385,6 +385,9 @@ class Argument { constructor(name, description) { this.description = description || ''; this.variadic = false; + this.parseArg = undefined; + this.defaultValue = undefined; + this.defaultValueDescription = undefined; switch (name[0]) { case '<': // e.g. @@ -416,6 +419,32 @@ class Argument { name() { return this._name; }; + + /** + * Set the default value, and optionally supply the description to be displayed in the help. + * + * @param {any} value + * @param {string} [description] + * @return {Option} + */ + + default(value, description) { + this.defaultValue = value; + this.defaultValueDescription = description; + return this; + }; + + /** + * Set the custom handler for processing CLI option arguments into option values. + * + * @param {Function} [fn] + * @return {Option} + */ + + argParser(fn) { + this.parseArg = fn; + return this; + }; } class Option { @@ -864,10 +893,17 @@ class Command extends EventEmitter { * * @param {string} name * @param {string} [description] + * @param {Function|*} [fn] - custom argument processing function + * @param {*} [defaultValue] Not implemented yet * @return {Command} `this` command for chaining */ - argument(name, description) { + argument(name, description, fn, defaultValue) { const argument = this.createArgument(name, description); + if (typeof fn === 'function') { + argument.default(defaultValue).argParser(fn); + } else { + argument.default(fn); + } this.addArgument(argument); return this; } @@ -1594,13 +1630,31 @@ class Command extends EventEmitter { if (this._actionHandler) { checkForUnknownOptions(); checkNumberOfArguments(); - // Collect trailing args into variadic. - let actionArgs = this.args; - const declaredArgCount = this._args.length; - if (declaredArgCount > 0 && this._args[declaredArgCount - 1].variadic) { - actionArgs = this.args.slice(0, declaredArgCount - 1); - actionArgs[declaredArgCount - 1] = this.args.slice(declaredArgCount - 1); - } + + // Move this into routine, say _getActionArgs + const actionArgs = []; + this._args.forEach((declaredArg, index) => { + let value = declaredArg.defaultValue; + if (declaredArg.variadic) { + if (index < this.args.length) { + value = this.args.slice(index); + if (declaredArg.parseArg) { + value = value.reduce((processed, v) => { + return declaredArg.parseArg(v, processed === undefined ? declaredArg.defaultValue : processed); + }, undefined); + } + } else if (value === undefined) { + value = []; + } + } else if (index < this.args.length) { + value = this.args[index]; + if (declaredArg.parseArg) { + value = declaredArg.parseArg(value, declaredArg.defaultValue); + } + } + actionArgs[index] = value; + }); + this._actionHandler(actionArgs); if (this.parent) this.parent.emit(commandEvent, operands, unknown); // legacy } else if (this.parent && this.parent.listenerCount(commandEvent)) { From 085c4088de52fd2155466702ecedfd86b88c5a9a Mon Sep 17 00:00:00 2001 From: John Gee Date: Wed, 21 Apr 2021 08:41:25 +1200 Subject: [PATCH 02/16] Simplify reduce call, good match for pattern --- index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index e528b7eac..ba9fdf39c 100644 --- a/index.js +++ b/index.js @@ -1640,8 +1640,8 @@ class Command extends EventEmitter { value = this.args.slice(index); if (declaredArg.parseArg) { value = value.reduce((processed, v) => { - return declaredArg.parseArg(v, processed === undefined ? declaredArg.defaultValue : processed); - }, undefined); + return declaredArg.parseArg(v, processed); + }, declaredArg.defaultValue); } } else if (value === undefined) { value = []; From 6df96ef093d68fb6a95948d594033ec59cdff890 Mon Sep 17 00:00:00 2001 From: John Gee Date: Wed, 21 Apr 2021 19:34:54 +1200 Subject: [PATCH 03/16] Refactor new code into routine --- index.js | 67 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/index.js b/index.js index ba9fdf39c..42c93cdc1 100644 --- a/index.js +++ b/index.js @@ -425,7 +425,7 @@ class Argument { * * @param {any} value * @param {string} [description] - * @return {Option} + * @return {Argument} */ default(value, description) { @@ -438,7 +438,7 @@ class Argument { * Set the custom handler for processing CLI option arguments into option values. * * @param {Function} [fn] - * @return {Option} + * @return {Argument} */ argParser(fn) { @@ -1561,6 +1561,7 @@ class Command extends EventEmitter { /** * @api private */ + _dispatchSubcommand(commandName, operands, unknown) { const subCommand = this._findCommand(commandName); if (!subCommand) this.help({ error: true }); @@ -1572,6 +1573,41 @@ class Command extends EventEmitter { } }; + /** + * Package arguments (this.args) for passing to action handler based + * on declared arguments (this._args). + * + * @api private + */ + + _getActionArguments() { + const actionArgs = []; + this._args.forEach((declaredArg, index) => { + let value = declaredArg.defaultValue; + if (declaredArg.variadic) { + // Collect together remaining arguments for passing together as an array. + if (index < this.args.length) { + value = this.args.slice(index); + if (declaredArg.parseArg) { + value = value.reduce((processed, v) => { + return declaredArg.parseArg(v, processed); + }, declaredArg.defaultValue); + } + } else if (value === undefined) { + value = []; + } + } else if (index < this.args.length) { + value = this.args[index]; + if (declaredArg.parseArg) { + // defaultValue passed for consistency, albeit not likely to be useful. + value = declaredArg.parseArg(value, declaredArg.defaultValue); + } + } + actionArgs[index] = value; + }); + return actionArgs; + } + /** * Process arguments in context of this command. * @@ -1630,32 +1666,7 @@ class Command extends EventEmitter { if (this._actionHandler) { checkForUnknownOptions(); checkNumberOfArguments(); - - // Move this into routine, say _getActionArgs - const actionArgs = []; - this._args.forEach((declaredArg, index) => { - let value = declaredArg.defaultValue; - if (declaredArg.variadic) { - if (index < this.args.length) { - value = this.args.slice(index); - if (declaredArg.parseArg) { - value = value.reduce((processed, v) => { - return declaredArg.parseArg(v, processed); - }, declaredArg.defaultValue); - } - } else if (value === undefined) { - value = []; - } - } else if (index < this.args.length) { - value = this.args[index]; - if (declaredArg.parseArg) { - value = declaredArg.parseArg(value, declaredArg.defaultValue); - } - } - actionArgs[index] = value; - }); - - this._actionHandler(actionArgs); + this._actionHandler(this._getActionArguments()); if (this.parent) this.parent.emit(commandEvent, operands, unknown); // legacy } else if (this.parent && this.parent.listenerCount(commandEvent)) { checkForUnknownOptions(); From fb14cc789e96e5115248547d54869b7beade62bf Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 29 Apr 2021 13:02:12 +1200 Subject: [PATCH 04/16] Add custom processing argument tests --- tests/argument.custom-processing.test.js | 122 +++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/argument.custom-processing.test.js diff --git a/tests/argument.custom-processing.test.js b/tests/argument.custom-processing.test.js new file mode 100644 index 000000000..f8d107025 --- /dev/null +++ b/tests/argument.custom-processing.test.js @@ -0,0 +1,122 @@ +const commander = require('../'); + +test('when argument not specified then callback not called', () => { + const mockCoercion = jest.fn(); + const program = new commander.Command(); + program + .argument('[n]', 'number', mockCoercion) + .action(() => {}); + program.parse([], { from: 'user' }); + expect(mockCoercion).not.toHaveBeenCalled(); +}); + +test('when argument not specified then action argument undefined', () => { + let actionValue = 'foo'; + const program = new commander.Command(); + program + .argument('[n]', 'number', parseFloat) + .action((arg) => { + actionValue = arg; + }); + program.parse([], { from: 'user' }); + expect(actionValue).toBeUndefined(); +}); + +test('when starting value is defined and argument not specified then callback not called', () => { + const mockCoercion = jest.fn(); + const program = new commander.Command(); + program + .argument('[n]', 'number', parseFloat, 1) + .action(() => {}); + program.parse([], { from: 'user' }); + expect(mockCoercion).not.toHaveBeenCalled(); +}); + +test('when starting value is defined and argument not specified then action argument is starting value', () => { + const startingValue = 1; + let actionValue; + const program = new commander.Command(); + program + .argument('[n]', 'number', parseFloat, startingValue) + .action((arg) => { + actionValue = arg; + }); + program.parse([], { from: 'user' }); + expect(actionValue).toEqual(startingValue); +}); + +test('when argument specified then callback called with value', () => { + const mockCoercion = jest.fn(); + const value = '1'; + const program = new commander.Command(); + program + .argument('[n]', 'number', mockCoercion) + .action(() => {}); + program.parse([value], { from: 'user' }); + expect(mockCoercion).toHaveBeenCalledWith(value, undefined); +}); + +test('when argument specified then action value is as returned from callback', () => { + const callbackResult = 2; + let actionValue; + const program = new commander.Command(); + program + .argument('[n]', 'number', () => { + return callbackResult; + }) + .action((arg) => { + actionValue = arg; + }); + program.parse(['node', 'test', 'alpha']); + expect(actionValue).toEqual(callbackResult); +}); + +test('when argument specified then program.args has original rather than custom', () => { + // This is as intended, so check behaviour. + const callbackResult = 2; + const program = new commander.Command(); + program + .argument('[n]', 'number', () => { + return callbackResult; + }) + .action(() => {}); + program.parse(['node', 'test', 'alpha']); + expect(program.args).toEqual(['alpha']); +}); + +test('when starting value is defined and argument specified then callback called with value and starting value', () => { + const mockCoercion = jest.fn(); + const startingValue = 1; + const value = '2'; + const program = new commander.Command() + .argument('[n]', 'number', mockCoercion, startingValue) + .action(() => {}); + program.parse(['node', 'test', value]); + expect(mockCoercion).toHaveBeenCalledWith(value, startingValue); +}); + +test('when variadic argument specified multiple times then callback called with value and previousValue', () => { + const mockCoercion = jest.fn().mockImplementation(() => { + return 'callback'; + }); + const program = new commander.Command(); + program + .argument('', 'number', mockCoercion) + .action(() => {}); + program.parse(['1', '2'], { from: 'user' }); + expect(mockCoercion).toHaveBeenCalledTimes(2); + expect(mockCoercion).toHaveBeenNthCalledWith(1, '1', undefined); + expect(mockCoercion).toHaveBeenNthCalledWith(2, '2', 'callback'); +}); + +test('when parseFloat "1e2" then value is 100', () => { + let actionValue; + const program = new commander.Command(); + program + .argument('', 'float argument', parseFloat) + .action((arg) => { + actionValue = arg; + }); + program.parse(['1e2'], { from: 'user' }); + expect(actionValue).toEqual(100); +}); From 2d986aa5faf7e9dc0163c5806833bd9ed607f6aa Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 29 Apr 2021 13:04:18 +1200 Subject: [PATCH 05/16] Add default value tests for argument --- tests/argument.custom-processing.test.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/argument.custom-processing.test.js b/tests/argument.custom-processing.test.js index f8d107025..b0c1a591c 100644 --- a/tests/argument.custom-processing.test.js +++ b/tests/argument.custom-processing.test.js @@ -45,6 +45,19 @@ test('when starting value is defined and argument not specified then action argu expect(actionValue).toEqual(startingValue); }); +test('when default value is defined (without custom processing) and argument not specified then action argument is default value', () => { + const defaultValue = 1; + let actionValue; + const program = new commander.Command(); + program + .argument('[n]', 'number', defaultValue) + .action((arg) => { + actionValue = arg; + }); + program.parse([], { from: 'user' }); + expect(actionValue).toEqual(defaultValue); +}); + test('when argument specified then callback called with value', () => { const mockCoercion = jest.fn(); const value = '1'; From e7205026b6c70cbdf4429096e4e8340e7f72fa77 Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 29 Apr 2021 13:09:08 +1200 Subject: [PATCH 06/16] Rename tests --- tests/argument.custom-processing.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/argument.custom-processing.test.js b/tests/argument.custom-processing.test.js index b0c1a591c..82cacd323 100644 --- a/tests/argument.custom-processing.test.js +++ b/tests/argument.custom-processing.test.js @@ -22,7 +22,7 @@ test('when argument not specified then action argument undefined', () => { expect(actionValue).toBeUndefined(); }); -test('when starting value is defined and argument not specified then callback not called', () => { +test('when custom with starting value and argument not specified then callback not called', () => { const mockCoercion = jest.fn(); const program = new commander.Command(); program @@ -32,7 +32,7 @@ test('when starting value is defined and argument not specified then callback no expect(mockCoercion).not.toHaveBeenCalled(); }); -test('when starting value is defined and argument not specified then action argument is starting value', () => { +test('when custom with starting value and argument not specified then action argument is starting value', () => { const startingValue = 1; let actionValue; const program = new commander.Command(); @@ -97,7 +97,7 @@ test('when argument specified then program.args has original rather than custom' expect(program.args).toEqual(['alpha']); }); -test('when starting value is defined and argument specified then callback called with value and starting value', () => { +test('when custom with starting value and argument specified then callback called with value and starting value', () => { const mockCoercion = jest.fn(); const startingValue = 1; const value = '2'; @@ -122,7 +122,7 @@ test('when variadic argument specified multiple times then callback called with expect(mockCoercion).toHaveBeenNthCalledWith(2, '2', 'callback'); }); -test('when parseFloat "1e2" then value is 100', () => { +test('when parseFloat "1e2" then action argument is 100', () => { let actionValue; const program = new commander.Command(); program From 2435b1baf570b85795f254b0b299577ff3d9818c Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 29 Apr 2021 13:51:15 +1200 Subject: [PATCH 07/16] Add error for ignored default value --- index.js | 5 ++++- tests/argument.custom-processing.test.js | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 42c93cdc1..df054ae05 100644 --- a/index.js +++ b/index.js @@ -937,7 +937,10 @@ class Command extends EventEmitter { addArgument(argument) { const previousArgument = this._args.slice(-1)[0]; if (previousArgument && previousArgument.variadic) { - throw new Error(`only the last argument can be variadic '${previousArgument.name()}'`); + throw new Error(`only the last argument can be variadic: '${previousArgument.name()}'`); + } + if (argument.required && argument.defaultValue !== undefined && argument.parseArg === undefined) { + throw new Error(`a default value for a required argument is never used: '${argument.name()}'`); } this._args.push(argument); return this; diff --git a/tests/argument.custom-processing.test.js b/tests/argument.custom-processing.test.js index 82cacd323..480fa73d6 100644 --- a/tests/argument.custom-processing.test.js +++ b/tests/argument.custom-processing.test.js @@ -1,5 +1,7 @@ const commander = require('../'); +// Testing default value and custom processing behaviours. + test('when argument not specified then callback not called', () => { const mockCoercion = jest.fn(); const program = new commander.Command(); @@ -133,3 +135,10 @@ test('when parseFloat "1e2" then action argument is 100', () => { program.parse(['1e2'], { from: 'user' }); expect(actionValue).toEqual(100); }); + +test('when defined default value for required argument then throw', () => { + const program = new commander.Command(); + expect(() => { + program.argument('', 'float argument', 4); + }).toThrow(); +}); From f33b02b452980509d04bc8d02ea7d4620efd2241 Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 29 Apr 2021 16:51:45 +1200 Subject: [PATCH 08/16] Add argument default to help information --- index.js | 9 +++++++- tests/command.help.test.js | 9 ++++++++ tests/help.argumentDescription.test.js | 30 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/help.argumentDescription.test.js diff --git a/index.js b/index.js index df054ae05..8de525a50 100644 --- a/index.js +++ b/index.js @@ -258,6 +258,13 @@ class Help { */ argumentDescription(argument) { + const extraInfo = []; + if (argument.defaultValue !== undefined) { + extraInfo.push(`default: ${argument.defaultValueDescription || JSON.stringify(argument.defaultValue)}`); + } + if (extraInfo.length > 0) { + return `${argument.description} (${extraInfo.join(', ')})`; + } return argument.description; } @@ -937,7 +944,7 @@ class Command extends EventEmitter { addArgument(argument) { const previousArgument = this._args.slice(-1)[0]; if (previousArgument && previousArgument.variadic) { - throw new Error(`only the last argument can be variadic: '${previousArgument.name()}'`); + throw new Error(`only the last argument can be variadic '${previousArgument.name()}'`); } if (argument.required && argument.defaultValue !== undefined && argument.parseArg === undefined) { throw new Error(`a default value for a required argument is never used: '${argument.name()}'`); diff --git a/tests/command.help.test.js b/tests/command.help.test.js index ec1ba4172..ce76cbfa4 100644 --- a/tests/command.help.test.js +++ b/tests/command.help.test.js @@ -253,6 +253,15 @@ test('when argument described then included in helpInformation', () => { expect(helpInformation).toMatch(/Arguments:\n +file +input source/); }); +test('when argument described with default then included in helpInformation', () => { + const program = new commander.Command(); + program + .argument('[file]', 'input source', 'test.txt') + .helpOption(false); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch(/Arguments:\n +file +input source \(default: "test.txt"\)/); +}); + test('when arguments described in deprecated way then included in helpInformation', () => { const program = new commander.Command(); program diff --git a/tests/help.argumentDescription.test.js b/tests/help.argumentDescription.test.js new file mode 100644 index 000000000..5ac112ecc --- /dev/null +++ b/tests/help.argumentDescription.test.js @@ -0,0 +1,30 @@ +const commander = require('../'); + +// These are tests of the Help class, not of the Command help. + +describe('argumentDescription', () => { + test('when argument has no description then empty string', () => { + const argument = new commander.Argument(''); + const helper = new commander.Help(); + expect(helper.argumentDescription(argument)).toEqual(''); + }); + + test('when argument has description then return description', () => { + const description = 'description'; + const argument = new commander.Argument('', description); + const helper = new commander.Help(); + expect(helper.argumentDescription(argument)).toEqual(description); + }); + + test('when argument has default value then return description and default value', () => { + const argument = new commander.Argument('', 'description').default('default'); + const helper = new commander.Help(); + expect(helper.argumentDescription(argument)).toEqual('description (default: "default")'); + }); + + test('when argument has default value description then return description and custom default description', () => { + const argument = new commander.Argument('', 'description').default('default value', 'custom'); + const helper = new commander.Help(); + expect(helper.argumentDescription(argument)).toEqual('description (default: custom)'); + }); +}); From 24080412bdd544f3a004b5276a07f60fe1806a33 Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 29 Apr 2021 16:52:42 +1200 Subject: [PATCH 09/16] Default only makes sense for optional argument --- tests/help.argumentDescription.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/help.argumentDescription.test.js b/tests/help.argumentDescription.test.js index 5ac112ecc..a2ada4ec9 100644 --- a/tests/help.argumentDescription.test.js +++ b/tests/help.argumentDescription.test.js @@ -4,26 +4,26 @@ const commander = require('../'); describe('argumentDescription', () => { test('when argument has no description then empty string', () => { - const argument = new commander.Argument(''); + const argument = new commander.Argument('[n]'); const helper = new commander.Help(); expect(helper.argumentDescription(argument)).toEqual(''); }); test('when argument has description then return description', () => { const description = 'description'; - const argument = new commander.Argument('', description); + const argument = new commander.Argument('[n]', description); const helper = new commander.Help(); expect(helper.argumentDescription(argument)).toEqual(description); }); test('when argument has default value then return description and default value', () => { - const argument = new commander.Argument('', 'description').default('default'); + const argument = new commander.Argument('[n]', 'description').default('default'); const helper = new commander.Help(); expect(helper.argumentDescription(argument)).toEqual('description (default: "default")'); }); test('when argument has default value description then return description and custom default description', () => { - const argument = new commander.Argument('', 'description').default('default value', 'custom'); + const argument = new commander.Argument('[n]', 'description').default('default value', 'custom'); const helper = new commander.Help(); expect(helper.argumentDescription(argument)).toEqual('description (default: custom)'); }); From 9e8000f265bfe7af3c1ef353a3580502f6a2ca68 Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 29 Apr 2021 17:04:13 +1200 Subject: [PATCH 10/16] Extend argument typings --- typings/index.d.ts | 3 ++- typings/index.test-d.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/typings/index.d.ts b/typings/index.d.ts index 904c48704..2b0855be9 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -270,7 +270,8 @@ declare namespace commander { * * @returns `this` command for chaining */ - argument(name: string, description?: string): this; + argument(flags: string, description: string, fn: (value: string, previous: T) => T, defaultValue?: T): this; + argument(name: string, description?: string, defaultValue?: any): this; /** * Define argument syntax for command, adding a prepared argument. diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index 4e42c5854..cc87203a8 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -40,6 +40,9 @@ expectType(program.addCommand(new commander.Command('abc'))); // argument expectType(program.argument('')); expectType(program.argument('', 'description')); +expectType(program.argument('[value]', 'description', 'default')); +expectType(program.argument('[value]', 'description', parseFloat)); +expectType(program.argument('[value]', 'description', parseFloat, 1.23)); // arguments expectType(program.arguments(' [env]')); From e398237a1f8eb804c082519cd42f87ad866b9706 Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 29 Apr 2021 17:27:14 +1200 Subject: [PATCH 11/16] Add argument custom processing to README --- Readme.md | 27 ++++++++++++++++++-- examples/argument.js | 4 +-- examples/arguments-custom-processing.js | 33 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 examples/arguments-custom-processing.js diff --git a/Readme.md b/Readme.md index ce3f25d59..840246331 100644 --- a/Readme.md +++ b/Readme.md @@ -23,6 +23,7 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md) - [Custom option processing](#custom-option-processing) - [Commands](#commands) - [Specify the argument syntax](#specify-the-argument-syntax) + - [Custom argument processing](#custom-argument-processing) - [Action handler](#action-handler) - [Stand-alone executable (sub)commands](#stand-alone-executable-subcommands) - [Automated help](#automated-help) @@ -434,6 +435,8 @@ you can instead use the following method. To configure a command, you can use `.argument()` to specify each expected command-argument. 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) @@ -441,10 +444,10 @@ Example file: [argument.js](./examples/argument.js) program .version('0.1.0') .argument('', 'user to login') - .argument('[password]', 'password for user, if required') + .argument('[password]', 'password for user, if required', 'no password given') .action((username, password) => { console.log('username:', username); - console.log('password:', password || 'no password given'); + console.log('password:', password); }); ``` @@ -470,6 +473,26 @@ program .arguments(' '); ``` +### 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. +It returns the new value for the argument. + +You can optionally specify the default/starting value for the argument after the function parameter. + +Example file: [arguments-custom-processing.js](./examples/arguments-custom-processing.js) + +```js +program + .argument('', 'integer argument', myParseInt) + .argument('[second]', 'integer argument', myParseInt, 1000) + .action((first, second) => { + console.log(`${first} + ${second} = ${first + second}`); + }) +; +``` + ### Action handler The action handler gets passed a parameter for each command-argument you declared, and two additional parameters diff --git a/examples/argument.js b/examples/argument.js index 83c779eae..30515e919 100644 --- a/examples/argument.js +++ b/examples/argument.js @@ -9,11 +9,11 @@ const program = new Command(); program .version('0.1.0') .argument('', 'user to login') - .argument('[password]', 'password for user, if required') + .argument('[password]', 'password for user, if required', 'no password given') .description('example program for argument') .action((username, password) => { console.log('username:', username); - console.log('password:', password || 'no password given'); + console.log('password:', password); }); program.parse(); diff --git a/examples/arguments-custom-processing.js b/examples/arguments-custom-processing.js new file mode 100644 index 000000000..3c5d6e099 --- /dev/null +++ b/examples/arguments-custom-processing.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +// This is used as an example in the README for: +// Custom argument processing +// You may specify a function to do custom processing of argument values. + +// const commander = require('commander'); // (normal include) +const commander = require('../'); // include commander in git clone of commander repo +const program = new commander.Command(); + +function myParseInt(value, dummyPrevious) { + // parseInt takes a string and a radix + const parsedValue = parseInt(value, 10); + // if (isNaN(parsedValue)) { + // throw new commander.InvalidOptionArgumentError('Not a number.'); + // } + return parsedValue; +} + +program + .argument('', 'integer argument', myParseInt) + .argument('[second]', 'integer argument', myParseInt, 1000) + .action((first, second) => { + console.log(`${first} + ${second} = ${first + second}`); + }) +; + +program.parse(); + +// Try the following: +// node arguments-custom-processing --help +// node arguments-custom-processing 2 +// node arguments-custom-processing 12 56 From fea1797cb2841cbe37908643620464c33bd68817 Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 29 Apr 2021 23:39:08 +1200 Subject: [PATCH 12/16] Add InvalidArgumentError and deprecate InvalidOptionArgumentError --- Readme.md | 2 +- Readme_zh-CN.md | 4 +-- docs/deprecated.md | 29 ++++++++++++++++++++ esm.mjs | 2 +- examples/arguments-custom-processing.js | 6 ++--- examples/options-custom-processing.js | 2 +- index.js | 36 ++++++++++++++++++------- tests/command.exitOverride.test.js | 28 ++++++++++++++++--- tests/esm-imports-test.mjs | 4 +-- tests/ts-imports.test.ts | 11 +++++--- typings/index.d.ts | 8 +++--- typings/index.test-d.ts | 3 ++- 12 files changed, 105 insertions(+), 30 deletions(-) diff --git a/Readme.md b/Readme.md index 840246331..c838514c3 100644 --- a/Readme.md +++ b/Readme.md @@ -343,7 +343,7 @@ function myParseInt(value, dummyPrevious) { // parseInt takes a string and a radix const parsedValue = parseInt(value, 10); if (isNaN(parsedValue)) { - throw new commander.InvalidOptionArgumentError('Not a number.'); + throw new commander.InvalidArgumentError('Not a number.'); } return parsedValue; } diff --git a/Readme_zh-CN.md b/Readme_zh-CN.md index c3cfd2433..65865b7ab 100644 --- a/Readme_zh-CN.md +++ b/Readme_zh-CN.md @@ -314,7 +314,7 @@ function myParseInt(value, dummyPrevious) { // parseInt takes a string and a radix const parsedValue = parseInt(value, 10); if (isNaN(parsedValue)) { - throw new commander.InvalidOptionArgumentError('Not a number.'); + throw new commander.InvalidArgumentError('Not a number.'); } return parsedValue; } @@ -877,4 +877,4 @@ program.parse(process.argv); 现在 Commander 已作为 Tidelift 订阅的一部分。 -Commander 和很多其他包的维护者已与 Tidelift 合作,面向企业提供开源依赖的商业支持与维护。企业可以向相关依赖包的维护者支付一定的费用,帮助企业节省时间,降低风险,改进代码运行情况。[了解更多](https://tidelift.com/subscription/pkg/npm-commander?utm_source=npm-commander&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) \ No newline at end of file +Commander 和很多其他包的维护者已与 Tidelift 合作,面向企业提供开源依赖的商业支持与维护。企业可以向相关依赖包的维护者支付一定的费用,帮助企业节省时间,降低风险,改进代码运行情况。[了解更多](https://tidelift.com/subscription/pkg/npm-commander?utm_source=npm-commander&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) diff --git a/docs/deprecated.md b/docs/deprecated.md index d81d12073..148ba18b6 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -111,3 +111,32 @@ program Deprecated from Commander v8. +## InvalidOptionArgumentError + +This was used for throwing an error from custom option processing, for a nice error message. + +```js +function myParseInt(value, dummyPrevious) { + // parseInt takes a string and a radix + const parsedValue = parseInt(value, 10); + if (isNaN(parsedValue)) { + throw new commander.InvalidOptionArgumentError('Not a number.'); + } + return parsedValue; +} +``` + +The replacement is `InvalidArgumentError` since can be used now for custom command-argument processing too. + +```js +function myParseInt(value, dummyPrevious) { + // parseInt takes a string and a radix + const parsedValue = parseInt(value, 10); + if (isNaN(parsedValue)) { + throw new commander.InvalidArgumentError('Not a number.'); + } + return parsedValue; +} +``` + +Deprecated from Commander v8. diff --git a/esm.mjs b/esm.mjs index 5ed09724e..dc6bd11c9 100644 --- a/esm.mjs +++ b/esm.mjs @@ -7,7 +7,7 @@ export const { createArgument, createOption, CommanderError, - InvalidOptionArgumentError, + InvalidArgumentError, Command, Argument, Option, diff --git a/examples/arguments-custom-processing.js b/examples/arguments-custom-processing.js index 3c5d6e099..2a3140b41 100644 --- a/examples/arguments-custom-processing.js +++ b/examples/arguments-custom-processing.js @@ -11,9 +11,9 @@ const program = new commander.Command(); function myParseInt(value, dummyPrevious) { // parseInt takes a string and a radix const parsedValue = parseInt(value, 10); - // if (isNaN(parsedValue)) { - // throw new commander.InvalidOptionArgumentError('Not a number.'); - // } + if (isNaN(parsedValue)) { + throw new commander.InvalidArgumentError('Not a number.'); + } return parsedValue; } diff --git a/examples/options-custom-processing.js b/examples/options-custom-processing.js index 3e601e5df..1325fee3b 100755 --- a/examples/options-custom-processing.js +++ b/examples/options-custom-processing.js @@ -12,7 +12,7 @@ function myParseInt(value, dummyPrevious) { // parseInt takes a string and a radix const parsedValue = parseInt(value, 10); if (isNaN(parsedValue)) { - throw new commander.InvalidOptionArgumentError('Not a number.'); + throw new commander.InvalidArgumentError('Not a number.'); } return parsedValue; } diff --git a/index.js b/index.js index 8de525a50..12f94ea45 100644 --- a/index.js +++ b/index.js @@ -558,7 +558,7 @@ class Option { this.argChoices = values; this.parseArg = (arg, previous) => { if (!values.includes(arg)) { - throw new InvalidOptionArgumentError(`Allowed choices are ${values.join(', ')}.`); + throw new InvalidArgumentError(`Allowed choices are ${values.join(', ')}.`); } if (this.variadic) { return this._concatValue(arg, previous); @@ -630,17 +630,17 @@ class CommanderError extends Error { } /** - * InvalidOptionArgumentError class + * InvalidArgumentError class * @class */ -class InvalidOptionArgumentError extends CommanderError { +class InvalidArgumentError extends CommanderError { /** - * Constructs the InvalidOptionArgumentError class + * Constructs the InvalidArgumentError class * @param {string} [message] explanation of why argument is invalid * @constructor */ constructor(message) { - super(1, 'commander.invalidOptionArgument', message); + super(1, 'commander.invalidArgument', message); // properly capture stack trace in Node.js Error.captureStackTrace(this, this.constructor); this.name = this.constructor.name; @@ -1122,7 +1122,7 @@ class Command extends EventEmitter { try { val = option.parseArg(val, oldValue === undefined ? defaultValue : oldValue); } catch (err) { - if (err.code === 'commander.invalidOptionArgument') { + if (err.code === 'commander.invalidArgument') { const message = `error: option '${option.flags}' argument '${val}' is invalid. ${err.message}`; this._displayError(err.exitCode, err.code, message); } @@ -1591,6 +1591,23 @@ class Command extends EventEmitter { */ _getActionArguments() { + const myParseArg = (argument, value, previous) => { + // Extra processing for nice error message on parsing failure. + let parsedValue = value; + if (value !== null && argument.parseArg) { + try { + parsedValue = argument.parseArg(value, previous); + } catch (err) { + if (err.code === 'commander.invalidArgument') { + const message = `error: command-argument value '${value}' is invalid for argument '${argument.name()}'. ${err.message}`; + this._displayError(err.exitCode, err.code, message); + } + throw err; + } + } + return parsedValue; + }; + const actionArgs = []; this._args.forEach((declaredArg, index) => { let value = declaredArg.defaultValue; @@ -1600,7 +1617,7 @@ class Command extends EventEmitter { value = this.args.slice(index); if (declaredArg.parseArg) { value = value.reduce((processed, v) => { - return declaredArg.parseArg(v, processed); + return myParseArg(declaredArg, v, processed); }, declaredArg.defaultValue); } } else if (value === undefined) { @@ -1610,7 +1627,7 @@ class Command extends EventEmitter { value = this.args[index]; if (declaredArg.parseArg) { // defaultValue passed for consistency, albeit not likely to be useful. - value = declaredArg.parseArg(value, declaredArg.defaultValue); + value = myParseArg(declaredArg, value, declaredArg.defaultValue); } } actionArgs[index] = value; @@ -2279,7 +2296,8 @@ exports.Command = Command; exports.Option = Option; exports.Argument = Argument; exports.CommanderError = CommanderError; -exports.InvalidOptionArgumentError = InvalidOptionArgumentError; +exports.InvalidArgumentError = InvalidArgumentError; +exports.InvalidOptionArgumentError = InvalidArgumentError; // Deprecated exports.Help = Help; /** diff --git a/tests/command.exitOverride.test.js b/tests/command.exitOverride.test.js index 6bf3b134b..e40856047 100644 --- a/tests/command.exitOverride.test.js +++ b/tests/command.exitOverride.test.js @@ -272,12 +272,12 @@ describe('.exitOverride and error details', () => { caughtErr = err; } - expectCommanderError(caughtErr, 1, 'commander.invalidOptionArgument', "error: option '--colour ' argument 'green' is invalid. Allowed choices are red, blue."); + expectCommanderError(caughtErr, 1, 'commander.invalidArgument', "error: option '--colour ' argument 'green' is invalid. Allowed choices are red, blue."); }); - test('when custom processing throws InvalidOptionArgumentError then throw CommanderError', () => { + test('when custom processing for option throws InvalidArgumentError then catch CommanderError', () => { function justSayNo(value) { - throw new commander.InvalidOptionArgumentError('NO'); + throw new commander.InvalidArgumentError('NO'); } const optionFlags = '--colour '; const program = new commander.Command(); @@ -292,7 +292,27 @@ describe('.exitOverride and error details', () => { caughtErr = err; } - expectCommanderError(caughtErr, 1, 'commander.invalidOptionArgument', "error: option '--colour ' argument 'green' is invalid. NO"); + expectCommanderError(caughtErr, 1, 'commander.invalidArgument', "error: option '--colour ' argument 'green' is invalid. NO"); + }); + + test('when custom processing for argument throws InvalidArgumentError then catch CommanderError', () => { + function justSayNo(value) { + throw new commander.InvalidArgumentError('NO'); + } + const program = new commander.Command(); + program + .exitOverride() + .argument('[n]', 'number', justSayNo) + .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 'n'. NO"); }); }); diff --git a/tests/esm-imports-test.mjs b/tests/esm-imports-test.mjs index 03b25bb8c..1ad478ac6 100644 --- a/tests/esm-imports-test.mjs +++ b/tests/esm-imports-test.mjs @@ -1,4 +1,4 @@ -import { program, Command, Option, Argument, CommanderError, InvalidOptionArgumentError, Help, createCommand, createArgument, createOption } from '../esm.mjs'; +import { program, Command, Option, Argument, CommanderError, InvalidArgumentError, Help, createCommand, createArgument, createOption } from '../esm.mjs'; // Do some simple checks that expected imports are available at runtime. // Run using `npm run test-esm`. @@ -24,7 +24,7 @@ check(program.constructor.name === 'Command', 'program is class Command'); checkClass(new Command(), 'Command'); checkClass(new Option('-e, --example'), 'Option'); checkClass(new CommanderError(1, 'code', 'failed'), 'CommanderError'); -checkClass(new InvalidOptionArgumentError('failed'), 'InvalidOptionArgumentError'); +checkClass(new InvalidArgumentError('failed'), 'InvalidArgumentError'); checkClass(new Help(), 'Help'); checkClass(new Argument(''), 'Argument'); diff --git a/tests/ts-imports.test.ts b/tests/ts-imports.test.ts index 70030a082..955c155ab 100644 --- a/tests/ts-imports.test.ts +++ b/tests/ts-imports.test.ts @@ -1,4 +1,4 @@ -import { program, Command, Option, CommanderError, InvalidOptionArgumentError, Help, createCommand } from '../'; +import { program, Command, Option, CommanderError, InvalidArgumentError, InvalidOptionArgumentError, Help, createCommand } from '../'; import * as commander from '../'; @@ -35,10 +35,15 @@ test('CommanderError', () => { checkClass(new CommanderError(1, 'code', 'failed'), 'CommanderError'); }); -test('InvalidOptionArgumentError', () => { - checkClass(new InvalidOptionArgumentError('failed'), 'InvalidOptionArgumentError'); +test('InvalidArgumentError', () => { + checkClass(new InvalidArgumentError('failed'), 'InvalidArgumentError'); }); +test('InvalidOptionArgumentError', () => { // Deprecated + checkClass(new InvalidOptionArgumentError('failed'), 'InvalidArgumentError'); +}); + + test('Help', () => { checkClass(new Help(), 'Help'); }); diff --git a/typings/index.d.ts b/typings/index.d.ts index 2b0855be9..64e08d00d 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -16,9 +16,9 @@ declare namespace commander { type CommanderErrorConstructor = new (exitCode: number, code: string, message: string) => CommanderError; // eslint-disable-next-line @typescript-eslint/no-empty-interface - interface InvalidOptionArgumentError extends CommanderError { + interface InvalidArgumentError extends CommanderError { } - type InvalidOptionArgumentErrorConstructor = new (message: string) => InvalidOptionArgumentError; + type InvalidArgumentErrorConstructor = new (message: string) => InvalidArgumentError; interface Argument { description: string; @@ -672,7 +672,9 @@ declare namespace commander { Option: OptionConstructor; Argument: ArgumentConstructor; CommanderError: CommanderErrorConstructor; - InvalidOptionArgumentError: InvalidOptionArgumentErrorConstructor; + InvalidArgumentError: InvalidArgumentErrorConstructor; + /** @deprecated since v8, replaced by InvalidArgumentError */ + InvalidOptionArgumentError: InvalidArgumentErrorConstructor; Help: HelpConstructor; } diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index cc87203a8..0f5495952 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -15,7 +15,8 @@ expectType(new commander.Command()); expectType(new commander.Command('name')); expectType(new commander.Option('-f')); expectType(new commander.CommanderError(1, 'code', 'message')); -expectType(new commander.InvalidOptionArgumentError('message')); +expectType(new commander.InvalidArgumentError('message')); +expectType(new commander.InvalidOptionArgumentError('message')); expectType(commander.createCommand()); // Command properties From bae33f44ac9ad1e4da7f1f07f2c3d21aa14b7270 Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 30 Apr 2021 00:08:40 +1200 Subject: [PATCH 13/16] Fix JSDoc --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 12f94ea45..7ad359f55 100644 --- a/index.js +++ b/index.js @@ -442,7 +442,7 @@ class Argument { }; /** - * Set the custom handler for processing CLI option arguments into option values. + * Set the custom handler for processing CLI command arguments into argument values. * * @param {Function} [fn] * @return {Argument} From eb498b2e091b97e495dfe9d6ed4116355895ea9c Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 30 Apr 2021 15:39:19 +1200 Subject: [PATCH 14/16] Add example of varidic argument custom processing --- Readme.md | 1 + examples/arguments-custom-processing.js | 24 +++++++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/Readme.md b/Readme.md index c838514c3..39c532f10 100644 --- a/Readme.md +++ b/Readme.md @@ -485,6 +485,7 @@ Example file: [arguments-custom-processing.js](./examples/arguments-custom-proce ```js program + .command('add') .argument('', 'integer argument', myParseInt) .argument('[second]', 'integer argument', myParseInt, 1000) .action((first, second) => { diff --git a/examples/arguments-custom-processing.js b/examples/arguments-custom-processing.js index 2a3140b41..ac92855f6 100644 --- a/examples/arguments-custom-processing.js +++ b/examples/arguments-custom-processing.js @@ -17,17 +17,31 @@ function myParseInt(value, dummyPrevious) { return parsedValue; } +// The previous value passed to the custom processing is used when processing variadic values. +function mySum(value, total) { + return total + myParseInt(value); +} + program + .command('add') .argument('', 'integer argument', myParseInt) .argument('[second]', 'integer argument', myParseInt, 1000) .action((first, second) => { console.log(`${first} + ${second} = ${first + second}`); - }) -; + }); + +program + .command('sum') + .argument('', 'values to be summed', mySum, 0) + .action((total) => { + console.log(`sum is ${total}`); + }); program.parse(); // Try the following: -// node arguments-custom-processing --help -// node arguments-custom-processing 2 -// node arguments-custom-processing 12 56 +// node arguments-custom-processing add --help +// node arguments-custom-processing add 2 +// node arguments-custom-processing add 12 56 +// node arguments-custom-processing sum 1 2 3 +// node arguments-custom-processing sum silly From caea829a7879d4671779ab5d9ddb790d177c60b9 Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 30 Apr 2021 15:48:15 +1200 Subject: [PATCH 15/16] Update comment --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 7ad359f55..fe7b47de2 100644 --- a/index.js +++ b/index.js @@ -901,7 +901,7 @@ class Command extends EventEmitter { * @param {string} name * @param {string} [description] * @param {Function|*} [fn] - custom argument processing function - * @param {*} [defaultValue] Not implemented yet + * @param {*} [defaultValue] * @return {Command} `this` command for chaining */ argument(name, description, fn, defaultValue) { From c37ef7103459c243c284968f3ae723697d39e1c3 Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 30 Apr 2021 15:51:26 +1200 Subject: [PATCH 16/16] Remove low value comment --- index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/index.js b/index.js index fe7b47de2..9ae19a90d 100644 --- a/index.js +++ b/index.js @@ -1626,7 +1626,6 @@ class Command extends EventEmitter { } else if (index < this.args.length) { value = this.args[index]; if (declaredArg.parseArg) { - // defaultValue passed for consistency, albeit not likely to be useful. value = myParseArg(declaredArg, value, declaredArg.defaultValue); } }