Skip to content

Commit

Permalink
Support argument processing without action handler (#1529)
Browse files Browse the repository at this point in the history
* Process command-arguments without needing action handler

* Add some test for new behaviours without action handler

* Modify README
  • Loading branch information
shadowspawn committed Jun 2, 2021
1 parent 082717f commit 4663597
Show file tree
Hide file tree
Showing 6 changed files with 94 additions and 31 deletions.
4 changes: 3 additions & 1 deletion Readme.md
Expand Up @@ -489,10 +489,12 @@ program
#### Custom argument processing
You may specify a function to do custom processing of command-arguments before they are passed to the action handler.
You may specify a function to do custom processing of command-arguments (like for option-arguments).
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.
The processed argument values are passed to the action handler, and saved as `.processedArgs`.
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)
Expand Down
69 changes: 42 additions & 27 deletions lib/command.js
Expand Up @@ -19,13 +19,19 @@ class Command extends EventEmitter {

constructor(name) {
super();
/** @type {Command[]} */
this.commands = [];
/** @type {Option[]} */
this.options = [];
this.parent = null;
this._allowUnknownOption = false;
this._allowExcessArguments = true;
/** @type {Argument[]} */
this._args = [];
this.rawArgs = null;
/** @type {string[]} */
this.args = []; // cli args with options removed
this.rawArgs = [];
this.processedArgs = []; // like .args but after custom processing and collecting variadic
this._scriptPath = null;
this._name = name || '';
this._optionValues = {};
Expand Down Expand Up @@ -991,13 +997,34 @@ Expecting one of '${allowedValues.join("', '")}'`);
};

/**
* Package arguments (this.args) for passing to action handler based
* on declared arguments (this._args).
* Check this.args against expected this._args.
*
* @api private
*/

_getActionArguments() {
_checkNumberOfArguments() {
// too few
this._args.forEach((arg, i) => {
if (arg.required && this.args[i] == null) {
this.missingArgument(arg.name());
}
});
// too many
if (this._args.length > 0 && this._args[this._args.length - 1].variadic) {
return;
}
if (this.args.length > this._args.length) {
this._excessArguments(this.args);
}
};

/**
* Process this.args using this._args and save as this.processedArgs!
*
* @api private
*/

_processArguments() {
const myParseArg = (argument, value, previous) => {
// Extra processing for nice error message on parsing failure.
let parsedValue = value;
Expand All @@ -1015,7 +1042,9 @@ Expecting one of '${allowedValues.join("', '")}'`);
return parsedValue;
};

const actionArgs = [];
this._checkNumberOfArguments();

const processedArgs = [];
this._args.forEach((declaredArg, index) => {
let value = declaredArg.defaultValue;
if (declaredArg.variadic) {
Expand All @@ -1036,9 +1065,9 @@ Expecting one of '${allowedValues.join("', '")}'`);
value = myParseArg(declaredArg, value, declaredArg.defaultValue);
}
}
actionArgs[index] = value;
processedArgs[index] = value;
});
return actionArgs;
this.processedArgs = processedArgs;
}

/**
Expand Down Expand Up @@ -1130,37 +1159,22 @@ Expecting one of '${allowedValues.join("', '")}'`);
this.unknownOption(parsed.unknown[0]);
}
};
const checkNumberOfArguments = () => {
// too few
this._args.forEach((arg, i) => {
if (arg.required && this.args[i] == null) {
this.missingArgument(arg.name());
}
});
// too many
if (this._args.length > 0 && this._args[this._args.length - 1].variadic) {
return;
}
if (this.args.length > this._args.length) {
this._excessArguments(this.args);
}
};

const commandEvent = `command:${this.name()}`;
if (this._actionHandler) {
checkForUnknownOptions();
checkNumberOfArguments();
this._processArguments();

let actionResult;
actionResult = this._chainOrCallHooks(actionResult, 'preAction');
actionResult = this._chainOrCall(actionResult, () => this._actionHandler(this._getActionArguments()));
actionResult = this._chainOrCall(actionResult, () => this._actionHandler(this.processedArgs));
if (this.parent) this.parent.emit(commandEvent, operands, unknown); // legacy
actionResult = this._chainOrCallHooks(actionResult, 'postAction');
return actionResult;
}
if (this.parent && this.parent.listenerCount(commandEvent)) {
checkForUnknownOptions();
checkNumberOfArguments();
this._processArguments();
this.parent.emit(commandEvent, operands, unknown); // legacy
} else if (operands.length) {
if (this._findCommand('*')) { // legacy default command
Expand All @@ -1173,14 +1187,14 @@ Expecting one of '${allowedValues.join("', '")}'`);
this.unknownCommand();
} else {
checkForUnknownOptions();
checkNumberOfArguments();
this._processArguments();
}
} else if (this.commands.length) {
// This command has subcommands and nothing hooked up at this level, so display help (and exit).
this.help({ error: true });
} else {
checkForUnknownOptions();
checkNumberOfArguments();
this._processArguments();
// fall through for caller to handle after calling .parse()
}
};
Expand Down Expand Up @@ -1528,6 +1542,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
alias(alias) {
if (alias === undefined) return this._aliases[0]; // just return first, for backwards compatibility

/** @type {Command} */
let command = this;
if (this.commands.length !== 0 && this.commands[this.commands.length - 1]._executableHandler) {
// assume adding alias for last added executable subcommand, rather than this
Expand Down
1 change: 1 addition & 0 deletions lib/help.js
Expand Up @@ -38,6 +38,7 @@ class Help {
}
if (this.sortSubcommands) {
visibleCommands.sort((a, b) => {
// @ts-ignore: overloaded return type
return a.name().localeCompare(b.name());
});
}
Expand Down
48 changes: 45 additions & 3 deletions tests/argument.custom-processing.test.js
@@ -1,6 +1,7 @@
const commander = require('../');

// Testing default value and custom processing behaviours.
// Some double assertions in tests to check action argument and .processedArg

test('when argument not specified then callback not called', () => {
const mockCoercion = jest.fn();
Expand Down Expand Up @@ -34,7 +35,7 @@ test('when custom with starting value and argument not specified then callback n
expect(mockCoercion).not.toHaveBeenCalled();
});

test('when custom with starting value and argument not specified then action argument is starting value', () => {
test('when custom with starting value and argument not specified with action handler then action argument is starting value', () => {
const startingValue = 1;
let actionValue;
const program = new commander.Command();
Expand All @@ -45,9 +46,19 @@ test('when custom with starting value and argument not specified then action arg
});
program.parse([], { from: 'user' });
expect(actionValue).toEqual(startingValue);
expect(program.processedArgs).toEqual([startingValue]);
});

test('when default value is defined (without custom processing) and argument not specified then action argument is default value', () => {
test('when custom with starting value and argument not specified without action handler then .processedArgs has starting value', () => {
const startingValue = 1;
const program = new commander.Command();
program
.argument('[n]', 'number', parseFloat, startingValue);
program.parse([], { from: 'user' });
expect(program.processedArgs).toEqual([startingValue]);
});

test('when default value is defined (without custom processing) and argument not specified with action handler then action argument is default value', () => {
const defaultValue = 1;
let actionValue;
const program = new commander.Command();
Expand All @@ -58,6 +69,16 @@ test('when default value is defined (without custom processing) and argument not
});
program.parse([], { from: 'user' });
expect(actionValue).toEqual(defaultValue);
expect(program.processedArgs).toEqual([defaultValue]);
});

test('when default value is defined (without custom processing) and argument not specified without action handler then .processedArgs is default value', () => {
const defaultValue = 1;
const program = new commander.Command();
program
.argument('[n]', 'number', defaultValue);
program.parse([], { from: 'user' });
expect(program.processedArgs).toEqual([defaultValue]);
});

test('when argument specified then callback called with value', () => {
Expand All @@ -71,7 +92,7 @@ test('when argument specified then callback called with value', () => {
expect(mockCoercion).toHaveBeenCalledWith(value, undefined);
});

test('when argument specified then action value is as returned from callback', () => {
test('when argument specified with action handler then action value is as returned from callback', () => {
const callbackResult = 2;
let actionValue;
const program = new commander.Command();
Expand All @@ -84,6 +105,18 @@ test('when argument specified then action value is as returned from callback', (
});
program.parse(['node', 'test', 'alpha']);
expect(actionValue).toEqual(callbackResult);
expect(program.processedArgs).toEqual([callbackResult]);
});

test('when argument specified without action handler then .processedArgs is as returned from callback', () => {
const callbackResult = 2;
const program = new commander.Command();
program
.argument('[n]', 'number', () => {
return callbackResult;
});
program.parse(['node', 'test', 'alpha']);
expect(program.processedArgs).toEqual([callbackResult]);
});

test('when argument specified then program.args has original rather than custom', () => {
Expand Down Expand Up @@ -124,6 +157,14 @@ test('when variadic argument specified multiple times then callback called with
expect(mockCoercion).toHaveBeenNthCalledWith(2, '2', 'callback');
});

test('when variadic argument without action handler then .processedArg has array', () => {
const program = new commander.Command();
program
.argument('<n...>', 'number');
program.parse(['1', '2'], { from: 'user' });
expect(program.processedArgs).toEqual([['1', '2']]);
});

test('when parseFloat "1e2" then action argument is 100', () => {
let actionValue;
const program = new commander.Command();
Expand All @@ -134,6 +175,7 @@ test('when parseFloat "1e2" then action argument is 100', () => {
});
program.parse(['1e2'], { from: 'user' });
expect(actionValue).toEqual(100);
expect(program.processedArgs).toEqual([actionValue]);
});

test('when defined default value for required argument then throw', () => {
Expand Down
1 change: 1 addition & 0 deletions typings/index.d.ts
Expand Up @@ -217,6 +217,7 @@ export interface OptionValues {

export class Command {
args: string[];
processedArgs: any[];
commands: Command[];
parent: Command | null;

Expand Down
2 changes: 2 additions & 0 deletions typings/index.test-d.ts
Expand Up @@ -25,6 +25,8 @@ expectType<commander.Argument>(commander.createArgument('<foo>'));

// Command properties
expectType<string[]>(program.args);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expectType<any[]>(program.processedArgs);
expectType<commander.Command[]>(program.commands);
expectType<commander.Command | null>(program.parent);

Expand Down

0 comments on commit 4663597

Please sign in to comment.