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/examples/help-groups.js b/examples/help-groups.js new file mode 100644 index 000000000..280d99cf5 --- /dev/null +++ b/examples/help-groups.js @@ -0,0 +1,75 @@ +const { Command, Option } = require('commander'); + +// Show the two approaches for adding help groups, and how to customise the built-in help and version. + +const program = new Command(); +const devOptionsHeading = 'Development Options:'; +const managementCommandsHeading = 'Management Commands:'; + +// 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(devOptionsHeading) + .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(managementCommandsHeading); +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( + devOptionsHeading, + ), + ) + .addOption( + new Option( + '-w, --watch', + 'run and relaunch service on file changes', + ).helpGroup(devOptionsHeading), + ); + +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(managementCommandsHeading); +docker2 + .command('volumes') + .description('manage volumes') + .helpGroup(managementCommandsHeading); + +// 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') // or .helpOption(true) to use default flags + .commandsGroup('Built-in Commands:') + .helpCommand('help [command]'); // or .helpCommand(true) to use default name + +program.parse(); + +// Try the following: +// node help-groups.js help docker1 +// node help-groups.js help docker2 +// node help-groups.js help built-in diff --git a/lib/command.js b/lib/command.js index efbb8f614..9f82e82d3 100644 --- a/lib/command.js +++ b/lib/command.js @@ -80,6 +80,12 @@ class Command extends EventEmitter { /** @type {Command} */ this._helpCommand = undefined; // lazy initialised, inherited this._helpConfiguration = {}; + /** @type {string | undefined} */ + this._helpGroupHeading = undefined; // soft initialised when added to parent + /** @type {string | undefined} */ + this._defaultCommandGroup = undefined; + /** @type {string | undefined} */ + this._defaultOptionGroup = undefined; } /** @@ -400,11 +406,15 @@ 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; } - 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); @@ -414,6 +424,8 @@ class Command extends EventEmitter { this._addImplicitHelpCommand = true; this._helpCommand = helpCommand; + // init group unless lazy create + if (enableOrNameAndArgs || description) this._initCommandGroup(helpCommand); return this; } @@ -435,6 +447,7 @@ class Command extends EventEmitter { this._addImplicitHelpCommand = true; this._helpCommand = helpCommand; + this._initCommandGroup(helpCommand); return this; } @@ -613,6 +626,7 @@ Expecting one of '${allowedValues.join("', '")}'`); - already used by option '${matchingOption.flags}'`); } + this._initOptionGroup(option); this.options.push(option); } @@ -640,6 +654,7 @@ Expecting one of '${allowedValues.join("', '")}'`); ); } + this._initCommandGroup(command); this.commands.push(command); } @@ -2294,6 +2309,75 @@ Expecting one of '${allowedValues.join("', '")}'`); return this; } + /** + * Set/get the help group heading for this subcommand in parent command's help. + * + * @param {string} [heading] + * @return {Command | string} + */ + + helpGroup(heading) { + if (heading === undefined) return this._helpGroupHeading ?? ''; + this._helpGroupHeading = heading; + return this; + } + + /** + * 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:); + * program.command('watch')... + * program.command('lint')... + * ... + * + * @param {string} [heading] + * @returns {Command | string} + */ + commandsGroup(heading) { + if (heading === undefined) return this._defaultCommandGroup ?? ''; + this._defaultCommandGroup = heading; + return this; + } + + /** + * 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:') + * .option('-d, --debug', 'output extra debugging') + * .option('-p, --profile', 'output profiling information') + * + * @param {string} [heading] + * @returns {Command | string} + */ + optionsGroup(heading) { + if (heading === undefined) return this._defaultOptionGroup ?? ''; + this._defaultOptionGroup = heading; + return this; + } + + /** + * @param {Option} option + * @private + */ + _initOptionGroup(option) { + if (this._defaultOptionGroup && !option.helpGroupHeading) + 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. @@ -2448,12 +2532,14 @@ 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; // reenable + if (this._defaultOptionGroup) { + // make the option to store the group + this._initOptionGroup(this._getHelpOption()); + } } else { this._helpOption = null; // disable } @@ -2461,9 +2547,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; } @@ -2492,6 +2581,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 e44801541..a6d68c6d0 100644 --- a/lib/help.js +++ b/lib/help.js @@ -388,6 +388,43 @@ class Help { return argument.description; } + /** + * @param {string} heading + * @param {string[]} items + * @param {Help} helper + * @returns string[] + */ + formatItemList(heading, items, helper) { + if (items.length === 0) return []; + + return [helper.styleTitle(heading), ...items, '']; + } + + /** + * + * @param {Command[] | Option[]} unsortedItems + * @param {Command[] | Option[]} visibleItems + * @param {Function} getGroup + * @returns {Map} + */ + groupItems(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. * @@ -429,28 +466,25 @@ 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.groupItems( + cmd.options, + helper.visibleOptions(cmd), + (option) => option.helpGroupHeading ?? '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 +495,26 @@ 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.groupItems( + cmd.commands, + helper.visibleCommands(cmd), + (sub) => 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..715c6cf08 100644 --- a/lib/option.js +++ b/lib/option.js @@ -33,6 +33,7 @@ class Option { this.argChoices = undefined; this.conflictsWith = []; this.implied = undefined; + this.helpGroupHeading = undefined; // soft initialised when option added to command } /** @@ -219,6 +220,17 @@ class Option { return camelcase(this.name()); } + /** + * Set the help group heading. + * + * @param {string} heading + * @return {Option} + */ + helpGroup(heading) { + this.helpGroupHeading = heading; + return this; + } + /** * Check if `arg` matches the short or long flag. * diff --git a/tests/command.chain.test.js b/tests/command.chain.test.js index b815954bf..94d1ead95 100644 --- a/tests/command.chain.test.js +++ b/tests/command.chain.test.js @@ -257,4 +257,22 @@ describe('Command methods that should return this for chaining', () => { const result = program.nameFromFilename('name'); expect(result).toBe(program); }); + + 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/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'); + }); }); 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 fbfa23e42..cd5abf075 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(heading) then returns this', () => { + const option = new Option('-e,--example '); + const result = option.helpGroup('Options:'); + expect(result).toBe(option); + }); });