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

Add support for sharing and stand-alone parsing of subcommands. Warn about parse calls on commands added with .command() #1938

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4f5ab2d
Add missing checks for passThroughOptions
aweebit Aug 4, 2023
0ac6609
Add tests for illegal passThroughOptions
aweebit Aug 4, 2023
9d4c96a
Remove illegal passThroughOptions check deemed unnecessary
aweebit Aug 5, 2023
43d9faa
Use weaker wording: "broken" instead of "illegal"
aweebit Aug 5, 2023
43cc821
Unclutter error message in broken passThrough checks
aweebit Aug 5, 2023
7689506
Refactor _checkForBrokenPassThrough() to make it instance-aware
aweebit Aug 5, 2023
d1686db
Add subroutine for common parse call code
aweebit Aug 5, 2023
3de6161
Add support for sharing and stand-alone parsing of subcommands
aweebit Aug 5, 2023
680930d
Warn about parse calls on commands added with .command()
aweebit Aug 5, 2023
ea728ec
Merge branch 'release/12.x' into feature/parents-with-implicit-subcom…
aweebit Aug 5, 2023
5085ddc
Get rid of redundant currentParent
aweebit Aug 5, 2023
aa280af
Introduce _getCommandAndAncestors()
aweebit Aug 5, 2023
777a452
Use _getCommandAndAncestors() consistently
aweebit Aug 5, 2023
2005522
Introduce _getCommandAndAncestors()
aweebit Aug 5, 2023
da5a499
Use _getCommandAndAncestors() consistently
aweebit Aug 5, 2023
724d414
Merge branch 'feature/_getCommandAndAncestors' into feature/parents-w…
aweebit Aug 5, 2023
5828e16
Copy type for parents and JSDoc comment for parent to .d.ts file
aweebit Aug 5, 2023
8b11122
Use _getCommandAndAncestors() less aggressively
aweebit Aug 10, 2023
f7fd986
Remove NODE_ENV check
aweebit Aug 11, 2023
4d2415c
Merge branch 'feature/_getCommandAndAncestors' into feature/parents-w…
aweebit Aug 12, 2023
cea6e9d
Reword warning about parse calls on commands added with .command()
aweebit Aug 12, 2023
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
113 changes: 75 additions & 38 deletions lib/command.js
Expand Up @@ -21,10 +21,21 @@ class Command extends EventEmitter {

constructor(name) {
super();
/** @type {boolean} */
this._implicitlyCreated = false;

/** @type {Command[]} */
this.commands = [];
/** @type {Option[]} */
this.options = [];
/** @type {Command[]} */
this.parents = [];
/**
* Last added parent command,
* or parent command in last parse call.
*
* @type {Command | null}
*/
this.parent = null;
this._allowUnknownOption = false;
this._allowExcessArguments = true;
Expand Down Expand Up @@ -109,6 +120,19 @@ class Command extends EventEmitter {
return this;
}

/**
* @returns {Command[]}
* @api private
*/

_getCommandAndAncestors() {
const result = [];
for (let command = this; command; command = command.parent) {
result.push(command);
}
return result;
}

/**
* Define a command.
*
Expand Down Expand Up @@ -145,6 +169,7 @@ class Command extends EventEmitter {
const [, name, args] = nameAndArgs.match(/([^ ]+) *(.*)/);

const cmd = this.createCommand(name);
cmd._implicitlyCreated = true;
if (desc) {
cmd.description(desc);
cmd._executableHandler = true;
Expand All @@ -154,6 +179,7 @@ class Command extends EventEmitter {
cmd._executableFile = opts.executableFile || null; // Custom name for executable file, set missing to null to match constructor
if (args) cmd.arguments(args);
this.commands.push(cmd);
cmd.parents.push(this);
cmd.parent = this;
cmd.copyInheritedSettings(this);

Expand Down Expand Up @@ -271,6 +297,7 @@ class Command extends EventEmitter {
if (opts.noHelp || opts.hidden) cmd._hidden = true; // modifying passed command due to existing implementation

this.commands.push(cmd);
cmd.parents.push(this);
cmd.parent = this;
cmd._checkForBrokenPassThrough();

Expand Down Expand Up @@ -746,7 +773,9 @@ Expecting one of '${allowedValues.join("', '")}'`);
*/

_checkForBrokenPassThrough() {
if (this.parent && this._passThroughOptions && !this.parent._enablePositionalOptions) {
if (this._passThroughOptions && this.parents.some(
(parent) => !parent._enablePositionalOptions
)) {
throw new Error(`passThroughOptions cannot be used for '${this._name}' without turning on enablePositionalOptions for parent command(s)`);
}
}
Expand Down Expand Up @@ -835,7 +864,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
getOptionValueSourceWithGlobals(key) {
// global overwrites local, like optsWithGlobals
let source;
getCommandAndParents(this).forEach((cmd) => {
this._getCommandAndAncestors().forEach((cmd) => {
if (cmd.getOptionValueSource(key) !== undefined) {
source = cmd.getOptionValueSource(key);
}
Expand Down Expand Up @@ -897,6 +926,26 @@ Expecting one of '${allowedValues.join("', '")}'`);
return userArgs;
}

/**
* @param {boolean} async
* @param {Function} userArgsCallback
* @param {string[]} [argv]
* @param {Object} [parseOptions]
* @param {string} [parseOptions.from]
* @return {Command|Promise}
* @api private
*/

_parseSubroutine(async, userArgsCallback, argv, parseOptions) {
const methodName = async ? 'parseAsync' : 'parse';
if (this._implicitlyCreated) {
console.warn(`Called .${methodName}() on subcommand '${this._name}' added with .command()
- meant to call on the top-level command?`);
}
const userArgs = this._prepareUserArgs(argv, parseOptions);
return userArgsCallback(userArgs);
}

/**
* Parse `argv`, setting options and invoking commands when defined.
*
Expand All @@ -915,10 +964,10 @@ Expecting one of '${allowedValues.join("', '")}'`);
*/

parse(argv, parseOptions) {
const userArgs = this._prepareUserArgs(argv, parseOptions);
this._parseCommand([], userArgs);

return this;
return this._parseSubroutine(false, (userArgs) => {
this._parseCommand([], userArgs);
return this;
}, argv, parseOptions);
}

/**
Expand All @@ -941,10 +990,10 @@ Expecting one of '${allowedValues.join("', '")}'`);
*/

async parseAsync(argv, parseOptions) {
const userArgs = this._prepareUserArgs(argv, parseOptions);
await this._parseCommand([], userArgs);

return this;
return this._parseSubroutine(true, async(userArgs) => {
await this._parseCommand([], userArgs);
return this;
}, argv, parseOptions);
}

/**
Expand Down Expand Up @@ -1088,7 +1137,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
if (subCommand._executableHandler) {
this._executeSubCommand(subCommand, operands.concat(unknown));
} else {
return subCommand._parseCommand(operands, unknown);
return subCommand._parseCommand(operands, unknown, this);
}
});
return hookResult;
Expand Down Expand Up @@ -1218,7 +1267,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
_chainOrCallHooks(promise, event) {
let result = promise;
const hooks = [];
getCommandAndParents(this)
this._getCommandAndAncestors()
.reverse()
.filter(cmd => cmd._lifeCycleHooks[event] !== undefined)
.forEach(hookedCommand => {
Expand Down Expand Up @@ -1266,7 +1315,9 @@ Expecting one of '${allowedValues.join("', '")}'`);
* @api private
*/

_parseCommand(operands, unknown) {
_parseCommand(operands, unknown, parent) {
this.parent = parent ?? null;

const parsed = this.parseOptions(unknown);
this._parseOptionsEnv(); // after cli, so parseArg not called on both cli and env
this._parseOptionsImplied();
Expand Down Expand Up @@ -1308,18 +1359,18 @@ Expecting one of '${allowedValues.join("', '")}'`);
let actionResult;
actionResult = this._chainOrCallHooks(actionResult, 'preAction');
actionResult = this._chainOrCall(actionResult, () => this._actionHandler(this.processedArgs));
if (this.parent) {
if (parent) {
actionResult = this._chainOrCall(actionResult, () => {
this.parent.emit(commandEvent, operands, unknown); // legacy
parent.emit(commandEvent, operands, unknown); // legacy
});
}
actionResult = this._chainOrCallHooks(actionResult, 'postAction');
return actionResult;
}
if (this.parent && this.parent.listenerCount(commandEvent)) {
if (parent?.listenerCount(commandEvent)) {
checkForUnknownOptions();
this._processArguments();
this.parent.emit(commandEvent, operands, unknown); // legacy
parent.emit(commandEvent, operands, unknown); // legacy
} else if (operands.length) {
if (this._findCommand('*')) { // legacy default command
return this._dispatchSubcommand('*', operands, unknown);
Expand Down Expand Up @@ -1375,13 +1426,13 @@ Expecting one of '${allowedValues.join("', '")}'`);

_checkForMissingMandatoryOptions() {
// Walk up hierarchy so can call in subcommand after checking for displaying help.
for (let cmd = this; cmd; cmd = cmd.parent) {
this._getCommandAndAncestors().forEach((cmd) => {
cmd.options.forEach((anOption) => {
if (anOption.mandatory && (cmd.getOptionValue(anOption.attributeName()) === undefined)) {
cmd.missingMandatoryOptionValue(anOption);
}
});
}
});
}

/**
Expand Down Expand Up @@ -1422,9 +1473,9 @@ Expecting one of '${allowedValues.join("', '")}'`);
*/
_checkForConflictingOptions() {
// Walk up hierarchy so can call in subcommand after checking for displaying help.
for (let cmd = this; cmd; cmd = cmd.parent) {
this._getCommandAndAncestors().forEach((cmd) => {
cmd._checkForConflictingLocalOptions();
}
});
}

/**
Expand Down Expand Up @@ -1587,7 +1638,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
*/
optsWithGlobals() {
// globals overwrite locals
return getCommandAndParents(this).reduce(
return this._getCommandAndAncestors().reduce(
(combinedOptions, cmd) => Object.assign(combinedOptions, cmd.opts()),
{}
);
Expand Down Expand Up @@ -2032,7 +2083,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
}
const context = this._getHelpContext(contextOptions);

getCommandAndParents(this).reverse().forEach(command => command.emit('beforeAllHelp', context));
this._getCommandAndAncestors().reverse().forEach(command => command.emit('beforeAllHelp', context));
this.emit('beforeHelp', context);

let helpInformation = this.helpInformation(context);
Expand All @@ -2046,7 +2097,7 @@ Expecting one of '${allowedValues.join("', '")}'`);

this.emit(this._helpLongFlag); // deprecated
this.emit('afterHelp', context);
getCommandAndParents(this).forEach(command => command.emit('afterAllHelp', context));
this._getCommandAndAncestors().forEach(command => command.emit('afterAllHelp', context));
}

/**
Expand Down Expand Up @@ -2189,18 +2240,4 @@ function incrementNodeInspectorPort(args) {
});
}

/**
* @param {Command} startCommand
* @returns {Command[]}
* @api private
*/

function getCommandAndParents(startCommand) {
const result = [];
for (let command = startCommand; command; command = command.parent) {
result.push(command);
}
return result;
}

exports.Command = Command;
12 changes: 6 additions & 6 deletions lib/help.js
Expand Up @@ -101,8 +101,8 @@ class Help {
if (!this.showGlobalOptions) return [];

const globalOptions = [];
for (let parentCmd = cmd.parent; parentCmd; parentCmd = parentCmd.parent) {
const visibleOptions = parentCmd.options.filter((option) => !option.hidden);
for (let ancestorCmd = cmd.parent; ancestorCmd; ancestorCmd = ancestorCmd.parent) {
const visibleOptions = ancestorCmd.options.filter((option) => !option.hidden);
globalOptions.push(...visibleOptions);
}
if (this.sortOptions) {
Expand Down Expand Up @@ -240,11 +240,11 @@ class Help {
if (cmd._aliases[0]) {
cmdName = cmdName + '|' + cmd._aliases[0];
}
let parentCmdNames = '';
for (let parentCmd = cmd.parent; parentCmd; parentCmd = parentCmd.parent) {
parentCmdNames = parentCmd.name() + ' ' + parentCmdNames;
let ancestorCmdNames = '';
for (let ancestorCmd = cmd.parent; ancestorCmd; ancestorCmd = ancestorCmd.parent) {
ancestorCmdNames = ancestorCmd.name() + ' ' + ancestorCmdNames;
}
return parentCmdNames + cmdName + ' ' + cmd.usage();
return ancestorCmdNames + cmdName + ' ' + cmd.usage();
}

/**
Expand Down
1 change: 1 addition & 0 deletions tests/command.addCommand.test.js
Expand Up @@ -30,6 +30,7 @@ test('when commands added using .addCommand and .command then internals similar'
expect(cmd2.parent).toBe(program2);

for (const key of Object.keys(cmd1)) {
if (key === '_implicitlyCreated') continue; // expected to differ
switch (typeof cmd1[key]) {
case 'string':
case 'boolean':
Expand Down
5 changes: 5 additions & 0 deletions typings/index.d.ts
Expand Up @@ -281,6 +281,11 @@ export class Command {
processedArgs: any[];
readonly commands: readonly Command[];
readonly options: readonly Option[];
parents: Command[];
/**
* Last added parent command,
* or parent command in last parse call.
*/
parent: Command | null;

constructor(name?: string);
Expand Down
1 change: 1 addition & 0 deletions typings/index.test-d.ts
Expand Up @@ -30,6 +30,7 @@ expectType<string[]>(program.args);
expectType<any[]>(program.processedArgs);
expectType<readonly commander.Command[]>(program.commands);
expectType<readonly commander.Option[]>(program.options);
expectType<commander.Command[]>(program.parents);
expectType<commander.Command | null>(program.parent);

// version
Expand Down