diff --git a/doc/api/util.md b/doc/api/util.md index 9cae613bc9faa2..1ef7ca7f2e8cf9 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -1991,6 +1991,10 @@ added: - v18.3.0 - v16.17.0 changes: + - version: + - REPLACEME + pr-url: https://github.com/nodejs/node/pull/58875 + description: Add support for help text in options and general help text. - version: - v22.4.0 - v20.16.0 @@ -2031,6 +2035,7 @@ changes: `true`, it must be an array. No default value is applied when the option does appear in the arguments to be parsed, even if the provided value is falsy. + * `help` {string} Descriptive text to display in help output for this option. * `strict` {boolean} Should an error be thrown when unknown arguments are encountered, or when arguments are passed that do not match the `type` configured in `options`. @@ -2045,11 +2050,13 @@ changes: the built-in behavior, from adding additional checks through to reprocessing the tokens in different ways. **Default:** `false`. + * `help` {string} General help text to display at the beginning of help output. * Returns: {Object} The parsed command line arguments: * `values` {Object} A mapping of parsed option names with their {string} or {boolean} values. * `positionals` {string\[]} Positional arguments. + * `helpText` {string | undefined} Formatted help text for all options provided. * `tokens` {Object\[] | undefined} See [parseArgs tokens](#parseargs-tokens) section. Only returned if `config` includes `tokens: true`. @@ -2097,6 +2104,85 @@ console.log(values, positionals); // Prints: [Object: null prototype] { foo: true, bar: 'b' } [] ``` +### `parseArgs` help text + +`parseArgs` supports automatic formatted help text generation for command-line options. When +general help text is provided, a help option is automatically added unless already present. + +#### Simple usage + +By default, providing general help text automatically adds a help option (`-h, --help`) and +returns formatted help text in `result.helpText`: + +```mjs +import { parseArgs } from 'node:util'; + +const options = { + foo: { + type: 'boolean', + short: 'f', + help: 'use the foo filter', + }, + bar: { + type: 'string', + help: 'use the specified bar filter', + }, +}; + +const result = parseArgs({ + help: 'utility to control filters', + options, +}); + +if (result.values.help) { + console.log(result.helpText); + // Prints: + // utility to control filters + // -f, --foo use the foo filter + // --bar use the specified bar filter + // -h, --help Show help +} +``` + +#### Custom help option + +You can override the auto-added help option by defining your own: + +```mjs +import { parseArgs } from 'node:util'; + +const options = { + foo: { + type: 'boolean', + short: 'f', + help: 'use the foo filter', + }, + bar: { + type: 'string', + help: 'use the specified bar filter', + }, + help: { + type: 'boolean', + short: '?', + help: 'display help', + }, +}; + +const result = parseArgs({ + help: 'utility to control filters', + options, +}); + +if (result.values.help) { + console.log(result.helpText); + // Prints: + // utility to control filters + // -f, --foo use the foo filter + // --bar use the specified bar filter + // -?, --help display help +} +``` + ### `parseArgs` `tokens` Detailed parse information is available for adding custom behaviors by diff --git a/lib/internal/util/parse_args/parse_args.js b/lib/internal/util/parse_args/parse_args.js index 95420ac830d9a3..faf42df1d3b294 100644 --- a/lib/internal/util/parse_args/parse_args.js +++ b/lib/internal/util/parse_args/parse_args.js @@ -13,6 +13,7 @@ const { ObjectPrototypeHasOwnProperty: ObjectHasOwn, StringPrototypeCharAt, StringPrototypeIndexOf, + StringPrototypePadEnd, StringPrototypeSlice, StringPrototypeStartsWith, } = primordials; @@ -305,13 +306,63 @@ function argsToTokens(args, options) { return tokens; } +/** + * Format help text for printing. + * @param {string} longOption - long option name e.g. 'foo' + * @param {object} optionConfig - option config from parseArgs({ options }) + * @returns {string} formatted help text for printing + * @example + * formatHelpTextForPrint('foo', { type: 'string', help: 'help text' }) + * // returns '--foo help text' + */ +function formatHelpTextForPrint(longOption, optionConfig) { + const layoutSpacing = 30; + + const shortOption = objectGetOwn(optionConfig, 'short'); + const type = objectGetOwn(optionConfig, 'type'); + const help = objectGetOwn(optionConfig, 'help'); + + let helpTextForPrint = ''; + if (shortOption) { + helpTextForPrint += `-${shortOption}, `; + } + helpTextForPrint += `--${longOption}`; + if (type === 'string') { + helpTextForPrint += ' '; + } + + if (help) { + if (helpTextForPrint.length > layoutSpacing) { + helpTextForPrint += `\n${StringPrototypePadEnd('', layoutSpacing)}${help}`; + } else { + helpTextForPrint = `${StringPrototypePadEnd(helpTextForPrint, layoutSpacing)}${help}`; + } + } + + return helpTextForPrint; +} + const parseArgs = (config = kEmptyObject) => { const args = objectGetOwn(config, 'args') ?? getMainArgs(); const strict = objectGetOwn(config, 'strict') ?? true; const allowPositionals = objectGetOwn(config, 'allowPositionals') ?? !strict; const returnTokens = objectGetOwn(config, 'tokens') ?? false; const allowNegative = objectGetOwn(config, 'allowNegative') ?? false; - const options = objectGetOwn(config, 'options') ?? { __proto__: null }; + let options = objectGetOwn(config, 'options') ?? { __proto__: null }; + const help = objectGetOwn(config, 'help') ?? ''; + + const hasGenerateHelp = help.length > 0; + if (hasGenerateHelp && !ObjectHasOwn(options, 'help')) { + options = { + ...options, + __proto__: null, + help: { + type: 'boolean', + short: 'h', + help: 'Show help', + }, + }; + } // Bundle these up for passing to strict-mode checks. const parseConfig = { args, strict, options, allowPositionals, allowNegative }; @@ -322,11 +373,11 @@ const parseArgs = (config = kEmptyObject) => { validateBoolean(returnTokens, 'tokens'); validateBoolean(allowNegative, 'allowNegative'); validateObject(options, 'options'); + validateString(help, 'help'); ArrayPrototypeForEach( ObjectEntries(options), ({ 0: longOption, 1: optionConfig }) => { validateObject(optionConfig, `options.${longOption}`); - // type is required const optionType = objectGetOwn(optionConfig, 'type'); validateUnion(optionType, `options.${longOption}.type`, ['string', 'boolean']); @@ -362,6 +413,11 @@ const parseArgs = (config = kEmptyObject) => { } validator(defaultValue, `options.${longOption}.default`); } + + const helpOption = objectGetOwn(optionConfig, 'help'); + if (ObjectHasOwn(optionConfig, 'help')) { + validateString(helpOption, `options.${longOption}.help`); + } }, ); @@ -404,6 +460,23 @@ const parseArgs = (config = kEmptyObject) => { } }); + // Phase 4: generate print usage for each option + let printUsage = ''; + if (help) { + printUsage += help; + } + ArrayPrototypeForEach(ObjectEntries(options), ({ 0: longOption, 1: optionConfig }) => { + const helpTextForPrint = formatHelpTextForPrint(longOption, optionConfig); + + if (printUsage.length > 0) { + printUsage += '\n'; + } + printUsage += helpTextForPrint; + }); + + if (help && printUsage.length > 0) { + result.helpText = printUsage; + } return result; }; diff --git a/test/parallel/test-parse-args.mjs b/test/parallel/test-parse-args.mjs index e79434bdc6bbbf..f040b546d230d0 100644 --- a/test/parallel/test-parse-args.mjs +++ b/test/parallel/test-parse-args.mjs @@ -1062,3 +1062,156 @@ test('auto-detect --no-foo as negated when strict:false and allowNegative', () = process.argv = holdArgv; process.execArgv = holdExecArgv; }); + +// Test help option +{ + test('help arg value config must be a string', () => { + const args = ['-f', 'bar']; + const options = { foo: { type: 'string', short: 'f', help: 'help text' } }; + const help = true; + assert.throws(() => { + parseArgs({ args, options, help }); + }, /The "help" argument must be of type string/ + ); + }); + + test('help value for option must be a string', () => { + const args = []; + const options = { alpha: { type: 'string', help: true } }; + assert.throws(() => { + parseArgs({ args, options }); + }, /"options\.alpha\.help" property must be of type string/ + ); + }); + + test('when option has help text values but help arg value is not provided, then no help value appear', () => { + const args = ['-f', 'bar']; + const options = { foo: { type: 'string', short: 'f', help: 'help text' } }; + const expected = { values: { __proto__: null, foo: 'bar' }, positionals: [] }; + const result = parseArgs({ args, options, allowPositionals: true }); + assert.deepStrictEqual(result, expected); + }); + + test('when option has short and long flags, then both appear in usage with help option', () => { + const args = ['-f', 'bar']; + const options = { foo: { type: 'string', short: 'f', help: 'help text' } }; + const help = 'Description for some awesome stuff:'; + const printUsage = help + '\n-f, --foo help text\n-h, --help Show help'; + const expected = { helpText: printUsage, values: { __proto__: null, foo: 'bar' }, positionals: [] }; + const result = parseArgs({ args, options, allowPositionals: true, help }); + assert.deepStrictEqual(result, expected); + }); + + test('when options has short group flags, then both appear in usage with help option', () => { + const args = ['-fm', 'bar']; + const options = { foo: { type: 'boolean', short: 'f', help: 'help text' }, + moo: { type: 'string', short: 'm', help: 'help text' } }; + const help = 'Description for some awesome stuff:'; + const printUsage = help + '\n-f, --foo help text\n' + + '-m, --moo help text\n' + + '-h, --help Show help'; + const expected = { helpText: printUsage, values: { __proto__: null, foo: true, moo: 'bar' }, positionals: [] }; + const result = parseArgs({ args, options, allowPositionals: true, help }); + assert.deepStrictEqual(result, expected); + }); + + test('when options has short flag with value, then both appear in usage with help option', () => { + const args = ['-fFILE']; + const options = { foo: { type: 'string', short: 'f', help: 'help text' } }; + const help = 'Description for some awesome stuff:'; + const printUsage = help + '\n-f, --foo help text\n-h, --help Show help'; + const expected = { helpText: printUsage, values: { __proto__: null, foo: 'FILE' }, positionals: [] }; + const result = parseArgs({ args, options, allowPositionals: true, help }); + assert.deepStrictEqual(result, expected); + }); + + test('when options has long flag, then it appear in usage with help option', () => { + const args = ['--foo', 'bar']; + const options = { foo: { type: 'string', help: 'help text' } }; + const help = 'Description for some awesome stuff:'; + const printUsage = help + '\n--foo help text\n-h, --help Show help'; + const expected = { helpText: printUsage, values: { __proto__: null, foo: 'bar' }, positionals: [] }; + const result = parseArgs({ args, options, allowPositionals: true, help }); + assert.deepStrictEqual(result, expected); + }); + + test('when options has long flag with value, then both appear in usage with help option', () => { + const args = ['--foo=bar']; + const options = { foo: { type: 'string', help: 'help text' } }; + const help = 'Description for some awesome stuff:'; + const printUsage = help + '\n--foo help text\n-h, --help Show help'; + const expected = { helpText: printUsage, values: { __proto__: null, foo: 'bar' }, positionals: [] }; + const result = parseArgs({ args, options, allowPositionals: true, help }); + assert.deepStrictEqual(result, expected); + }); + + test('when options has help values with and without explicit texts, then all appear in usage', () => { + const args = [ + '-h', '-a', 'val1', + ]; + const options = { + help: { type: 'boolean', short: 'h', help: 'Prints command line options' }, + alpha: { type: 'string', short: 'a', help: 'Alpha option help' }, + beta: { type: 'boolean', short: 'b', help: 'Beta option help' }, + charlie: { type: 'string', short: 'c' }, + delta: { type: 'string', help: 'Delta option help' }, + echo: { type: 'boolean', short: 'e', help: 'Echo option help' }, + foxtrot: { type: 'string', help: 'Foxtrot option help' }, + golf: { type: 'boolean', help: 'Golf option help' }, + hotel: { type: 'string', help: 'Hotel option help' }, + india: { type: 'string' }, + juliet: { type: 'boolean', short: 'j', help: 'Juliet option help' }, + looooooooooooooongHelpText: { + type: 'string', + short: 'L', + help: 'Very long option help text for demonstration purposes' + } + }; + const help = 'Description for some awesome stuff:'; + + const result = parseArgs({ args, options, help }); + const printUsage = + 'Description for some awesome stuff:\n' + + '-h, --help Prints command line options\n' + + '-a, --alpha Alpha option help\n' + + '-b, --beta Beta option help\n' + + '-c, --charlie \n' + + '--delta Delta option help\n' + + '-e, --echo Echo option help\n' + + '--foxtrot Foxtrot option help\n' + + '--golf Golf option help\n' + + '--hotel Hotel option help\n' + + '--india \n' + + '-j, --juliet Juliet option help\n' + + '-L, --looooooooooooooongHelpText \n' + + ' Very long option help text for demonstration purposes'; + + assert.strictEqual(result.helpText, printUsage); + }); + + test('when general help text and options with no help values, then all appear in usage', () => { + const args = ['-a', 'val1', '--help']; + const help = 'Description for some awesome stuff:'; + const options = { alpha: { type: 'string', short: 'a' }, help: { type: 'boolean' } }; + const printUsage = + 'Description for some awesome stuff:\n' + + '-a, --alpha \n' + + '--help'; + + const result = parseArgs({ args, options, help }); + + assert.strictEqual(result.helpText, printUsage); + }); + + test('auto-injects help option when no existing help option', () => { + const args = ['--foo', 'bar']; + const options = { foo: { type: 'string', help: 'use the foo filter' } }; + const help = 'utility to control filters'; + + const result = parseArgs({ args, options, help }); + + assert.ok(result.helpText.includes('-h, --help Show help')); + const resultWithHelp = parseArgs({ args: ['--help'], options, help }); + assert.strictEqual(resultWithHelp.values.help, true); + }); +}