From 16752e0b35061a2e32840be67e76fc48ad054e82 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sat, 22 Feb 2025 18:58:53 +1300 Subject: [PATCH 01/10] Add support for command and option grouping in help output --- examples/help-groups.js | 37 ++++++++++++ lib/command.js | 37 ++++++++++++ lib/help.js | 122 +++++++++++++++++++++++++++------------- lib/option.js | 12 ++++ 4 files changed, 170 insertions(+), 38 deletions(-) create mode 100644 examples/help-groups.js diff --git a/examples/help-groups.js b/examples/help-groups.js new file mode 100644 index 000000000..51b4586a2 --- /dev/null +++ b/examples/help-groups.js @@ -0,0 +1,37 @@ +const { Command, Option } = require('commander'); + +const program = new Command(); + +// Default option group (Options:) +program.option('-u, --user', 'username to use, instead of current user'); +program.groupOptions('Built-in Options:', ['help']); + +const devHelpGroup = 'Development Options:'; +program + .addOption( + new Option('--profile', 'show how long command takes').helpGroup( + devHelpGroup, + ), + ) + .addOption( + new Option('-d, --debug', 'add extra trace information').helpGroup( + devHelpGroup, + ), + ); + +// Default help group (Commands:) +program.command('deploy').description('deploy project'); +program.command('restart').description('restart project'); +program.groupCommands('Built-in Commands:', ['help']); + +const userManagementGroup = 'User Management:'; +program + .command('sign-in') + .description('sign in user') + .helpGroup(userManagementGroup); +program + .command('sign-out') + .description('sign out') + .helpGroup(userManagementGroup); + +program.parse(); diff --git a/lib/command.js b/lib/command.js index efbb8f614..22c1d2ef1 100644 --- a/lib/command.js +++ b/lib/command.js @@ -80,6 +80,9 @@ class Command extends EventEmitter { /** @type {Command} */ this._helpCommand = undefined; // lazy initialised, inherited this._helpConfiguration = {}; + this._helpGroup = undefined; // group for this command + this._commandGroups = new Map(); // map of group to array of command names + this._optionGroups = new Map(); // map of group to array of option names } /** @@ -2294,6 +2297,40 @@ Expecting one of '${allowedValues.join("', '")}'`); return this; } + /** + * Get or set the help group of the command. + * + * @param {string} [str] + * @return {(string|Command)} + */ + + helpGroup(str) { + if (str === undefined) return this._helpGroup; + this._helpGroup = str; + return this; + } + + /** + * @param {string} group + * @param {string[]} names + * @returns {Command} + */ + groupCommands(group, names) { + this._commandGroups.set(group, names); + return this; + } + + /** + * + * @param {string} group + * @param {string[]} names + * @returns {Command} + */ + groupOptions(group, names) { + this._optionGroups.set(group, names); + return this; + } + /** * Set the name of the command from script filename, such as process.argv[1], * or require.main.filename, or __filename. diff --git a/lib/help.js b/lib/help.js index e44801541..24deb5770 100644 --- a/lib/help.js +++ b/lib/help.js @@ -388,6 +388,42 @@ class Help { return argument.description; } + /** + * @param {string} title + * @param {string[]} items + * @returns string[] + */ + formatItemList(title, items, helper) { + if (items.length === 0) return []; + + return [helper.styleTitle(title), ...items, '']; + } + + /** + * + * @param {Command[] | Option[]} unsortedItems + * @param {Command[] | Option[]} visibleItems + * @param {Function} getGroup + * @returns {Map} + */ + getItemGroups(unsortedItems, visibleItems, getGroup) { + const result = new Map(); + // Add groups in order of appearance in unsortedItems. + unsortedItems.forEach((item) => { + const group = getGroup(item); + if (!result.has(group)) result.set(group, []); + }); + // Add items in order of appearance in visibleItems. + visibleItems.forEach((item) => { + const group = getGroup(item); + if (!result.has(group)) { + result.set(group, []); + } + result.get(group).push(item); + }); + return result; + } + /** * Generate the built-in help text. * @@ -404,6 +440,16 @@ class Help { return helper.formatItem(term, termWidth, description, helper); } + function lookupGroup(groupMap, name) { + let result; + groupMap.forEach((names, group) => { + if (names.includes(name)) { + result = group; + } + }); + return result; + } + // Usage let output = [ `${helper.styleTitle('Usage:')} ${helper.styleUsage(helper.commandUsage(cmd))}`, @@ -429,28 +475,28 @@ class Help { helper.styleArgumentDescription(helper.argumentDescription(argument)), ); }); - if (argumentList.length > 0) { - output = output.concat([ - helper.styleTitle('Arguments:'), - ...argumentList, - '', - ]); - } + output = output.concat( + this.formatItemList('Arguments:', argumentList, helper), + ); // Options - const optionList = helper.visibleOptions(cmd).map((option) => { - return callFormatItem( - helper.styleOptionTerm(helper.optionTerm(option)), - helper.styleOptionDescription(helper.optionDescription(option)), - ); + const optionGroups = this.getItemGroups( + cmd.options, + helper.visibleOptions(cmd), + (option) => + lookupGroup(cmd._optionGroups, option.attributeName()) ?? + option.helpGroupTitle ?? + 'Options:', + ); + optionGroups.forEach((options, group) => { + const optionList = options.map((option) => { + return callFormatItem( + helper.styleOptionTerm(helper.optionTerm(option)), + helper.styleOptionDescription(helper.optionDescription(option)), + ); + }); + output = output.concat(this.formatItemList(group, optionList, helper)); }); - if (optionList.length > 0) { - output = output.concat([ - helper.styleTitle('Options:'), - ...optionList, - '', - ]); - } if (helper.showGlobalOptions) { const globalOptionList = helper @@ -461,29 +507,29 @@ class Help { helper.styleOptionDescription(helper.optionDescription(option)), ); }); - if (globalOptionList.length > 0) { - output = output.concat([ - helper.styleTitle('Global Options:'), - ...globalOptionList, - '', - ]); - } + output = output.concat( + this.formatItemList('Global Options:', globalOptionList, helper), + ); } // Commands - const commandList = helper.visibleCommands(cmd).map((cmd) => { - return callFormatItem( - helper.styleSubcommandTerm(helper.subcommandTerm(cmd)), - helper.styleSubcommandDescription(helper.subcommandDescription(cmd)), - ); + const commandGroups = this.getItemGroups( + cmd.commands, + helper.visibleCommands(cmd), + (sub) => + lookupGroup(cmd._commandGroups, sub.name()) ?? + sub.helpGroup() ?? + 'Commands:', + ); + commandGroups.forEach((commands, group) => { + const commandList = commands.map((sub) => { + return callFormatItem( + helper.styleSubcommandTerm(helper.subcommandTerm(sub)), + helper.styleSubcommandDescription(helper.subcommandDescription(sub)), + ); + }); + output = output.concat(this.formatItemList(group, commandList, helper)); }); - if (commandList.length > 0) { - output = output.concat([ - helper.styleTitle('Commands:'), - ...commandList, - '', - ]); - } return output.join('\n'); } diff --git a/lib/option.js b/lib/option.js index 5d43dfbd7..6ef2473b5 100644 --- a/lib/option.js +++ b/lib/option.js @@ -33,6 +33,7 @@ class Option { this.argChoices = undefined; this.conflictsWith = []; this.implied = undefined; + this.helpGroupTitle = undefined; } /** @@ -219,6 +220,17 @@ class Option { return camelcase(this.name()); } + /** + * Set group for option in help. + * + * @param {string} title + * @return {Option} + */ + helpGroup(title) { + this.helpGroupTitle = title; + return this; + } + /** * Check if `arg` matches the short or long flag. * From 1b5e3f0f70f5763d3d991570f9173bca41ef05f9 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sun, 23 Feb 2025 21:42:00 +1300 Subject: [PATCH 02/10] Add .optionsGroup() and .commandsGroup() implementation --- examples/help-groups.js | 58 +++++++++++++---------- lib/command.js | 94 +++++++++++++++++++++++++++---------- lib/help.js | 20 +------- lib/option.js | 2 +- tests/command.chain.test.js | 6 +++ tests/option.chain.test.js | 6 +++ 6 files changed, 117 insertions(+), 69 deletions(-) diff --git a/examples/help-groups.js b/examples/help-groups.js index 51b4586a2..eb0a1a47a 100644 --- a/examples/help-groups.js +++ b/examples/help-groups.js @@ -1,37 +1,47 @@ const { Command, Option } = require('commander'); -const program = new Command(); +// The help group can be set explicitly on an Option or Command using `.helpGroup()`, +// or by setting the default group before adding the option or command. -// Default option group (Options:) -program.option('-u, --user', 'username to use, instead of current user'); -program.groupOptions('Built-in Options:', ['help']); +const program = new Command(); -const devHelpGroup = 'Development Options:'; -program - .addOption( - new Option('--profile', 'show how long command takes').helpGroup( - devHelpGroup, - ), - ) +const directGroups = program + .command('direct-groups') + .description('example with help groups set by using .helpGroup()') .addOption( new Option('-d, --debug', 'add extra trace information').helpGroup( - devHelpGroup, + 'Development Options:', ), ); +directGroups + .command('watch') + .description('run project in watch mode') + .helpGroup('Development Commands:'); -// Default help group (Commands:) -program.command('deploy').description('deploy project'); -program.command('restart').description('restart project'); -program.groupCommands('Built-in Commands:', ['help']); +const defaultGroups = program + .command('default-groups') + .description( + 'example with help groups set by using optionsGroup/commandsGroup ', + ) + .optionsGroup('Development Options:') + .option('-d, --debug', 'add extra trace information', 'Development Options:'); +defaultGroups.commandsGroup('Development Commands:'); +defaultGroups.command('watch').description('run project in watch mode'); -const userManagementGroup = 'User Management:'; -program - .command('sign-in') - .description('sign in user') - .helpGroup(userManagementGroup); program - .command('sign-out') - .description('sign out') - .helpGroup(userManagementGroup); + .command('built-in') + .description( + 'changing the help for built-ins by explicitly customising built-in', + ) + .optionsGroup('Built-in Options:') + .version('v2.3.4') + .helpOption('-h, --help') // get default group by customising help option + .commandsGroup('Built-in Commands:') + .helpCommand('help'); // get default group by customising help option program.parse(); + +// Try the following: +// node help-groups.js help direct-groups +// node help-groups.js help default-groups +// node help-groups.js help built-in diff --git a/lib/command.js b/lib/command.js index 22c1d2ef1..2e1595eb8 100644 --- a/lib/command.js +++ b/lib/command.js @@ -80,9 +80,12 @@ class Command extends EventEmitter { /** @type {Command} */ this._helpCommand = undefined; // lazy initialised, inherited this._helpConfiguration = {}; - this._helpGroup = undefined; // group for this command - this._commandGroups = new Map(); // map of group to array of command names - this._optionGroups = new Map(); // map of group to array of option names + /** @type {string | undefined} */ + this._helpGroupTitle = undefined; // soft initialised when added to parent + /** @type {string | undefined} */ + this._defaultCommandGroup = undefined; + /** @type {string | undefined} */ + this._defaultOptionGroup = undefined; } /** @@ -406,8 +409,8 @@ class Command extends EventEmitter { return this; } - enableOrNameAndArgs = enableOrNameAndArgs ?? 'help [command]'; - const [, helpName, helpArgs] = enableOrNameAndArgs.match(/([^ ]+) *(.*)/); + const nameAndArgs = enableOrNameAndArgs ?? 'help [command]'; + const [, helpName, helpArgs] = nameAndArgs.match(/([^ ]+) *(.*)/); const helpDescription = description ?? 'display help for command'; const helpCommand = this.createCommand(helpName); @@ -417,6 +420,8 @@ class Command extends EventEmitter { this._addImplicitHelpCommand = true; this._helpCommand = helpCommand; + // init group unless lazy create + if (enableOrNameAndArgs || description) this._initCommandGroup(helpCommand); return this; } @@ -438,6 +443,7 @@ class Command extends EventEmitter { this._addImplicitHelpCommand = true; this._helpCommand = helpCommand; + this._initCommandGroup(helpCommand); return this; } @@ -616,6 +622,7 @@ Expecting one of '${allowedValues.join("', '")}'`); - already used by option '${matchingOption.flags}'`); } + this._initOptionGroup(option); this.options.push(option); } @@ -643,6 +650,7 @@ Expecting one of '${allowedValues.join("', '")}'`); ); } + this._initCommandGroup(command); this.commands.push(command); } @@ -2298,39 +2306,71 @@ Expecting one of '${allowedValues.join("', '")}'`); } /** - * Get or set the help group of the command. + * Set/get the help group for this subcommand in parent command's help. * * @param {string} [str] - * @return {(string|Command)} + * @return {Command | string} */ helpGroup(str) { - if (str === undefined) return this._helpGroup; - this._helpGroup = str; + if (str === undefined) return this._helpGroupTitle ?? ''; + this._helpGroupTitle = str; return this; } /** - * @param {string} group - * @param {string[]} names - * @returns {Command} + * Set/get the default help group title for next subcommands added to this command. + * + * @example + * program.commandsGroup('Development Commands:); + * program.command('watch')... + * program.command('lint')... + * ... + * + * @param {string} [title] + * @returns {Command | string} */ - groupCommands(group, names) { - this._commandGroups.set(group, names); + commandsGroup(title) { + if (title === undefined) return this._defaultCommandGroup ?? ''; + this._defaultCommandGroup = title; return this; } /** + * Set/get the default help group title for next options added to this command. * - * @param {string} group - * @param {string[]} names - * @returns {Command} + * @example + * program.optionsGroup('Development Options:') + * .option('-d, --debug', 'output extra debugging') + * .option('-p, --profile', 'output profiling information') + * + * @param {string} [title] + * @returns {Command | string} */ - groupOptions(group, names) { - this._optionGroups.set(group, names); + optionsGroup(title) { + if (title === undefined) return this._defaultOptionGroup ?? ''; + this._defaultOptionGroup = title; return this; } + /** + * @param {Option} option + * @private + */ + _initOptionGroup(option) { + if (this._defaultOptionGroup && !option.helpGroupTitle) + option.helpGroup(this._defaultOptionGroup); + } + + /** + * @param {Command} cmd + * @private + */ + _initCommandGroup(cmd) { + if (this._defaultCommandGroup && !cmd.helpGroup()) + cmd.helpGroup(this._defaultCommandGroup); + } + /** * Set the name of the command from script filename, such as process.argv[1], * or require.main.filename, or __filename. @@ -2485,12 +2525,10 @@ Expecting one of '${allowedValues.join("', '")}'`); */ helpOption(flags, description) { - // Support disabling built-in help option. + // Support enabling/disabling built-in help option. if (typeof flags === 'boolean') { - // true is not an expected value. Do something sensible but no unit-test. - // istanbul ignore if if (flags) { - this._helpOption = this._helpOption ?? undefined; // preserve existing option + if (this._helpOption === null) this._helpOption = undefined; } else { this._helpOption = null; // disable } @@ -2498,9 +2536,12 @@ Expecting one of '${allowedValues.join("', '")}'`); } // Customise flags and description. - flags = flags ?? '-h, --help'; - description = description ?? 'display help for command'; - this._helpOption = this.createOption(flags, description); + this._helpOption = this.createOption( + flags ?? '-h, --help', + description ?? 'display help for command', + ); + // init group unless lazy create + if (flags || description) this._initOptionGroup(this._helpOption); return this; } @@ -2529,6 +2570,7 @@ Expecting one of '${allowedValues.join("', '")}'`); */ addHelpOption(option) { this._helpOption = option; + this._initOptionGroup(option); return this; } diff --git a/lib/help.js b/lib/help.js index 24deb5770..ca312d22d 100644 --- a/lib/help.js +++ b/lib/help.js @@ -440,16 +440,6 @@ class Help { return helper.formatItem(term, termWidth, description, helper); } - function lookupGroup(groupMap, name) { - let result; - groupMap.forEach((names, group) => { - if (names.includes(name)) { - result = group; - } - }); - return result; - } - // Usage let output = [ `${helper.styleTitle('Usage:')} ${helper.styleUsage(helper.commandUsage(cmd))}`, @@ -483,10 +473,7 @@ class Help { const optionGroups = this.getItemGroups( cmd.options, helper.visibleOptions(cmd), - (option) => - lookupGroup(cmd._optionGroups, option.attributeName()) ?? - option.helpGroupTitle ?? - 'Options:', + (option) => option.helpGroupTitle ?? 'Options:', ); optionGroups.forEach((options, group) => { const optionList = options.map((option) => { @@ -516,10 +503,7 @@ class Help { const commandGroups = this.getItemGroups( cmd.commands, helper.visibleCommands(cmd), - (sub) => - lookupGroup(cmd._commandGroups, sub.name()) ?? - sub.helpGroup() ?? - 'Commands:', + (sub) => sub._helpGroupTitle || 'Commands:', ); commandGroups.forEach((commands, group) => { const commandList = commands.map((sub) => { diff --git a/lib/option.js b/lib/option.js index 6ef2473b5..64ec662b6 100644 --- a/lib/option.js +++ b/lib/option.js @@ -33,7 +33,7 @@ class Option { this.argChoices = undefined; this.conflictsWith = []; this.implied = undefined; - this.helpGroupTitle = undefined; + this.helpGroupTitle = undefined; // soft initialised when option added to command } /** diff --git a/tests/command.chain.test.js b/tests/command.chain.test.js index b815954bf..7fd041b2a 100644 --- a/tests/command.chain.test.js +++ b/tests/command.chain.test.js @@ -257,4 +257,10 @@ describe('Command methods that should return this for chaining', () => { const result = program.nameFromFilename('name'); expect(result).toBe(program); }); + + test('when set .helpGroup(title) then returns this', () => { + const program = new Command(); + const result = program.helpGroup('Commands:'); + expect(result).toBe(program); + }); }); diff --git a/tests/option.chain.test.js b/tests/option.chain.test.js index fbfa23e42..ada9c611b 100644 --- a/tests/option.chain.test.js +++ b/tests/option.chain.test.js @@ -42,4 +42,10 @@ describe('Option methods that should return this for chaining', () => { const result = option.conflicts(['a']); expect(result).toBe(option); }); + + test('when call .helpGroup(title) then returns this', () => { + const option = new Option('-e,--example '); + const result = option.helpGroup('Options:'); + expect(result).toBe(option); + }); }); From 7c83ea8a9a0b7e16b1d8f7b7585c5a7b32e7be71 Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 24 Feb 2025 22:39:58 +1300 Subject: [PATCH 03/10] Add example of using help groups --- examples/help-groups.js | 82 +++++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/examples/help-groups.js b/examples/help-groups.js index eb0a1a47a..8cfa135ac 100644 --- a/examples/help-groups.js +++ b/examples/help-groups.js @@ -1,47 +1,75 @@ const { Command, Option } = require('commander'); -// The help group can be set explicitly on an Option or Command using `.helpGroup()`, -// or by setting the default group before adding the option or command. +// Show the two approaches for adding help groups, and how to customise the built-in help and version. const program = new Command(); +const devOptionsTitle = 'Development Options:'; +const managementCommandsTitle = 'Management Commands:'; -const directGroups = program - .command('direct-groups') - .description('example with help groups set by using .helpGroup()') +// The high-level approach is use .optionsGroup() and .commandsGroup() before adding the options/commands. +const docker1 = program + .command('docker1') + .description('help groups created using .optionsGroup() and .commandsGroup()') + .addOption(new Option('-h, --hostname ', 'container host name')) + .addOption(new Option('-p, --port ', 'container port number')) + .optionsGroup(devOptionsTitle) + .option('-d, --debug', 'add extra trace information') + .option('-w, --watch', 'run and relaunch service on file changes'); + +docker1 + .command('run') + .description('create and run a new container from an image'); +docker1.command('exec').description('execute a command in a running container'); + +docker1.commandsGroup(managementCommandsTitle); +docker1.command('images').description('manage images'); +docker1.command('volumes').description('manage volumes'); + +// The low-level approach is using .helpGroup() on the Option or Command. +const docker2 = program + .command('docker2') + .description('help groups created using .helpGroup()') + .addOption(new Option('-h, --hostname ', 'container host name')) + .addOption(new Option('-p, --port ', 'container port number')) .addOption( new Option('-d, --debug', 'add extra trace information').helpGroup( - 'Development Options:', + devOptionsTitle, ), - ); -directGroups - .command('watch') - .description('run project in watch mode') - .helpGroup('Development Commands:'); - -const defaultGroups = program - .command('default-groups') - .description( - 'example with help groups set by using optionsGroup/commandsGroup ', ) - .optionsGroup('Development Options:') - .option('-d, --debug', 'add extra trace information', 'Development Options:'); -defaultGroups.commandsGroup('Development Commands:'); -defaultGroups.command('watch').description('run project in watch mode'); + .addOption( + new Option( + '-w, --watch', + 'run and relaunch service on file changes', + ).helpGroup(devOptionsTitle), + ); + +docker2 + .command('run') + .description('create and run a new container from an image'); +docker2.command('exec').description('execute a command in a running container'); +docker2 + .command('images') + .description('manage images') + .helpGroup(managementCommandsTitle); +docker2 + .command('volumes') + .description('manage volumes') + .helpGroup(managementCommandsTitle); + +// Customise group for built-ins by explicitly adding them with default group set. program .command('built-in') - .description( - 'changing the help for built-ins by explicitly customising built-in', - ) + .description('help groups for help and version') .optionsGroup('Built-in Options:') .version('v2.3.4') - .helpOption('-h, --help') // get default group by customising help option + .helpOption('-h, --help') .commandsGroup('Built-in Commands:') - .helpCommand('help'); // get default group by customising help option + .helpCommand('help'); program.parse(); // Try the following: -// node help-groups.js help direct-groups -// node help-groups.js help default-groups +// node help-groups.js help docker1 +// node help-groups.js help docker2 // node help-groups.js help built-in From 09ff045ee4325dd67c25c596777b6be0c997fc1a Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 24 Feb 2025 23:11:42 +1300 Subject: [PATCH 04/10] Rename routine --- lib/help.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/help.js b/lib/help.js index ca312d22d..71d07cc5d 100644 --- a/lib/help.js +++ b/lib/help.js @@ -406,7 +406,7 @@ class Help { * @param {Function} getGroup * @returns {Map} */ - getItemGroups(unsortedItems, visibleItems, getGroup) { + groupItems(unsortedItems, visibleItems, getGroup) { const result = new Map(); // Add groups in order of appearance in unsortedItems. unsortedItems.forEach((item) => { @@ -470,7 +470,7 @@ class Help { ); // Options - const optionGroups = this.getItemGroups( + const optionGroups = this.groupItems( cmd.options, helper.visibleOptions(cmd), (option) => option.helpGroupTitle ?? 'Options:', @@ -500,7 +500,7 @@ class Help { } // Commands - const commandGroups = this.getItemGroups( + const commandGroups = this.groupItems( cmd.commands, helper.visibleCommands(cmd), (sub) => sub._helpGroupTitle || 'Commands:', From 7a7d80d1824f901a675b3039b52b1615a4db292f Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 27 Feb 2025 22:49:26 +1300 Subject: [PATCH 05/10] Add missing param in JSDoc --- lib/help.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/help.js b/lib/help.js index 71d07cc5d..893950124 100644 --- a/lib/help.js +++ b/lib/help.js @@ -391,6 +391,7 @@ class Help { /** * @param {string} title * @param {string[]} items + * @param {Help} helper * @returns string[] */ formatItemList(title, items, helper) { From 2ce700669d99b8a53aaaa07db4c254d669160dd5 Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 28 Feb 2025 13:37:38 +1300 Subject: [PATCH 06/10] Add test for undocumented edge case of calling .helpOption(true) --- tests/command.helpOption.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/command.helpOption.test.js b/tests/command.helpOption.test.js index ad6ad93e4..e61cb2965 100644 --- a/tests/command.helpOption.test.js +++ b/tests/command.helpOption.test.js @@ -115,4 +115,20 @@ describe('helpOption', () => { program.parse(['UNKNOWN'], { from: 'user' }); }).toThrow("error: unknown command 'UNKNOWN'"); }); + + test('when helpOption(true) after false then helpInformation does include --help', () => { + const program = new commander.Command(); + program.helpOption(false); + program.helpOption(true); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch('--help'); + }); + + test('when helpOption(true) after customise then helpInformation still customised', () => { + const program = new commander.Command(); + program.helpOption('--ASSIST'); + program.helpOption(true); + const helpInformation = program.helpInformation(); + expect(helpInformation).toMatch('--ASSIST'); + }); }); From 6053835fc49c40bb052856244d93a1a62ac28249 Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 28 Feb 2025 15:06:50 +1300 Subject: [PATCH 07/10] Experimental support for adding help group to built-in help option+command without "customising" --- examples/help-groups.js | 6 ++++-- lib/command.js | 10 +++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/examples/help-groups.js b/examples/help-groups.js index 8cfa135ac..2471dd5c4 100644 --- a/examples/help-groups.js +++ b/examples/help-groups.js @@ -63,9 +63,11 @@ program .description('help groups for help and version') .optionsGroup('Built-in Options:') .version('v2.3.4') - .helpOption('-h, --help') + // .helpOption('-h, --help') + .helpOption(true) .commandsGroup('Built-in Commands:') - .helpCommand('help'); + // .helpCommand('help'); + .helpCommand(true); program.parse(); diff --git a/lib/command.js b/lib/command.js index 2e1595eb8..247390c7e 100644 --- a/lib/command.js +++ b/lib/command.js @@ -406,6 +406,10 @@ class Command extends EventEmitter { helpCommand(enableOrNameAndArgs, description) { if (typeof enableOrNameAndArgs === 'boolean') { this._addImplicitHelpCommand = enableOrNameAndArgs; + if (enableOrNameAndArgs && this._defaultCommandGroup) { + // make the command to store the group + this._initCommandGroup(this._getHelpCommand()); + } return this; } @@ -2528,7 +2532,11 @@ Expecting one of '${allowedValues.join("', '")}'`); // Support enabling/disabling built-in help option. if (typeof flags === 'boolean') { if (flags) { - if (this._helpOption === null) this._helpOption = undefined; + if (this._helpOption === null) this._helpOption = undefined; // reenable + if (this._defaultOptionGroup) { + // make the option to store the group + this._initOptionGroup(this._getHelpOption()); + } } else { this._helpOption = null; // disable } From e5cd8fd3fbde125b2b9cc823dad35c4756efc40b Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 28 Feb 2025 17:50:54 +1300 Subject: [PATCH 08/10] Add help groups to README and change group title to heading --- Readme.md | 9 +++++++++ lib/command.js | 39 +++++++++++++++++++++------------------ lib/help.js | 10 +++++----- lib/option.js | 10 +++++----- 4 files changed, 40 insertions(+), 28 deletions(-) diff --git a/Readme.md b/Readme.md index 8fcc64cd9..3106b769a 100644 --- a/Readme.md +++ b/Readme.md @@ -38,6 +38,7 @@ Read this in other languages: English | [简体中文](./Readme_zh-CN.md) - [.description and .summary](#description-and-summary) - [.helpOption(flags, description)](#helpoptionflags-description) - [.helpCommand()](#helpcommand) + - [Help Groups](#help-groups) - [More configuration](#more-configuration-2) - [Custom event listeners](#custom-event-listeners) - [Bits and pieces](#bits-and-pieces) @@ -926,6 +927,14 @@ program.helpCommand('assist [command]', 'show assistance'); (Or use `.addHelpCommand()` to add a command you construct yourself.) +### Help Groups + +The help by default lists options under the the heading `Options:` and commands under `Commands:`. You can create your own groups +with different headings. The high-level way is to set the desired group heading while adding the options and commands, +using `.optionsGroup()` and `.commandsGroup()`. The low-level way is using `.helpGroup()` on an individual `Option` or `Command` + +Example file: [help-groups.js](./examples/help-groups.js) + ### More configuration The built-in help is formatted using the Help class. diff --git a/lib/command.js b/lib/command.js index 247390c7e..9f82e82d3 100644 --- a/lib/command.js +++ b/lib/command.js @@ -81,7 +81,7 @@ class Command extends EventEmitter { this._helpCommand = undefined; // lazy initialised, inherited this._helpConfiguration = {}; /** @type {string | undefined} */ - this._helpGroupTitle = undefined; // soft initialised when added to parent + this._helpGroupHeading = undefined; // soft initialised when added to parent /** @type {string | undefined} */ this._defaultCommandGroup = undefined; /** @type {string | undefined} */ @@ -2310,20 +2310,21 @@ Expecting one of '${allowedValues.join("', '")}'`); } /** - * Set/get the help group for this subcommand in parent command's help. + * Set/get the help group heading for this subcommand in parent command's help. * - * @param {string} [str] + * @param {string} [heading] * @return {Command | string} */ - helpGroup(str) { - if (str === undefined) return this._helpGroupTitle ?? ''; - this._helpGroupTitle = str; + helpGroup(heading) { + if (heading === undefined) return this._helpGroupHeading ?? ''; + this._helpGroupHeading = heading; return this; } /** - * Set/get the default help group title for next subcommands added to this command. + * Set/get the default help group heading for subcommands added to this command. + * (This does not override a group set directly on the subcommand using .helpGroup().) * * @example * program.commandsGroup('Development Commands:); @@ -2331,29 +2332,31 @@ Expecting one of '${allowedValues.join("', '")}'`); * program.command('lint')... * ... * - * @param {string} [title] + * @param {string} [heading] * @returns {Command | string} */ - commandsGroup(title) { - if (title === undefined) return this._defaultCommandGroup ?? ''; - this._defaultCommandGroup = title; + commandsGroup(heading) { + if (heading === undefined) return this._defaultCommandGroup ?? ''; + this._defaultCommandGroup = heading; return this; } /** - * Set/get the default help group title for next options added to this command. + * Set/get the default help group heading for options added to this command. + * (This does not override a group set directly on the option using .helpGroup().) * * @example - * program.optionsGroup('Development Options:') + * program + * .optionsGroup('Development Options:') * .option('-d, --debug', 'output extra debugging') * .option('-p, --profile', 'output profiling information') * - * @param {string} [title] + * @param {string} [heading] * @returns {Command | string} */ - optionsGroup(title) { - if (title === undefined) return this._defaultOptionGroup ?? ''; - this._defaultOptionGroup = title; + optionsGroup(heading) { + if (heading === undefined) return this._defaultOptionGroup ?? ''; + this._defaultOptionGroup = heading; return this; } @@ -2362,7 +2365,7 @@ Expecting one of '${allowedValues.join("', '")}'`); * @private */ _initOptionGroup(option) { - if (this._defaultOptionGroup && !option.helpGroupTitle) + if (this._defaultOptionGroup && !option.helpGroupHeading) option.helpGroup(this._defaultOptionGroup); } diff --git a/lib/help.js b/lib/help.js index 893950124..a6d68c6d0 100644 --- a/lib/help.js +++ b/lib/help.js @@ -389,15 +389,15 @@ class Help { } /** - * @param {string} title + * @param {string} heading * @param {string[]} items * @param {Help} helper * @returns string[] */ - formatItemList(title, items, helper) { + formatItemList(heading, items, helper) { if (items.length === 0) return []; - return [helper.styleTitle(title), ...items, '']; + return [helper.styleTitle(heading), ...items, '']; } /** @@ -474,7 +474,7 @@ class Help { const optionGroups = this.groupItems( cmd.options, helper.visibleOptions(cmd), - (option) => option.helpGroupTitle ?? 'Options:', + (option) => option.helpGroupHeading ?? 'Options:', ); optionGroups.forEach((options, group) => { const optionList = options.map((option) => { @@ -504,7 +504,7 @@ class Help { const commandGroups = this.groupItems( cmd.commands, helper.visibleCommands(cmd), - (sub) => sub._helpGroupTitle || 'Commands:', + (sub) => sub.helpGroup() || 'Commands:', ); commandGroups.forEach((commands, group) => { const commandList = commands.map((sub) => { diff --git a/lib/option.js b/lib/option.js index 64ec662b6..715c6cf08 100644 --- a/lib/option.js +++ b/lib/option.js @@ -33,7 +33,7 @@ class Option { this.argChoices = undefined; this.conflictsWith = []; this.implied = undefined; - this.helpGroupTitle = undefined; // soft initialised when option added to command + this.helpGroupHeading = undefined; // soft initialised when option added to command } /** @@ -221,13 +221,13 @@ class Option { } /** - * Set group for option in help. + * Set the help group heading. * - * @param {string} title + * @param {string} heading * @return {Option} */ - helpGroup(title) { - this.helpGroupTitle = title; + helpGroup(heading) { + this.helpGroupHeading = heading; return this; } From 30b2316cd82f448db3707b203efbb0210bb16da9 Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 28 Feb 2025 17:59:38 +1300 Subject: [PATCH 09/10] Tweak example --- examples/help-groups.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/help-groups.js b/examples/help-groups.js index 2471dd5c4..cb08e854e 100644 --- a/examples/help-groups.js +++ b/examples/help-groups.js @@ -57,17 +57,15 @@ docker2 .description('manage volumes') .helpGroup(managementCommandsTitle); -// Customise group for built-ins by explicitly adding them with default group set. +// Customise group for built-ins by configuring them with default group set. program .command('built-in') .description('help groups for help and version') .optionsGroup('Built-in Options:') .version('v2.3.4') - // .helpOption('-h, --help') - .helpOption(true) + .helpOption('-h, --help') // or .helpOption(true) to use default flags .commandsGroup('Built-in Commands:') - // .helpCommand('help'); - .helpCommand(true); + .helpCommand('help [command]'); // or .helpCommand(true) to use default name program.parse(); From 3b26bff534574c3d749c8dbc1186434dfa0e0a0e Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 28 Feb 2025 19:24:17 +1300 Subject: [PATCH 10/10] Add tests --- tests/command.chain.test.js | 14 +- tests/helpGroup.test.js | 252 ++++++++++++++++++++++++++++++++++++ tests/option.chain.test.js | 2 +- 3 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 tests/helpGroup.test.js diff --git a/tests/command.chain.test.js b/tests/command.chain.test.js index 7fd041b2a..94d1ead95 100644 --- a/tests/command.chain.test.js +++ b/tests/command.chain.test.js @@ -258,9 +258,21 @@ describe('Command methods that should return this for chaining', () => { expect(result).toBe(program); }); - test('when set .helpGroup(title) then returns this', () => { + test('when set .helpGroup(heading) then returns this', () => { const program = new Command(); const result = program.helpGroup('Commands:'); expect(result).toBe(program); }); + + test('when set .commandsGroup(heading) then returns this', () => { + const program = new Command(); + const result = program.commandsGroup('Commands:'); + expect(result).toBe(program); + }); + + test('when set .optionsGroup(heading) then returns this', () => { + const program = new Command(); + const result = program.optionsGroup('Options:'); + expect(result).toBe(program); + }); }); diff --git a/tests/helpGroup.test.js b/tests/helpGroup.test.js new file mode 100644 index 000000000..112887e84 --- /dev/null +++ b/tests/helpGroup.test.js @@ -0,0 +1,252 @@ +const { Command, Option } = require('../'); + +// Similar tests for Option.helpGroup() and Command.helpGroup(), +// and for Command.optionsGroup() and Command.commandsGroup(). + +describe('Option.helpGroup', () => { + test('when add one option with helpGroup then help contains group', () => { + const program = new Command(); + program.addOption(new Option('--alpha').helpGroup('Greek:')); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *--alpha/); + }); + + test('when add two options with helpGroup then help contains group', () => { + const program = new Command(); + program.addOption(new Option('--alpha').helpGroup('Greek:')); + program.addOption(new Option('--beta').helpGroup('Greek:')); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *--alpha\n *--beta/); + }); +}); + +describe('Command.helpGroup', () => { + test('when add one command with helpGroup then help contains group', () => { + const program = new Command(); + program.command('alpha').helpGroup('Greek:'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *alpha/); + }); + + test('when add two commands with helpGroup then help contains group', () => { + const program = new Command(); + program.command('alpha').helpGroup('Greek:'); + program.command('beta').helpGroup('Greek:'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *alpha\n *beta/); + }); +}); + +describe('.optionsGroup', () => { + test('when add one option then help contains group', () => { + const program = new Command(); + program.optionsGroup('Greek:'); + program.option('--alpha'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *--alpha/); + }); + + test('when add two options then help contains group with two options', () => { + const program = new Command(); + program.optionsGroup('Greek:'); + program.option('--alpha'); + program.option('--beta'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *--alpha\n *--beta/); + }); + + test('when add options with different groups then help contains two groups', () => { + const program = new Command(); + program.optionsGroup('Greek:'); + program.option('--alpha'); + program.optionsGroup('Latin:'); + program.option('--unus'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *--alpha/); + expect(helpInfo).toMatch(/Latin:\n *--unus/); + }); + + test('when implicit help option then help option not affected', () => { + const program = new Command(); + program.optionsGroup('Greek:'); + program.option('--alpha'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Options:\n *-h, --help/); + }); + + test('when option with helpGroup then helpGroup wins', () => { + const program = new Command(); + program.optionsGroup('Greek:'); + program.addOption(new Option('--unus').helpGroup('Latin:')); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Latin:\n *--unus/); + }); + + test('when add no options with heading then heading does not appear', () => { + const program = new Command(); + program.optionsGroup('Greek:'); + const helpInfo = program.helpInformation(); + expect(helpInfo).not.toMatch(/Greek/); + }); + + test('when add no visible options with heading then heading does not appear', () => { + const program = new Command(); + program.optionsGroup('Greek:'); + program.addOption(new Option('--alpha').hideHelp()); + const helpInfo = program.helpInformation(); + expect(helpInfo).not.toMatch(/Greek/); + }); + + test('when .helpOption(flags) then help option in group', () => { + const program = new Command(); + program.optionsGroup('Greek:'); + program.helpOption('--assist'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *--assist/); + }); + + test('when .helpOption(true) then help option in group', () => { + const program = new Command(); + program.optionsGroup('Greek:'); + program.helpOption(true); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *-h, --help/); + }); + + test('when .version(str) then version option in group', () => { + const program = new Command(); + program.optionsGroup('Greek:'); + program.version('1.2.3'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *-V, --version/); + }); + + test('when set sortOptions then options are sorted within groups', () => { + const program = new Command(); + program.configureHelp({ sortOptions: true }); + program.optionsGroup('Latin:'); + program.option('--unus'); + program.option('--duo'); + program.optionsGroup('Greek:'); + program.option('--beta'); + program.option('--alpha'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Latin:\n *--duo\n *--unus/); + expect(helpInfo).toMatch(/Greek:\n *--alpha\n *--beta/); + }); + + test('when set sortOptions then groups are in order added not sorted', () => { + const program = new Command(); + program.configureHelp({ sortOptions: true }); + program.addOption(new Option('--bbb').helpGroup('BBB:')); + program.addOption(new Option('--ccc').helpGroup('CCC:')); + program.addOption(new Option('--aaa').helpGroup('AAA:')); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch( + /BBB:\n *--bbb.*\n\nCCC:\n *--ccc.*\n\nAAA:\n *--aaa/, + ); + }); +}); + +describe('.commandsGroup', () => { + test('when add one command then help contains group', () => { + const program = new Command(); + program.commandsGroup('Greek:'); + program.command('alpha'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *alpha/); + }); + + test('when add two commands then help contains group with two commands', () => { + const program = new Command(); + program.commandsGroup('Greek:'); + program.command('alpha'); + program.command('beta'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *alpha\n *beta/); + }); + + test('when add commands with different groups then help contains two groups', () => { + const program = new Command(); + program.commandsGroup('Greek:'); + program.command('alpha'); + program.commandsGroup('Latin:'); + program.command('unus'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *alpha/); + expect(helpInfo).toMatch(/Latin:\n *unus/); + }); + + test('when implicit help command then help command not affected', () => { + const program = new Command(); + program.commandsGroup('Greek:'); + program.command('alpha'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Commands:\n *help/); + }); + + test('when command with custom helpGroup then helpGroup wins', () => { + const program = new Command(); + program.commandsGroup('Greek:'); + program.command('unus').helpGroup('Latin:'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Latin:\n *unus/); + }); + + test('when add no commands with heading then heading does not appear', () => { + const program = new Command(); + program.commandsGroup('Greek:'); + const helpInfo = program.helpInformation(); + expect(helpInfo).not.toMatch(/Greek/); + }); + + test('when add no visible command with heading then heading does not appear', () => { + const program = new Command(); + program.commandsGroup('Greek:'); + program.command('alpha', { hidden: true }); + const helpInfo = program.helpInformation(); + expect(helpInfo).not.toMatch(/Greek/); + }); + + test('when .helpCommand(name) then help command in group', () => { + const program = new Command(); + program.command('foo'); + program.commandsGroup('Greek:'); + program.helpCommand('assist'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *assist/); + }); + + test('when .helpCommand(true) then help command in group', () => { + const program = new Command(); + program.command('foo'); + program.commandsGroup('Greek:'); + program.helpCommand(true); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Greek:\n *help/); + }); + + test('when set sortCommands then commands are sorted within groups', () => { + const program = new Command(); + program.configureHelp({ sortSubcommands: true }); + program.commandsGroup('Latin:'); + program.command('unus'); + program.command('duo'); + program.commandsGroup('Greek:'); + program.command('beta'); + program.command('alpha'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/Latin:\n *duo\n *unus/); + expect(helpInfo).toMatch(/Greek:\n *alpha\n *beta/); + }); + + test('when set sortOptions then groups are in order added not sorted', () => { + const program = new Command(); + program.configureHelp({ sortSubcommands: true }); + program.command('bbb').helpGroup('BBB:'); + program.command('ccc').helpGroup('CCC:'); + program.command('aaa').helpGroup('AAA:'); + const helpInfo = program.helpInformation(); + expect(helpInfo).toMatch(/BBB:\n *bbb.*\n\nCCC:\n *ccc.*\n\nAAA:\n *aaa/); + }); +}); diff --git a/tests/option.chain.test.js b/tests/option.chain.test.js index ada9c611b..cd5abf075 100644 --- a/tests/option.chain.test.js +++ b/tests/option.chain.test.js @@ -43,7 +43,7 @@ describe('Option methods that should return this for chaining', () => { expect(result).toBe(option); }); - test('when call .helpGroup(title) then returns this', () => { + test('when call .helpGroup(heading) then returns this', () => { const option = new Option('-e,--example '); const result = option.helpGroup('Options:'); expect(result).toBe(option);