Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support argument processing without action handler #1529

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion Readme.md
Expand Up @@ -488,10 +488,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 @@ -976,13 +982,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 @@ -1000,7 +1027,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 @@ -1021,9 +1050,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 @@ -1115,37 +1144,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 @@ -1158,14 +1172,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 @@ -1513,6 +1527,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