From b4ce0858dfbc5a84c4710a7014f3e0ab7c78e90a Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 10 Jul 2023 22:03:55 +1200 Subject: [PATCH 1/8] Prototype command groups, no setup support --- lib/command.js | 1 + lib/help.js | 40 ++++++++++++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/lib/command.js b/lib/command.js index 590a271dd..28edbe656 100644 --- a/lib/command.js +++ b/lib/command.js @@ -77,6 +77,7 @@ class Command extends EventEmitter { this._helpCommandnameAndArgs = 'help [command]'; this._helpCommandDescription = 'display help for command'; this._helpConfiguration = {}; + this.commandGroups = null; // Lazy create Map if used. } /** diff --git a/lib/help.js b/lib/help.js index 14e0fb9f3..6852ce634 100644 --- a/lib/help.js +++ b/lib/help.js @@ -46,6 +46,31 @@ class Help { return visibleCommands; } + /** + * Make public? + * @api private + * @return {Map} + */ + _visibleCommandGroups(cmd, helper) { + const commandNameGroups = cmd._commandGroups ?? new Map(); + const commandGroups = new Map(); + const lookupGroup = new Map(); + commandNameGroups.forEach((commandNames, groupTitle) => { + commandGroups.set(groupTitle, []); + commandNames.forEach(name => lookupGroup.set(name, groupTitle)); + }); + console.log(lookupGroup); + console.log(lookupGroup.get('b1')); + commandGroups.set('Commands:', []); // (may be there already, but make sure) + // Populate groups with visible (possibly sorted) commands. + helper.visibleCommands(cmd).forEach(sub => { + const group = lookupGroup.get(sub.name()) ?? 'Commands:'; + commandGroups.get(group).push(sub); + }); + console.log(commandGroups.keys()); + return commandGroups; + } + /** * Compare options for sort. * @@ -395,13 +420,16 @@ class Help { } } - // Commands - const commandList = helper.visibleCommands(cmd).map((cmd) => { - return formatItem(helper.subcommandTerm(cmd), helper.subcommandDescription(cmd)); + // Command Groups + const commandGroups = this._visibleCommandGroups(cmd, helper); + commandGroups.forEach((cmds, groupTitle) => { + if (cmds.length > 0) { + const commandList = cmds.map((cmd) => { + return formatItem(helper.subcommandTerm(cmd), helper.subcommandDescription(cmd)); + }); + output = output.concat([groupTitle, formatList(commandList), '']); + } }); - if (commandList.length > 0) { - output = output.concat(['Commands:', formatList(commandList), '']); - } return output.join('\n'); } From fda96747871e3ecd0d660cbac5c5a2f1ca57935e Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 10 Jul 2023 22:26:18 +1200 Subject: [PATCH 2/8] Remove debugging, minor tidy --- lib/help.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/help.js b/lib/help.js index 6852ce634..01dc752d2 100644 --- a/lib/help.js +++ b/lib/help.js @@ -48,26 +48,25 @@ class Help { /** * Make public? - * @api private + * @param {Command} cmd + * @param {Help} helper * @return {Map} + * @api private */ _visibleCommandGroups(cmd, helper) { - const commandNameGroups = cmd._commandGroups ?? new Map(); + const commandNameGroups = cmd.commandGroups ?? new Map(); const commandGroups = new Map(); const lookupGroup = new Map(); commandNameGroups.forEach((commandNames, groupTitle) => { commandGroups.set(groupTitle, []); commandNames.forEach(name => lookupGroup.set(name, groupTitle)); }); - console.log(lookupGroup); - console.log(lookupGroup.get('b1')); commandGroups.set('Commands:', []); // (may be there already, but make sure) // Populate groups with visible (possibly sorted) commands. helper.visibleCommands(cmd).forEach(sub => { const group = lookupGroup.get(sub.name()) ?? 'Commands:'; commandGroups.get(group).push(sub); }); - console.log(commandGroups.keys()); return commandGroups; } From da5d09b5f987efec3195f0a8e49dbab98355f282 Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 10 Jul 2023 23:44:48 +1200 Subject: [PATCH 3/8] Add support for option groups --- lib/command.js | 3 ++- lib/help.js | 55 +++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/lib/command.js b/lib/command.js index 28edbe656..f6fc28f50 100644 --- a/lib/command.js +++ b/lib/command.js @@ -77,7 +77,8 @@ class Command extends EventEmitter { this._helpCommandnameAndArgs = 'help [command]'; this._helpCommandDescription = 'display help for command'; this._helpConfiguration = {}; - this.commandGroups = null; // Lazy create Map if used. + this.commandGroups = null; + this.optionGroups = null; } /** diff --git a/lib/help.js b/lib/help.js index 01dc752d2..2e763f4d8 100644 --- a/lib/help.js +++ b/lib/help.js @@ -54,6 +54,11 @@ class Help { * @api private */ _visibleCommandGroups(cmd, helper) { + const defaultTitle = 'Commands:'; + // Short-circuit normal case of no groups. (Same result as full processing.) + if (!cmd.commandGroups) return new Map([[defaultTitle, helper.visibleCommands(cmd)]]); + + // Refactor groups for matching against visible commands. const commandNameGroups = cmd.commandGroups ?? new Map(); const commandGroups = new Map(); const lookupGroup = new Map(); @@ -61,15 +66,48 @@ class Help { commandGroups.set(groupTitle, []); commandNames.forEach(name => lookupGroup.set(name, groupTitle)); }); - commandGroups.set('Commands:', []); // (may be there already, but make sure) + commandGroups.set(defaultTitle, []); // (may be there already, but make sure) // Populate groups with visible (possibly sorted) commands. helper.visibleCommands(cmd).forEach(sub => { - const group = lookupGroup.get(sub.name()) ?? 'Commands:'; + const group = lookupGroup.get(sub.name()) ?? defaultTitle; commandGroups.get(group).push(sub); }); return commandGroups; } + /** + * Make public? + * @param {Command} cmd + * @param {Help} helper + * @return {Map} + * @api private + */ + _visibleOptionGroups(cmd, helper) { + const defaultTitle = 'Options:'; + // Short-circuit normal case of no groups. (Same result as full processing.) + if (!cmd.optionGroups) return new Map([[defaultTitle, helper.visibleOptions(cmd)]]); + + // Refactor groups for matching against visible options. + const optionFlagGroups = cmd.optionGroups ?? new Map(); + const optionGroups = new Map(); + const lookupGroup = new Map(); + optionFlagGroups.forEach((optionFlags, groupTitle) => { + optionGroups.set(groupTitle, []); + optionFlags.forEach(flag => lookupGroup.set(flag, groupTitle)); + }); + optionGroups.set(defaultTitle, []); // (may be there already, but make sure) + // Populate groups with visible (possibly sorted) options. + helper.visibleOptions(cmd).forEach(opt => { + let group; + if (opt.long) group = lookupGroup.get(opt.long); + if (!group && opt.short) group = lookupGroup.get(opt.short); + if (!group) group = lookupGroup.get(opt.attributeName()); + if (!group) group = defaultTitle; + optionGroups.get(group).push(opt); + }); + return optionGroups; + } + /** * Compare options for sort. * @@ -403,12 +441,15 @@ class Help { } // Options - const optionList = helper.visibleOptions(cmd).map((option) => { - return formatItem(helper.optionTerm(option), helper.optionDescription(option)); + const optionGroups = this._visibleOptionGroups(cmd, helper); + optionGroups.forEach((opts, groupTitle) => { + if (opts.length > 0) { + const optionList = opts.map((opt) => { + return formatItem(helper.optionTerm(opt), helper.optionDescription(opt)); + }); + output = output.concat([groupTitle, formatList(optionList), '']); + } }); - if (optionList.length > 0) { - output = output.concat(['Options:', formatList(optionList), '']); - } if (this.showGlobalOptions) { const globalOptionList = helper.visibleGlobalOptions(cmd).map((option) => { From d250c8a0c7388a053cab4836490554a59704a07d Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 14 Jul 2023 20:38:32 +1200 Subject: [PATCH 4/8] Add support for group property --- lib/command.js | 16 ++++++- lib/help.js | 93 +++++++++++++++++++++++-------------- lib/option.js | 14 ++++++ tests/command.chain.test.js | 6 +++ tests/option.chain.test.js | 6 +++ 5 files changed, 97 insertions(+), 38 deletions(-) diff --git a/lib/command.js b/lib/command.js index f6fc28f50..0e7dba79d 100644 --- a/lib/command.js +++ b/lib/command.js @@ -67,6 +67,7 @@ class Command extends EventEmitter { }; this._hidden = false; + this._group = undefined; this._hasHelpOption = true; this._helpFlags = '-h, --help'; this._helpDescription = 'display help for command'; @@ -77,8 +78,6 @@ class Command extends EventEmitter { this._helpCommandnameAndArgs = 'help [command]'; this._helpCommandDescription = 'display help for command'; this._helpConfiguration = {}; - this.commandGroups = null; - this.optionGroups = null; } /** @@ -1938,6 +1937,19 @@ Expecting one of '${allowedValues.join("', '")}'`); return this; } + /** + * Get or set the help group of the command. + * + * @param {string} [title] + * @return {string|Command} + */ + + group(title) { + if (title === undefined) return this._group; + this._group = title; + 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 2e763f4d8..30ce7ca9a 100644 --- a/lib/help.js +++ b/lib/help.js @@ -17,6 +17,11 @@ class Help { this.sortSubcommands = false; this.sortOptions = false; this.showGlobalOptions = false; + + /** @type Map */ + this.commandGroups = new Map(); + /** @type Map */ + this.optionGroups = new Map(); } /** @@ -55,24 +60,34 @@ class Help { */ _visibleCommandGroups(cmd, helper) { const defaultTitle = 'Commands:'; - // Short-circuit normal case of no groups. (Same result as full processing.) - if (!cmd.commandGroups) return new Map([[defaultTitle, helper.visibleCommands(cmd)]]); - - // Refactor groups for matching against visible commands. - const commandNameGroups = cmd.commandGroups ?? new Map(); - const commandGroups = new Map(); - const lookupGroup = new Map(); - commandNameGroups.forEach((commandNames, groupTitle) => { - commandGroups.set(groupTitle, []); - commandNames.forEach(name => lookupGroup.set(name, groupTitle)); + const groupMap = new Map(); + const subs = helper.visibleCommands(cmd); + + const addGroup = (group) => { + if (group && !groupMap.has(group)) groupMap.set(group, []); + }; + + // Process explicit groups from helper first. + helper.commandGroups.forEach((names, group) => { + addGroup(group); + names.forEach(name => { + const index = subs.findIndex(sub => name === sub.name()); + if (index >= 0) { + groupMap.get(group).push(subs[index]); + subs.splice(index, 1); + } + }); }); - commandGroups.set(defaultTitle, []); // (may be there already, but make sure) - // Populate groups with visible (possibly sorted) commands. - helper.visibleCommands(cmd).forEach(sub => { - const group = lookupGroup.get(sub.name()) ?? defaultTitle; - commandGroups.get(group).push(sub); + + // Scan for new groups in order commands created. + cmd.commands.forEach(sub => addGroup(sub.group())); + addGroup(defaultTitle); + // Populate groups with remaining subcommands. + subs.forEach(sub => { + const group = sub.group() ?? defaultTitle; + groupMap.get(group).push(sub); }); - return commandGroups; + return groupMap; } /** @@ -84,28 +99,34 @@ class Help { */ _visibleOptionGroups(cmd, helper) { const defaultTitle = 'Options:'; - // Short-circuit normal case of no groups. (Same result as full processing.) - if (!cmd.optionGroups) return new Map([[defaultTitle, helper.visibleOptions(cmd)]]); - - // Refactor groups for matching against visible options. - const optionFlagGroups = cmd.optionGroups ?? new Map(); - const optionGroups = new Map(); - const lookupGroup = new Map(); - optionFlagGroups.forEach((optionFlags, groupTitle) => { - optionGroups.set(groupTitle, []); - optionFlags.forEach(flag => lookupGroup.set(flag, groupTitle)); + const groupMap = new Map(); + const opts = helper.visibleOptions(cmd); + + const addGroup = (group) => { + if (group && !groupMap.has(group)) groupMap.set(group, []); + }; + + // Process explicit groups from helper first. + helper.optionGroups.forEach((flags, group) => { + addGroup(group); + flags.forEach(flag => { + const index = opts.findIndex(opt => opt.is(flag) || flag === opt.attributeName()); + if (index >= 0) { + groupMap.get(group).push(opts[index]); + opts.splice(index, 1); + } + }); }); - optionGroups.set(defaultTitle, []); // (may be there already, but make sure) - // Populate groups with visible (possibly sorted) options. - helper.visibleOptions(cmd).forEach(opt => { - let group; - if (opt.long) group = lookupGroup.get(opt.long); - if (!group && opt.short) group = lookupGroup.get(opt.short); - if (!group) group = lookupGroup.get(opt.attributeName()); - if (!group) group = defaultTitle; - optionGroups.get(group).push(opt); + + // Scan for new groups in order options created. + cmd.options.forEach(opt => addGroup(opt.helpGroup)); + addGroup(defaultTitle); + // Populate groups with remaining subcommands. + opts.forEach(opt => { + const group = opt.helpGroup ?? defaultTitle; + groupMap.get(group).push(opt); }); - return optionGroups; + return groupMap; } /** diff --git a/lib/option.js b/lib/option.js index d61fc5f2f..5de1fc8bc 100644 --- a/lib/option.js +++ b/lib/option.js @@ -32,6 +32,7 @@ class Option { this.envVar = undefined; this.parseArg = undefined; this.hidden = false; + this.helpGroup = undefined; this.argChoices = undefined; this.conflictsWith = []; this.implied = undefined; @@ -159,6 +160,19 @@ class Option { return this; } + /** + * Get or set the help group of the option. + * + * @param {string} title + * @return {Option} + */ + + group(title) { + if (title === undefined) return this.helpGroup; + this.helpGroup = title; + return this; + } + /** * @api private */ diff --git a/tests/command.chain.test.js b/tests/command.chain.test.js index 3f907c869..300c0e3b7 100644 --- a/tests/command.chain.test.js +++ b/tests/command.chain.test.js @@ -208,4 +208,10 @@ describe('Command methods that should return this for chaining', () => { const result = program.nameFromFilename('name'); expect(result).toBe(program); }); + + test('when call .group() then returns this', () => { + const program = new Command(); + const result = program.group('My Commands:'); + expect(result).toBe(program); + }); }); diff --git a/tests/option.chain.test.js b/tests/option.chain.test.js index 3ed59688d..8aaaf8856 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 .group() then returns this', () => { + const option = new Option('-e,--example '); + const result = option.group('My Options:'); + expect(result).toBe(option); + }); }); From 46693df62095acd29456ed16998f0a31b1fb88f9 Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 14 Jul 2023 21:01:56 +1200 Subject: [PATCH 5/8] Share implementation for determining groups --- lib/help.js | 86 +++++++++++++++++++++++++---------------------------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/lib/help.js b/lib/help.js index 30ce7ca9a..d9167c964 100644 --- a/lib/help.js +++ b/lib/help.js @@ -52,40 +52,41 @@ class Help { } /** - * Make public? - * @param {Command} cmd - * @param {Help} helper - * @return {Map} + * info.defaultTitle + * info.overrideMap + * info.things + * info.visibleThings + * info.match + * * @api private */ - _visibleCommandGroups(cmd, helper) { - const defaultTitle = 'Commands:'; + _visibleThingGroups(cmd, helper, info) { const groupMap = new Map(); - const subs = helper.visibleCommands(cmd); + const visibleThings = info.visibleThings; const addGroup = (group) => { if (group && !groupMap.has(group)) groupMap.set(group, []); }; // Process explicit groups from helper first. - helper.commandGroups.forEach((names, group) => { + info.overrideMap.forEach((ids, group) => { addGroup(group); - names.forEach(name => { - const index = subs.findIndex(sub => name === sub.name()); + ids.forEach(id => { + const index = visibleThings.findIndex(thing => info.match(id, thing)); if (index >= 0) { - groupMap.get(group).push(subs[index]); - subs.splice(index, 1); + groupMap.get(group).push(visibleThings[index]); + visibleThings.splice(index, 1); } }); }); // Scan for new groups in order commands created. - cmd.commands.forEach(sub => addGroup(sub.group())); - addGroup(defaultTitle); + info.things.forEach(sub => addGroup(sub.group())); + addGroup(info.defaultTitle); // Populate groups with remaining subcommands. - subs.forEach(sub => { - const group = sub.group() ?? defaultTitle; - groupMap.get(group).push(sub); + visibleThings.forEach(thing => { + const group = thing.group() ?? info.defaultTitle; + groupMap.get(group).push(thing); }); return groupMap; } @@ -94,39 +95,34 @@ class Help { * Make public? * @param {Command} cmd * @param {Help} helper - * @return {Map} + * @return {Map} * @api private */ - _visibleOptionGroups(cmd, helper) { - const defaultTitle = 'Options:'; - const groupMap = new Map(); - const opts = helper.visibleOptions(cmd); - - const addGroup = (group) => { - if (group && !groupMap.has(group)) groupMap.set(group, []); - }; - - // Process explicit groups from helper first. - helper.optionGroups.forEach((flags, group) => { - addGroup(group); - flags.forEach(flag => { - const index = opts.findIndex(opt => opt.is(flag) || flag === opt.attributeName()); - if (index >= 0) { - groupMap.get(group).push(opts[index]); - opts.splice(index, 1); - } - }); + _visibleCommandGroups(cmd, helper) { + return this._visibleThingGroups(cmd, helper, { + defaultTitle: 'Commands:', + overrideMap: helper.commandGroups, + things: cmd.commands, + visibleThings: helper.visibleCommands(cmd), + match: (id, sub) => id === sub.name() }); + } - // Scan for new groups in order options created. - cmd.options.forEach(opt => addGroup(opt.helpGroup)); - addGroup(defaultTitle); - // Populate groups with remaining subcommands. - opts.forEach(opt => { - const group = opt.helpGroup ?? defaultTitle; - groupMap.get(group).push(opt); + /** + * Make public? + * @param {Command} cmd + * @param {Help} helper + * @return {Map} + * @api private + */ + _visibleOptionGroups(cmd, helper) { + return this._visibleThingGroups(cmd, helper, { + defaultTitle: 'Options:', + overrideMap: helper.optionGroups, + things: cmd.options, + visibleThings: helper.visibleOptions(cmd), + match: (id, opt) => opt.is(id) || id === opt.attributeName() }); - return groupMap; } /** From 119d7ad2d1503200b2450e51101912c822066d3d Mon Sep 17 00:00:00 2001 From: John Gee Date: Fri, 14 Jul 2023 22:05:39 +1200 Subject: [PATCH 6/8] Move override groups back to command, since not shared --- lib/command.js | 7 ++++++- lib/help.js | 11 +++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/command.js b/lib/command.js index 0e7dba79d..ca791bcdd 100644 --- a/lib/command.js +++ b/lib/command.js @@ -66,8 +66,13 @@ class Command extends EventEmitter { outputError: (str, write) => write(str) }; - this._hidden = false; this._group = undefined; + /** @type Map */ + this.commandGroups = null; + /** @type Map */ + this.optionGroups = null; + + this._hidden = false; this._hasHelpOption = true; this._helpFlags = '-h, --help'; this._helpDescription = 'display help for command'; diff --git a/lib/help.js b/lib/help.js index d9167c964..61ed01a27 100644 --- a/lib/help.js +++ b/lib/help.js @@ -17,11 +17,6 @@ class Help { this.sortSubcommands = false; this.sortOptions = false; this.showGlobalOptions = false; - - /** @type Map */ - this.commandGroups = new Map(); - /** @type Map */ - this.optionGroups = new Map(); } /** @@ -62,7 +57,7 @@ class Help { */ _visibleThingGroups(cmd, helper, info) { const groupMap = new Map(); - const visibleThings = info.visibleThings; + const visibleThings = info.visibleThings.slice(0); const addGroup = (group) => { if (group && !groupMap.has(group)) groupMap.set(group, []); @@ -101,7 +96,7 @@ class Help { _visibleCommandGroups(cmd, helper) { return this._visibleThingGroups(cmd, helper, { defaultTitle: 'Commands:', - overrideMap: helper.commandGroups, + overrideMap: cmd.commandGroups ?? new Map(), things: cmd.commands, visibleThings: helper.visibleCommands(cmd), match: (id, sub) => id === sub.name() @@ -118,7 +113,7 @@ class Help { _visibleOptionGroups(cmd, helper) { return this._visibleThingGroups(cmd, helper, { defaultTitle: 'Options:', - overrideMap: helper.optionGroups, + overrideMap: cmd.optionGroups ?? new Map(), things: cmd.options, visibleThings: helper.visibleOptions(cmd), match: (id, opt) => opt.is(id) || id === opt.attributeName() From 3787b7a9084f866553b6565a327520996ee93684 Mon Sep 17 00:00:00 2001 From: John Gee Date: Sun, 16 Jul 2023 12:17:30 +1200 Subject: [PATCH 7/8] Remove empty group in inner routine --- lib/help.js | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/help.js b/lib/help.js index 61ed01a27..9562b9a6a 100644 --- a/lib/help.js +++ b/lib/help.js @@ -83,6 +83,14 @@ class Help { const group = thing.group() ?? info.defaultTitle; groupMap.get(group).push(thing); }); + + // Remove empty groups + groupMap.forEach((things, key) => { + if (things.length === 0) { + groupMap.delete(key); + } + }); + return groupMap; } @@ -455,12 +463,10 @@ class Help { // Options const optionGroups = this._visibleOptionGroups(cmd, helper); optionGroups.forEach((opts, groupTitle) => { - if (opts.length > 0) { - const optionList = opts.map((opt) => { - return formatItem(helper.optionTerm(opt), helper.optionDescription(opt)); - }); - output = output.concat([groupTitle, formatList(optionList), '']); - } + const optionList = opts.map((opt) => { + return formatItem(helper.optionTerm(opt), helper.optionDescription(opt)); + }); + output = output.concat([groupTitle, formatList(optionList), '']); }); if (this.showGlobalOptions) { @@ -475,12 +481,10 @@ class Help { // Command Groups const commandGroups = this._visibleCommandGroups(cmd, helper); commandGroups.forEach((cmds, groupTitle) => { - if (cmds.length > 0) { - const commandList = cmds.map((cmd) => { - return formatItem(helper.subcommandTerm(cmd), helper.subcommandDescription(cmd)); - }); - output = output.concat([groupTitle, formatList(commandList), '']); - } + const commandList = cmds.map((cmd) => { + return formatItem(helper.subcommandTerm(cmd), helper.subcommandDescription(cmd)); + }); + output = output.concat([groupTitle, formatList(commandList), '']); }); return output.join('\n'); From 021ffb2ad98b0b87f33d67fd62a9482f742885d8 Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 17 Jul 2023 23:37:50 +1200 Subject: [PATCH 8/8] Add support for startCommandGroup and startOptionGroup --- lib/command.js | 65 ++++++++++++++++++++++++++++++++++++++++++++++++-- lib/help.js | 37 ++++++++++++++-------------- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/lib/command.js b/lib/command.js index ca791bcdd..4f99038ed 100644 --- a/lib/command.js +++ b/lib/command.js @@ -67,10 +67,12 @@ class Command extends EventEmitter { }; this._group = undefined; + this._defaultCommandGroup = undefined; + this._defaultOptionGroup = undefined; /** @type Map */ - this.commandGroups = null; + this.commandGroups = new Map(); /** @type Map */ - this.optionGroups = null; + this.optionGroups = new Map(); this._hidden = false; this._hasHelpOption = true; @@ -160,6 +162,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); + this._addGroupCommand(this._defaultCommandGroup, cmd.name()); cmd.parent = this; cmd.copyInheritedSettings(this); @@ -277,6 +280,7 @@ class Command extends EventEmitter { if (opts.noHelp || opts.hidden) cmd._hidden = true; // modifying passed command due to existing implementation this.commands.push(cmd); + this._addGroupCommand(this._defaultCommandGroup, cmd.name()); cmd.parent = this; return this; } @@ -380,6 +384,7 @@ class Command extends EventEmitter { this._helpCommandnameAndArgs = enableOrNameAndArgs; } this._helpCommandDescription = description || this._helpCommandDescription; + this._addGroupCommand(this._defaultCommandGroup, this._helpCommandName); } return this; } @@ -529,6 +534,7 @@ Expecting one of '${allowedValues.join("', '")}'`); // register the option this.options.push(option); + this._addGroupOption(this._defaultOptionGroup, option); // handler for cli and env supplied values const handleOptionValue = (val, invalidValueMessage, valueSource) => { @@ -1827,6 +1833,7 @@ Expecting one of '${allowedValues.join("', '")}'`); const versionOption = this.createOption(flags, description); this._versionOptionName = versionOption.attributeName(); this.options.push(versionOption); + this._addGroupOption(this._defaultOptionGroup, versionOption); this.on('option:' + versionOption.name(), () => { this._outputConfiguration.writeOut(`${str}\n`); this._exit(0, 'commander.version', str); @@ -1955,6 +1962,56 @@ Expecting one of '${allowedValues.join("', '")}'`); return this; } + /** + * + * @param {string} title + */ + startCommandGroup(title) { + this._defaultCommandGroup = title; + this._addGroupCommand(title); + return this; + } + + /** + * + * @param {string} group + * @param {Command} name + * @api private; + */ + _addGroupCommand(group, commandName) { + if (!group) return; // cmd.group gets handled later + if (!this.commandGroups.has(group)) this.commandGroups.set(group, []); + if (commandName) this.commandGroups.get(group).push(commandName); + } + + /** + * + * @param {string} title + * @api private; + */ + startOptionGroup(title) { + this._defaultOptionGroup = title; + this._addGroupOption(title); + return this; + } + + /** + * + * @param {string} group + * @param {Option | string} [option] - allow string to make it easy to add implicit help option + * @api private; + */ + _addGroupOption(group, option) { + if (!group) return; // option.group gets handled later + if (!this.optionGroups.has(group)) this.optionGroups.set(group, []); + if (option) { + const optionKey = typeof option === 'string' + ? option + : option.long ?? option.short; + this.optionGroups.get(group).push(optionKey); + } + } + /** * Set the name of the command from script filename, such as process.argv[1], * or require.main.filename, or __filename. @@ -2071,6 +2128,8 @@ Expecting one of '${allowedValues.join("', '")}'`); helpOption(flags, description) { if (typeof flags === 'boolean') { this._hasHelpOption = flags; + // This is a bit messy: + if (this._hasHelpOption) this._addGroupOption(this._defaultOptionGroup, this._helpLongFlag ?? this._helpShortFlag); return this; } this._helpFlags = flags || this._helpFlags; @@ -2079,6 +2138,8 @@ Expecting one of '${allowedValues.join("', '")}'`); const helpFlags = splitOptionFlags(this._helpFlags); this._helpShortFlag = helpFlags.shortFlag; this._helpLongFlag = helpFlags.longFlag; + // This is a bit messy: + this._addGroupOption(this._defaultOptionGroup, this._helpLongFlag ?? this._helpShortFlag); return this; } diff --git a/lib/help.js b/lib/help.js index 9562b9a6a..0ce5c13ac 100644 --- a/lib/help.js +++ b/lib/help.js @@ -57,31 +57,30 @@ class Help { */ _visibleThingGroups(cmd, helper, info) { const groupMap = new Map(); - const visibleThings = info.visibleThings.slice(0); - const addGroup = (group) => { if (group && !groupMap.has(group)) groupMap.set(group, []); }; - // Process explicit groups from helper first. - info.overrideMap.forEach((ids, group) => { - addGroup(group); - ids.forEach(id => { - const index = visibleThings.findIndex(thing => info.match(id, thing)); - if (index >= 0) { - groupMap.get(group).push(visibleThings[index]); - visibleThings.splice(index, 1); + // Add groups from override map in order groups created. + for (const group of info.overrideMap.keys()) addGroup(group); + // Add groups from things in order things created. + info.things.forEach(thing => addGroup(thing.group())); + // Add default last, but may have been added above. + addGroup(info.defaultTitle); + + // Process visible things into groups (reminder: may be sorted). + info.visibleThings.forEach(thing => { + let overrideFound = false; + info.overrideMap.forEach((things, group) => { + if (things.find(key => info.match(key, thing))) { + groupMap.get(group).push(thing); + overrideFound = true; } }); - }); - - // Scan for new groups in order commands created. - info.things.forEach(sub => addGroup(sub.group())); - addGroup(info.defaultTitle); - // Populate groups with remaining subcommands. - visibleThings.forEach(thing => { - const group = thing.group() ?? info.defaultTitle; - groupMap.get(group).push(thing); + if (!overrideFound) { + const group = thing.group() ?? info.defaultTitle; + groupMap.get(group).push(thing); + } }); // Remove empty groups