From 0ae5b2fbaad3c1afe774ae2904a1d71ebd94a51a Mon Sep 17 00:00:00 2001 From: John Gee Date: Thu, 8 Dec 2022 08:52:34 +1300 Subject: [PATCH 1/7] Support showing global options in help (#1828) Add help configuration to show the global options. --- Readme.md | 1 + ...bal-options.js => global-options-added.js} | 10 +-- examples/global-options-nested.js | 32 +++++++++ examples/optsWithGlobals.js | 24 ------- lib/help.js | 69 +++++++++++++++++-- tests/help.padWidth.test.js | 13 ++++ tests/help.showGlobalOptions.test.js | 62 +++++++++++++++++ tests/help.visibleGlobalOptions.test.js | 45 ++++++++++++ typings/index.d.ts | 5 ++ typings/index.test-d.ts | 3 + 10 files changed, 228 insertions(+), 36 deletions(-) rename examples/{global-options.js => global-options-added.js} (85%) create mode 100644 examples/global-options-nested.js delete mode 100644 examples/optsWithGlobals.js create mode 100644 tests/help.showGlobalOptions.test.js create mode 100644 tests/help.visibleGlobalOptions.test.js diff --git a/Readme.md b/Readme.md index 48675a940..2e377bcc3 100644 --- a/Readme.md +++ b/Readme.md @@ -915,6 +915,7 @@ The data properties are: - `helpWidth`: specify the wrap width, useful for unit tests - `sortSubcommands`: sort the subcommands alphabetically - `sortOptions`: sort the options alphabetically +- `showGlobalOptions`: show a section with the global options from the parent command(s) There are methods getting the visible lists of arguments, options, and subcommands. There are methods for formatting the items in the lists, with each item having a _term_ and _description_. Take a look at `.formatHelp()` to see how they are used. diff --git a/examples/global-options.js b/examples/global-options-added.js similarity index 85% rename from examples/global-options.js rename to examples/global-options-added.js index 61ca65236..a064f9a83 100644 --- a/examples/global-options.js +++ b/examples/global-options-added.js @@ -7,7 +7,7 @@ // The code in this example assumes there is just one level of subcommands. // // (A different pattern for a "global" option is to add it to the root command, rather -// than to the subcommand. That is not shown here.) +// than to the subcommand. See global-options-nested.js.) // const { Command } = require('commander'); // (normal include) const { Command } = require('../'); // include commander in git clone of commander repo @@ -45,7 +45,7 @@ program.commands.forEach((cmd) => { program.parse(); // Try the following: -// node common-options.js --help -// node common-options.js print --help -// node common-options.js serve --help -// node common-options.js serve --debug --verbose +// node global-options-added.js --help +// node global-options-added.js print --help +// node global-options-added.js serve --help +// node global-options-added.js serve --debug --verbose diff --git a/examples/global-options-nested.js b/examples/global-options-nested.js new file mode 100644 index 000000000..ec5f30fa2 --- /dev/null +++ b/examples/global-options-nested.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +// This example shows global options on the program which affect all the subcommands. +// See how to work with global options in the subcommand and display them in the help. +// +// (A different pattern for a "global" option is to add it to the subcommands, rather +// than to the program. See global-options-added.js.) + +// const { Command } = require('commander'); // (normal include) +const { Command } = require('../'); // include commander in git clone of commander repo + +const program = new Command(); + +program + .configureHelp({ showGlobalOptions: true }) + .option('-g, --global'); + +program + .command('sub') + .option('-l, --local') + .action((options, cmd) => { + console.log({ + opts: cmd.opts(), + optsWithGlobals: cmd.optsWithGlobals() + }); + }); + +program.parse(); + +// Try the following: +// node global-options-nested.js --global sub --local +// node global-options-nested.js sub --help diff --git a/examples/optsWithGlobals.js b/examples/optsWithGlobals.js deleted file mode 100644 index 366597c06..000000000 --- a/examples/optsWithGlobals.js +++ /dev/null @@ -1,24 +0,0 @@ -// const { Command } = require('commander'); // (normal include) -const { Command } = require('../'); // include commander in git clone of commander repo - -// Show use of .optsWithGlobals(), and compare with .opts(). - -const program = new Command(); - -program - .option('-g, --global'); - -program - .command('sub') - .option('-l, --local') - .action((options, cmd) => { - console.log({ - opts: cmd.opts(), - optsWithGlobals: cmd.optsWithGlobals() - }); - }); - -program.parse(); - -// Try the following: -// node optsWithGlobals.js --global sub --local diff --git a/lib/help.js b/lib/help.js index 2b8c95596..90d9d68cc 100644 --- a/lib/help.js +++ b/lib/help.js @@ -16,6 +16,7 @@ class Help { this.helpWidth = undefined; this.sortSubcommands = false; this.sortOptions = false; + this.showGlobalOptions = false; } /** @@ -45,6 +46,21 @@ class Help { return visibleCommands; } + /** + * Compare options for sort. + * + * @param {Option} a + * @param {Option} b + * @returns number + */ + compareOptions(a, b) { + const getSortKey = (option) => { + // WYSIWYG for order displayed in help. Short used for comparison if present. No special handling for negated. + return option.short ? option.short.replace(/^-/, '') : option.long.replace(/^--/, ''); + }; + return getSortKey(a).localeCompare(getSortKey(b)); + } + /** * Get an array of the visible options. Includes a placeholder for the implicit help option, if there is one. * @@ -69,17 +85,32 @@ class Help { visibleOptions.push(helpOption); } if (this.sortOptions) { - const getSortKey = (option) => { - // WYSIWYG for order displayed in help with short before long, no special handling for negated. - return option.short ? option.short.replace(/^-/, '') : option.long.replace(/^--/, ''); - }; - visibleOptions.sort((a, b) => { - return getSortKey(a).localeCompare(getSortKey(b)); - }); + visibleOptions.sort(this.compareOptions); } return visibleOptions; } + /** + * Get an array of the visible global options. (Not including help.) + * + * @param {Command} cmd + * @returns {Option[]} + */ + + visibleGlobalOptions(cmd) { + if (!this.showGlobalOptions) return []; + + const globalOptions = []; + for (let parentCmd = cmd.parent; parentCmd; parentCmd = parentCmd.parent) { + const visibleOptions = parentCmd.options.filter((option) => !option.hidden); + globalOptions.push(...visibleOptions); + } + if (this.sortOptions) { + globalOptions.sort(this.compareOptions); + } + return globalOptions; + } + /** * Get an array of the arguments if any have a description. * @@ -168,6 +199,20 @@ class Help { }, 0); } + /** + * Get the longest global option term length. + * + * @param {Command} cmd + * @param {Help} helper + * @returns {number} + */ + + longestGlobalOptionTermLength(cmd, helper) { + return helper.visibleGlobalOptions(cmd).reduce((max, option) => { + return Math.max(max, helper.optionTerm(option).length); + }, 0); + } + /** * Get the longest argument term length. * @@ -341,6 +386,15 @@ class Help { output = output.concat(['Options:', formatList(optionList), '']); } + if (this.showGlobalOptions) { + const globalOptionList = helper.visibleGlobalOptions(cmd).map((option) => { + return formatItem(helper.optionTerm(option), helper.optionDescription(option)); + }); + if (globalOptionList.length > 0) { + output = output.concat(['Global Options:', formatList(globalOptionList), '']); + } + } + // Commands const commandList = helper.visibleCommands(cmd).map((cmd) => { return formatItem(helper.subcommandTerm(cmd), helper.subcommandDescription(cmd)); @@ -363,6 +417,7 @@ class Help { padWidth(cmd, helper) { return Math.max( helper.longestOptionTermLength(cmd, helper), + helper.longestGlobalOptionTermLength(cmd, helper), helper.longestSubcommandTermLength(cmd, helper), helper.longestArgumentTermLength(cmd, helper) ); diff --git a/tests/help.padWidth.test.js b/tests/help.padWidth.test.js index affa3e201..b9ace7ae0 100644 --- a/tests/help.padWidth.test.js +++ b/tests/help.padWidth.test.js @@ -28,6 +28,19 @@ describe('padWidth', () => { expect(helper.padWidth(program, helper)).toEqual(longestThing.length); }); + test('when global option term longest return global option length', () => { + const longestThing = '--very-long-thing-bigger-than-others'; + const program = new commander.Command(); + program + .argument('', 'desc') + .option(longestThing) + .configureHelp({ showGlobalOptions: true }); + const sub = program + .command('sub'); + const helper = sub.createHelp(); + expect(helper.padWidth(sub, helper)).toEqual(longestThing.length); + }); + test('when command term longest return command length', () => { const longestThing = 'very-long-thing-bigger-than-others'; const program = new commander.Command(); diff --git a/tests/help.showGlobalOptions.test.js b/tests/help.showGlobalOptions.test.js new file mode 100644 index 000000000..5d9cb8642 --- /dev/null +++ b/tests/help.showGlobalOptions.test.js @@ -0,0 +1,62 @@ +const commander = require('../'); + +test('when default configuration then global options hidden', () => { + const program = new commander.Command(); + program + .option('--global'); + const sub = program.command('sub'); + expect(sub.helpInformation()).not.toContain('global'); +}); + +test('when showGlobalOptions:true then program options shown', () => { + const program = new commander.Command(); + program + .option('--global') + .configureHelp({ showGlobalOptions: true }); + const sub = program.command('sub'); + expect(sub.helpInformation()).toContain('global'); +}); + +test('when showGlobalOptions:true and no global options then global options header not shown', () => { + const program = new commander.Command(); + program + .configureHelp({ showGlobalOptions: true }); + const sub = program.command('sub'); + expect(sub.helpInformation()).not.toContain('Global'); +}); + +test('when showGlobalOptions:true and nested commands then combined nested options shown program last', () => { + const program = new commander.Command(); + program + .option('--global') + .configureHelp({ showGlobalOptions: true }); + const sub1 = program.command('sub1') + .option('--sub1'); + const sub2 = sub1.command('sub2'); + expect(sub2.helpInformation()).toContain(`Global Options: + --sub1 + --global +`); +}); + +test('when showGlobalOptions:true and sortOptions: true then global options sorted', () => { + const program = new commander.Command(); + program + .option('-3') + .option('-4') + .option('-2') + .configureHelp({ showGlobalOptions: true, sortOptions: true }); + const sub1 = program.command('sub1') + .option('-6') + .option('-1') + .option('-5'); + const sub2 = sub1.command('sub2'); + expect(sub2.helpInformation()).toContain(`Global Options: + -1 + -2 + -3 + -4 + -5 + -6 +`); +}); diff --git a/tests/help.visibleGlobalOptions.test.js b/tests/help.visibleGlobalOptions.test.js new file mode 100644 index 000000000..00b8e7f25 --- /dev/null +++ b/tests/help.visibleGlobalOptions.test.js @@ -0,0 +1,45 @@ +const commander = require('../'); + +test('when default configuration then return empty array', () => { + const program = new commander.Command(); + program + .option('--global'); + const sub = program.command('sub'); + const helper = sub.createHelp(); + expect(helper.visibleGlobalOptions(program)).toEqual([]); +}); + +test('when showGlobalOptions:true then return program options', () => { + const program = new commander.Command(); + program + .option('--global') + .configureHelp({ showGlobalOptions: true }); + const sub = program.command('sub'); + const helper = sub.createHelp(); + const visibleOptionNames = helper.visibleGlobalOptions(sub).map(option => option.name()); + expect(visibleOptionNames).toEqual(['global']); +}); + +test('when showGlobalOptions:true and program has version then return version', () => { + const program = new commander.Command(); + program + .configureHelp({ showGlobalOptions: true }) + .version('1.2.3'); + const sub = program.command('sub'); + const helper = sub.createHelp(); + const visibleOptionNames = helper.visibleGlobalOptions(sub).map(option => option.name()); + expect(visibleOptionNames).toEqual(['version']); +}); + +test('when showGlobalOptions:true and nested commands then return combined global options', () => { + const program = new commander.Command(); + program + .configureHelp({ showGlobalOptions: true }) + .option('--global'); + const sub1 = program.command('sub1') + .option('--sub1'); + const sub2 = sub1.command('sub2'); + const helper = sub2.createHelp(); + const visibleOptionNames = helper.visibleGlobalOptions(sub2).map(option => option.name()); + expect(visibleOptionNames).toEqual(['sub1', 'global']); +}); diff --git a/typings/index.d.ts b/typings/index.d.ts index b69ea9104..7c76805d8 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -199,6 +199,7 @@ export class Help { helpWidth?: number; sortSubcommands: boolean; sortOptions: boolean; + showGlobalOptions: boolean; constructor(); @@ -224,6 +225,8 @@ export class Help { visibleCommands(cmd: Command): Command[]; /** Get an array of the visible options. Includes a placeholder for the implicit help option, if there is one. */ visibleOptions(cmd: Command): Option[]; + /** Get an array of the visible global options. (Not including help.) */ + visibleGlobalOptions(cmd: Command): Option[]; /** Get an array of the arguments which have descriptions. */ visibleArguments(cmd: Command): Argument[]; @@ -231,6 +234,8 @@ export class Help { longestSubcommandTermLength(cmd: Command, helper: Help): number; /** Get the longest option term length. */ longestOptionTermLength(cmd: Command, helper: Help): number; + /** Get the longest global option term length. */ + longestGlobalOptionTermLength(cmd: Command, helper: Help): number; /** Get the longest argument term length. */ longestArgumentTermLength(cmd: Command, helper: Help): number; /** Calculate the pad width from the maximum term length. */ diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index 7b8af12ea..7df4cc9e8 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -366,6 +366,7 @@ const helperArgument = new commander.Argument(''); expectType(helper.helpWidth); expectType(helper.sortSubcommands); expectType(helper.sortOptions); +expectType(helper.showGlobalOptions); expectType(helper.subcommandTerm(helperCommand)); expectType(helper.commandUsage(helperCommand)); @@ -378,10 +379,12 @@ expectType(helper.argumentDescription(helperArgument)); expectType(helper.visibleCommands(helperCommand)); expectType(helper.visibleOptions(helperCommand)); +expectType(helper.visibleGlobalOptions(helperCommand)); expectType(helper.visibleArguments(helperCommand)); expectType(helper.longestSubcommandTermLength(helperCommand, helper)); expectType(helper.longestOptionTermLength(helperCommand, helper)); +expectType(helper.longestGlobalOptionTermLength(helperCommand, helper)); expectType(helper.longestArgumentTermLength(helperCommand, helper)); expectType(helper.padWidth(helperCommand, helper)); From 5a201ecb5d1ee3589a1f1ebb9347b0ff52cb2dbd Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 19 Dec 2022 19:02:23 +1300 Subject: [PATCH 2/7] Add getOptionValueSourceWithGlobals for completeness. (#1832) --- lib/command.js | 19 +++ tests/options.optsWithGlobals.test.js | 164 +++++++++++++++++--------- typings/index.d.ts | 7 +- typings/index.test-d.ts | 3 + 4 files changed, 136 insertions(+), 57 deletions(-) diff --git a/lib/command.js b/lib/command.js index 9284ec39f..7a637cb77 100644 --- a/lib/command.js +++ b/lib/command.js @@ -814,6 +814,25 @@ Expecting one of '${allowedValues.join("', '")}'`); return this._optionValueSources[key]; } + /** + * Get source of option value. See also .optsWithGlobals(). + * Expected values are default | config | env | cli | implied + * + * @param {string} key + * @return {string} + */ + + getOptionValueSourceWithGlobals(key) { + // global overwrites local, like optsWithGlobals + let source; + getCommandAndParents(this).forEach((cmd) => { + if (cmd.getOptionValueSource(key) !== undefined) { + source = cmd.getOptionValueSource(key); + } + }); + return source; + } + /** * Get user arguments from implied or explicit arguments. * Side-effects: set _scriptPath if args included script. Used for default program name, and subcommand searches. diff --git a/tests/options.optsWithGlobals.test.js b/tests/options.optsWithGlobals.test.js index 4cc52ecd8..95ed2c5dc 100644 --- a/tests/options.optsWithGlobals.test.js +++ b/tests/options.optsWithGlobals.test.js @@ -1,63 +1,115 @@ const commander = require('../'); -test('when variety of options used with program then opts is same as optsWithGlobals', () => { - const program = new commander.Command(); - program - .option('-b, --boolean') - .option('-r, --require-value ', 'description', parseFloat) - .option('-d, --default-value { - const program = new commander.Command(); - let mergedOptions; - program - .option('-g, --global '); - program - .command('sub') - .option('-l, --local { - mergedOptions = cmd.optsWithGlobals(); - }); - - program.parse(['-g', 'GGG', 'sub', '-l', 'LLL'], { from: 'user' }); - expect(mergedOptions).toEqual({ global: 'GGG', local: 'LLL' }); -}); +describe('optsWithGlobals', () => { + test('when variety of options used with program then opts is same as optsWithGlobals', () => { + const program = new commander.Command(); + program + .option('-b, --boolean') + .option('-r, --require-value ', 'description', parseFloat) + .option('-d, --default-value { + const program = new commander.Command(); + let mergedOptions; + program + .option('-g, --global '); + program + .command('sub') + .option('-l, --local { + mergedOptions = cmd.optsWithGlobals(); + }); + + program.parse(['-g', 'GGG', 'sub', '-l', 'LLL'], { from: 'user' }); + expect(mergedOptions).toEqual({ global: 'GGG', local: 'LLL' }); + }); + + test('when options in sub and subsub then optsWithGlobals includes both', () => { + const program = new commander.Command(); + let mergedOptions; + program + .command('sub') + .option('-g, --global { + mergedOptions = cmd.optsWithGlobals(); + }); + + program.parse(['sub', '-g', 'GGG', 'subsub', '-l', 'LLL'], { from: 'user' }); + expect(mergedOptions).toEqual({ global: 'GGG', local: 'LLL' }); + }); + + test('when same named option in sub and program then optsWithGlobals includes global', () => { + const program = new commander.Command(); + let mergedOptions; + program + .option('-c, --common ') + .enablePositionalOptions(); + program + .command('sub') + .option('-c, --common { + mergedOptions = cmd.optsWithGlobals(); + }); -test('when options in sub and subsub then optsWithGlobals includes both', () => { - const program = new commander.Command(); - let mergedOptions; - program - .command('sub') - .option('-g, --global { - mergedOptions = cmd.optsWithGlobals(); - }); - - program.parse(['sub', '-g', 'GGG', 'subsub', '-l', 'LLL'], { from: 'user' }); - expect(mergedOptions).toEqual({ global: 'GGG', local: 'LLL' }); + program.parse(['-c', 'GGG', 'sub', '-c', 'LLL'], { from: 'user' }); + expect(mergedOptions).toEqual({ common: 'GGG' }); + }); }); -test('when same named option in sub and program then optsWithGlobals includes global', () => { - const program = new commander.Command(); - let mergedOptions; - program - .option('-c, --common ') - .enablePositionalOptions(); - program - .command('sub') - .option('-c, --common { - mergedOptions = cmd.optsWithGlobals(); - }); - - program.parse(['-c', 'GGG', 'sub', '-c', 'LLL'], { from: 'user' }); - expect(mergedOptions).toEqual({ common: 'GGG' }); +describe('getOptionValueSourceWithGlobals', () => { + test('when option used with simple command then source is defined', () => { + const program = new commander.Command(); + program + .option('-g, --global'); + + program.parse(['-g'], { from: 'user' }); + expect(program.getOptionValueSourceWithGlobals('global')).toEqual('cli'); + }); + + test('when option used with program then source is defined', () => { + const program = new commander.Command(); + program + .option('-g, --global'); + const sub = program.command('sub') + .option('-l, --local') + .action(() => {}); + + program.parse(['sub', '-g'], { from: 'user' }); + expect(sub.getOptionValueSourceWithGlobals('global')).toEqual('cli'); + }); + + test('when option used with subcommand then source is defined', () => { + const program = new commander.Command(); + program + .option('-g, --global'); + const sub = program.command('sub') + .option('-l, --local') + .action(() => {}); + + program.parse(['sub', '-l'], { from: 'user' }); + expect(sub.getOptionValueSourceWithGlobals('local')).toEqual('cli'); + }); + + test('when same named option in sub and program then source is defined by global', () => { + const program = new commander.Command(); + program + .enablePositionalOptions() + .option('-c, --common ', 'description', 'default value'); + const sub = program.command('sub') + .option('-c, --common ') + .action(() => {}); + + program.parse(['sub', '--common', 'value'], { from: 'user' }); + expect(sub.getOptionValueSourceWithGlobals('common')).toEqual('default'); + }); }); diff --git a/typings/index.d.ts b/typings/index.d.ts index 7c76805d8..e9a5e4b36 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -600,10 +600,15 @@ export class Command { setOptionValueWithSource(key: string, value: unknown, source: OptionValueSource): this; /** - * Retrieve option value source. + * Get source of option value. */ getOptionValueSource(key: string): OptionValueSource | undefined; + /** + * Get source of option value. See also .optsWithGlobals(). + */ + getOptionValueSourceWithGlobals(key: string): OptionValueSource | undefined; + /** * Alter parsing of short flags with optional values. * diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index 7df4cc9e8..95b171baf 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -174,6 +174,9 @@ expectType(program.setOptionValueWithSource('example', [], 'c // getOptionValueSource expectType(program.getOptionValueSource('example')); +// getOptionValueSourceWithGlobals +expectType(program.getOptionValueSourceWithGlobals('example')); + // combineFlagAndOptionalValue expectType(program.combineFlagAndOptionalValue()); expectType(program.combineFlagAndOptionalValue(false)); From 91bfa8d8e02f8116a680d01a5e34283766fde8ff Mon Sep 17 00:00:00 2001 From: John Gee Date: Mon, 19 Dec 2022 20:06:58 +1300 Subject: [PATCH 3/7] Add mention of .alias() to README. (#1833) --- Readme.md | 2 ++ examples/alias.js | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 examples/alias.js diff --git a/Readme.md b/Readme.md index 2e377bcc3..ed19cff63 100644 --- a/Readme.md +++ b/Readme.md @@ -543,6 +543,8 @@ Configuration options can be passed with the call to `.command()` and `.addComma remove the command from the generated help output. Specifying `isDefault: true` will run the subcommand if no other subcommand is specified ([example](./examples/defaultCommand.js)). +You can add alternative names for a command with `.alias()`. ([example](./examples/alias.js)) + For safety, `.addCommand()` does not automatically copy the inherited settings from the parent command. There is a helper routine `.copyInheritedSettings()` for copying the settings when they are wanted. ### Command-arguments diff --git a/examples/alias.js b/examples/alias.js new file mode 100644 index 000000000..ca9a9cd6d --- /dev/null +++ b/examples/alias.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +// This example shows giving alternative names for a command. + +// const { Command } = require('commander'); // (normal include) +const { Command } = require('../'); // include commander in git clone of commander repo +const program = new Command(); + +program + .command('exec') + .argument('