Skip to content

Commit

Permalink
Implement lazily created sub commands.
Browse files Browse the repository at this point in the history
  • Loading branch information
mshima committed Jun 7, 2020
1 parent 6cad30a commit 6e24851
Show file tree
Hide file tree
Showing 2 changed files with 166 additions and 49 deletions.
118 changes: 69 additions & 49 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ class Command extends EventEmitter {
this._actionHandler = null;
this._executableHandler = false;
this._executableFile = null; // custom name for executable
this._subCommandBuilder = null; // sub command builder
this._defaultCommandName = null;
this._exitCallback = null;
this._aliases = [];
Expand Down Expand Up @@ -187,6 +188,7 @@ class Command extends EventEmitter {
cmd._passCommandToAction = this._passCommandToAction;

cmd._executableFile = opts.executableFile || null; // Custom name for executable file, set missing to null to match constructor
cmd._subCommandBuilder = opts.subCommandBuilder || null; // A builder for sub commands.
this.commands.push(cmd);
cmd._parseExpectedArgs(args);
cmd.parent = this;
Expand Down Expand Up @@ -228,8 +230,8 @@ class Command extends EventEmitter {
// Fail fast and detect when adding rather than later when parsing.
function checkExplicitNames(commandArray) {
commandArray.forEach((cmd) => {
if (cmd._executableHandler && !cmd._executableFile) {
throw new Error(`Must specify executableFile for deeply nested executable: ${cmd.name()}`);
if (cmd._executableHandler && !cmd._executableFile && !cmd._subCommandBuilder) {
throw new Error(`Must specify executableFile or subCommandBuilder for deeply nested executable: ${cmd.name()}`);
}
checkExplicitNames(cmd.commands);
});
Expand Down Expand Up @@ -668,6 +670,11 @@ class Command extends EventEmitter {
*/

parse(argv, parseOptions) {
this._parseProgram(argv, parseOptions);
return this;
}

_parseProgram(argv, parseOptions) {
if (argv !== undefined && !Array.isArray(argv)) {
throw new Error('first parameter to parse must be array or undefined');
}
Expand Down Expand Up @@ -714,9 +721,7 @@ class Command extends EventEmitter {
this._name = this._name || (this._scriptPath && path.basename(this._scriptPath, path.extname(this._scriptPath)));

// Let's go!
this._parseCommand([], userArgs);

return this;
return this._parseCommand([], userArgs);
};

/**
Expand All @@ -736,13 +741,14 @@ class Command extends EventEmitter {
* @param {string[]} [argv]
* @param {Object} [parseOptions]
* @param {string} parseOptions.from - where the args are from: 'node', 'user', 'electron'
* @return {Promise}
* @return {Promise} promise `this`
* @api public
*/

parseAsync(argv, parseOptions) {
this.parse(argv, parseOptions);
return Promise.all(this._actionResults).then(() => this);
return this._parseProgram(argv, parseOptions).then(() => {
return Promise.all(this._actionResults).then(() => this);
});
};

/**
Expand Down Expand Up @@ -850,25 +856,36 @@ class Command extends EventEmitter {

// Store the reference to the child process
this.runningCommand = proc;
return Promise.resolve();
};

/**
* @return {Promise}
* @api private
*/
_dispatchSubcommand(commandName, operands, unknown) {
const subCommand = this._findCommand(commandName);
if (!subCommand) this._helpAndError();

if (subCommand._executableHandler) {
this._executeSubCommand(subCommand, operands.concat(unknown));
} else {
subCommand._parseCommand(operands, unknown);
if (subCommand._subCommandBuilder) {
const handlerPromise = subCommand._subCommandBuilder(this, operands, unknown);
if (handlerPromise instanceof Promise) {
return handlerPromise.then(handler => {
return handler._parseCommand(operands, unknown);
});
}
return handlerPromise._parseCommand(operands, unknown);
}
return this._executeSubCommand(subCommand, operands.concat(unknown));
}
return subCommand._parseCommand(operands, unknown);
};

/**
* Process arguments in context of this command.
*
* @return {Promise}
* @api private
*/

Expand All @@ -879,55 +896,58 @@ class Command extends EventEmitter {
this.args = operands.concat(unknown);

if (operands && this._findCommand(operands[0])) {
this._dispatchSubcommand(operands[0], operands.slice(1), unknown);
} else if (this._lazyHasImplicitHelpCommand() && operands[0] === this._helpCommandName) {
return this._dispatchSubcommand(operands[0], operands.slice(1), unknown);
}
if (this._lazyHasImplicitHelpCommand() && operands[0] === this._helpCommandName) {
if (operands.length === 1) {
this.help();
} else {
this._dispatchSubcommand(operands[1], [], [this._helpLongFlag]);
return Promise.resolve();
}
} else if (this._defaultCommandName) {
return this._dispatchSubcommand(operands[1], [], [this._helpLongFlag]);
}
if (this._defaultCommandName) {
outputHelpIfRequested(this, unknown); // Run the help for default command from parent rather than passing to default command
this._dispatchSubcommand(this._defaultCommandName, operands, unknown);
} else {
if (this.commands.length && this.args.length === 0 && !this._actionHandler && !this._defaultCommandName) {
// probably missing subcommand and no handler, user needs help
this._helpAndError();
}
return this._dispatchSubcommand(this._defaultCommandName, operands, unknown);
}

outputHelpIfRequested(this, parsed.unknown);
this._checkForMissingMandatoryOptions();
if (parsed.unknown.length > 0) {
this.unknownOption(parsed.unknown[0]);
}
if (this.commands.length && this.args.length === 0 && !this._actionHandler && !this._defaultCommandName) {
// probably missing subcommand and no handler, user needs help
this._helpAndError();
}

if (this._actionHandler) {
const args = this.args.slice();
this._args.forEach((arg, i) => {
if (arg.required && args[i] == null) {
this.missingArgument(arg.name);
} else if (arg.variadic) {
args[i] = args.splice(i);
}
});
outputHelpIfRequested(this, parsed.unknown);
this._checkForMissingMandatoryOptions();
if (parsed.unknown.length > 0) {
this.unknownOption(parsed.unknown[0]);
}

this._actionHandler(args);
this.emit('command:' + this.name(), operands, unknown);
} else if (operands.length) {
if (this._findCommand('*')) {
this._dispatchSubcommand('*', operands, unknown);
} else if (this.listenerCount('command:*')) {
this.emit('command:*', operands, unknown);
} else if (this.commands.length) {
this.unknownCommand();
if (this._actionHandler) {
const args = this.args.slice();
this._args.forEach((arg, i) => {
if (arg.required && args[i] == null) {
this.missingArgument(arg.name);
} else if (arg.variadic) {
args[i] = args.splice(i);
}
});

this._actionHandler(args);
this.emit('command:' + this.name(), operands, unknown);
} else if (operands.length) {
if (this._findCommand('*')) {
this._dispatchSubcommand('*', operands, unknown);
} else if (this.listenerCount('command:*')) {
this.emit('command:*', operands, unknown);
} else if (this.commands.length) {
// This command has subcommands and nothing hooked up at this level, so display help.
this._helpAndError();
} else {
// fall through for caller to handle after calling .parse()
this.unknownCommand();
}
} else if (this.commands.length) {
// This command has subcommands and nothing hooked up at this level, so display help.
this._helpAndError();
} else {
// fall through for caller to handle after calling .parse()
}
return Promise.resolve();
};

/**
Expand Down
97 changes: 97 additions & 0 deletions tests/command.subCommandPromise.action.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
const commander = require('../');

// Test some behaviours of .action not covered in more specific tests.

describe('with subCommandBuilder', () => {
test('when .action called then builder action is executed', () => {
const actionMock = jest.fn();
const program = new commander.Command();
program.command('run', 'run description', {
subCommandBuilder: parent => {
return parent
.command('run')
.action(actionMock);
}
});
program.parse(['node', 'test', 'run']);
expect(actionMock).toHaveBeenCalled();
});

test('when .action called then builder promised action is executed', () => {
const actionMock = jest.fn();
const program = new commander.Command();
program.command('run', 'run description', {
subCommandBuilder: parent => {
return Promise.resolve(parent
.command('run')
.action(actionMock));
}
});
return program.parseAsync(['node', 'test', 'run']).then(() => {
expect(actionMock).toHaveBeenCalled();
});
});

test('arguments is resolved and passed to action handler', () => {
const actionSpy = jest.fn();
return new commander.Command()
.command('run <handler>', 'run description', {
subCommandBuilder: parent => {
return Promise.resolve(parent
.command('run <arg>')
.action((arg) => {
expect(arg).toEqual('foo');
actionSpy();
}));
}
})
.parseAsync(['node', 'test', 'run', 'foo']).then(() => {
expect(actionSpy).toHaveBeenCalled();
});
});

test('options is resolved and passed to action handler', () => {
const actionSpy = jest.fn();
return new commander.Command()
.command('run <handler>', 'run description', {
subCommandBuilder: parent => {
return Promise.resolve(parent
.command('run')
.requiredOption('--test-option <val>', 'test value')
.action(command => {
expect(command.testOption).toEqual('bar');
actionSpy();
})
);
}
})
.parseAsync(['node', 'test', 'run', '--test-option', 'bar']).then(() => {
expect(actionSpy).toHaveBeenCalled();
});
});

test('recursive sub command action handler', () => {
const infoSpy = jest.fn();
const runSpy = jest.fn();
const runCommandBuilder = parent => {
return parent.command('run <handler>', 'run description', {
subCommandBuilder: (parent, _operands, _unknown) => {
if (_operands && _operands[0] === 'run') {
runSpy();
return runCommandBuilder(parent);
}
return Promise.resolve(parent
.command('info')
.action(infoSpy)
);
}
});
};
return runCommandBuilder(new commander.Command())
.parseAsync(['node', 'test', 'run', 'run', 'run', 'run', 'info']).then(() => {
expect(infoSpy).toHaveBeenCalled();
expect(runSpy).toHaveBeenCalledTimes(3);
})
;
});
});

0 comments on commit 6e24851

Please sign in to comment.