diff --git a/doc/api/util.md b/doc/api/util.md index 6099f6a7b8d59a..006695566169db 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -1921,6 +1921,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 enableHelpPrinting config. - version: - v22.4.0 - v20.16.0 @@ -1959,6 +1963,7 @@ changes: be used if (and only if) the option does not appear in the arguments to be parsed. It must be of the same type as the `type` property. When `multiple` is `true`, it must be an array. + * `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`. @@ -1973,6 +1978,10 @@ 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. + * `enableHelpPrinting` {boolean} When `true`, if any options have help text + configured, the help will be printed to stdout and the process will exit + with code 0. **Default:** `false`. * Returns: {Object} The parsed command line arguments: * `values` {Object} A mapping of parsed option names with their {string} @@ -1980,6 +1989,8 @@ changes: * `positionals` {string\[]} Positional arguments. * `tokens` {Object\[] | undefined} See [parseArgs tokens](#parseargs-tokens) section. Only returned if `config` includes `tokens: true`. + * `printUsage` {string\[] | undefined} Formatted help text for options that have + help text configured. Only included if help text is available and `enableHelpPrinting` is `false`. Provides a higher level API for command-line argument parsing than interacting with `process.argv` directly. Takes a specification for the expected arguments @@ -2025,6 +2036,104 @@ console.log(values, positionals); // Prints: [Object: null prototype] { foo: true, bar: 'b' } [] ``` +### `parseArgs` help text + +`parseArgs` can generate and display help text for command-line options. To enable +this functionality, add `help` text to individual options and optionally provide +general help text via the `help` config property. + +```mjs +import { parseArgs } from 'node:util'; + +const options = { + verbose: { + type: 'boolean', + short: 'v', + help: 'Enable verbose output', + }, + file: { + type: 'string', + short: 'f', + help: 'Input file path', + }, + output: { + type: 'string', + help: 'Output directory', + }, +}; + +// Get help text in result +const result = parseArgs({ + options, + help: 'My CLI Tool v1.0\n\nProcess files with various options.', +}); + +if (result.printUsage) { + console.log(result.printUsage.join('\n')); + // Prints: + // My CLI Tool v1.0 + // + // Process files with various options. + // -v, --verbose Enable verbose output + // -f, --file Input file path + // --output Output directory +} + +// Or automatically print help and exit +parseArgs({ + options, + help: 'My CLI Tool v1.0\n\nProcess files with various options.', + enableHelpPrinting: true, +}); +// Prints help and exits with code 0 +``` + +```cjs +const { parseArgs } = require('node:util'); + +const options = { + verbose: { + type: 'boolean', + short: 'v', + help: 'Enable verbose output', + }, + file: { + type: 'string', + short: 'f', + help: 'Input file path', + }, + output: { + type: 'string', + help: 'Output directory', + }, +}; + +// Get help text in result +const result = parseArgs({ + options, + help: 'My CLI Tool v1.0\n\nProcess files with various options.', +}); + +if (result.printUsage) { + console.log(result.printUsage.join('\n')); + // Prints: + // My CLI Tool v1.0 + // + // Process files with various options. + // -v, --verbose Enable verbose output + // -f, --file Input file path + // --output Output directory +} + +// Or automatically print help and exit +parseArgs({ + options, + help: 'My CLI Tool v1.0\n\nProcess files with various options.', + enableHelpPrinting: true, +}); +// Prints help and exits with code 0 +``` + ### `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 09b3ff7316d3ef..65fd44c245ed05 100644 --- a/lib/internal/util/parse_args/parse_args.js +++ b/lib/internal/util/parse_args/parse_args.js @@ -28,6 +28,7 @@ const { } = require('internal/validators'); const { + findHelpValueForOption, findLongOptionForShort, isLoneLongOption, isLoneShortOption, @@ -218,6 +219,7 @@ function argsToTokens(args, options) { // e.g. '-f' const shortOption = StringPrototypeCharAt(arg, 1); const longOption = findLongOptionForShort(shortOption, options); + const helpValue = findHelpValueForOption(longOption, options); let value; let inlineValue; if (optionsGetOwn(options, longOption, 'type') === 'string' && @@ -229,7 +231,7 @@ function argsToTokens(args, options) { ArrayPrototypePush( tokens, { kind: 'option', name: longOption, rawName: arg, - index, value, inlineValue }); + index, value, inlineValue, ...(helpValue !== undefined && { help: helpValue }) }); if (value != null) ++index; continue; } @@ -261,16 +263,19 @@ function argsToTokens(args, options) { const shortOption = StringPrototypeCharAt(arg, 1); const longOption = findLongOptionForShort(shortOption, options); const value = StringPrototypeSlice(arg, 2); + const helpValue = findHelpValueForOption(longOption, options); ArrayPrototypePush( tokens, { kind: 'option', name: longOption, rawName: `-${shortOption}`, - index, value, inlineValue: true }); + index, value, inlineValue: true, + ...(helpValue !== undefined && { help: helpValue }) }); continue; } if (isLoneLongOption(arg)) { // e.g. '--foo' const longOption = StringPrototypeSlice(arg, 2); + const helpValue = findHelpValueForOption(longOption, options); let value; let inlineValue; if (optionsGetOwn(options, longOption, 'type') === 'string' && @@ -282,7 +287,7 @@ function argsToTokens(args, options) { ArrayPrototypePush( tokens, { kind: 'option', name: longOption, rawName: arg, - index, value, inlineValue }); + index, value, inlineValue, ...(helpValue !== undefined && { help: helpValue }) }); if (value != null) ++index; continue; } @@ -292,10 +297,11 @@ function argsToTokens(args, options) { const equalIndex = StringPrototypeIndexOf(arg, '='); const longOption = StringPrototypeSlice(arg, 2, equalIndex); const value = StringPrototypeSlice(arg, equalIndex + 1); + const helpValue = findHelpValueForOption(longOption, options); ArrayPrototypePush( tokens, { kind: 'option', name: longOption, rawName: `--${longOption}`, - index, value, inlineValue: true }); + index, value, inlineValue: true, ...(helpValue !== undefined && { help: helpValue }) }); continue; } @@ -304,6 +310,43 @@ 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 (help) { + if (shortOption) { + helpTextForPrint += `-${shortOption}, `; + } + helpTextForPrint += `--${longOption}`; + if (type === 'string') { + helpTextForPrint += ' '; + } else if (type === 'boolean') { + helpTextForPrint += ''; + } + if (helpTextForPrint.length > layoutSpacing) { + helpTextForPrint += '\n' + ''.padEnd(layoutSpacing) + help; + } else { + helpTextForPrint = helpTextForPrint.padEnd(layoutSpacing) + help; + } + } + + return helpTextForPrint; +} + const parseArgs = (config = kEmptyObject) => { const args = objectGetOwn(config, 'args') ?? getMainArgs(); const strict = objectGetOwn(config, 'strict') ?? true; @@ -311,6 +354,8 @@ const parseArgs = (config = kEmptyObject) => { const returnTokens = objectGetOwn(config, 'tokens') ?? false; const allowNegative = objectGetOwn(config, 'allowNegative') ?? false; const options = objectGetOwn(config, 'options') ?? { __proto__: null }; + const help = objectGetOwn(config, 'help') ?? ''; + const enableHelpPrinting = objectGetOwn(config, 'enableHelpPrinting') ?? false; // Bundle these up for passing to strict-mode checks. const parseConfig = { args, strict, options, allowPositionals, allowNegative }; @@ -321,11 +366,12 @@ const parseArgs = (config = kEmptyObject) => { validateBoolean(returnTokens, 'tokens'); validateBoolean(allowNegative, 'allowNegative'); validateObject(options, 'options'); + validateString(help, 'help'); + validateBoolean(enableHelpPrinting, 'enableHelpPrinting'); 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']); @@ -361,6 +407,11 @@ const parseArgs = (config = kEmptyObject) => { } validator(defaultValue, `options.${longOption}.default`); } + + const helpOption = objectGetOwn(optionConfig, 'help'); + if (ObjectHasOwn(optionConfig, 'help')) { + validateString(helpOption, `options.${longOption}.help`); + } }, ); @@ -403,6 +454,32 @@ const parseArgs = (config = kEmptyObject) => { } }); + // Phase 4: generate print usage for each option + const printUsage = []; + if (help) { + ArrayPrototypePush(printUsage, help); + } + ArrayPrototypeForEach(ObjectEntries(options), ({ 0: longOption, 1: optionConfig }) => { + const helpTextForPrint = formatHelpTextForPrint(longOption, optionConfig); + + if (helpTextForPrint) { + ArrayPrototypePush(printUsage, helpTextForPrint); + } + }); + + if (enableHelpPrinting) { + const console = require('internal/console/global'); + if (printUsage.length > 0 || help) { + ArrayPrototypeForEach(printUsage, (line) => { + console.log(line); + }); + } else { + console.log('No help text available.'); + } + process.exit(0); + } else if (printUsage.length > 0) { + result.printUsage = printUsage; + } return result; }; diff --git a/lib/internal/util/parse_args/utils.js b/lib/internal/util/parse_args/utils.js index 95f787daf1debf..a17c834e6328f0 100644 --- a/lib/internal/util/parse_args/utils.js +++ b/lib/internal/util/parse_args/utils.js @@ -170,6 +170,21 @@ function findLongOptionForShort(shortOption, options) { return longOptionEntry?.[0] ?? shortOption; } +/** + * Find the help value associated with a long option. + * @param {string} longOption + * @param {object} options + * @returns {string|undefined} - the help value or undefined if not found + */ +function findHelpValueForOption(longOption, options) { + validateObject(options, 'options'); + if (ObjectHasOwn(options, longOption)) { + return objectGetOwn(options[longOption], 'help'); + } + + return undefined; +} + /** * Check if the given option includes a default value * and that option has not been set by the input args. @@ -183,6 +198,7 @@ function useDefaultValueOption(longOption, optionConfig, values) { } module.exports = { + findHelpValueForOption, findLongOptionForShort, isLoneLongOption, isLoneShortOption, diff --git a/test/parallel/test-parse-args.mjs b/test/parallel/test-parse-args.mjs index e79434bdc6bbbf..3dbfbdf6d9932b 100644 --- a/test/parallel/test-parse-args.mjs +++ b/test/parallel/test-parse-args.mjs @@ -1062,3 +1062,180 @@ test('auto-detect --no-foo as negated when strict:false and allowNegative', () = process.argv = holdArgv; process.execArgv = holdExecArgv; }); + +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 help value for lone short option is added, then add help text', () => { + const args = ['-f', 'bar']; + const options = { foo: { type: 'string', short: 'f', help: 'help text' } }; + const printUsage = ['-f, --foo help text']; + const expected = { values: { __proto__: null, foo: 'bar' }, positionals: [], printUsage }; + const result = parseArgs({ args, options, allowPositionals: true }); + assert.deepStrictEqual(result, expected); +}); + +test('when help value for short group option is added, then add help text', () => { + const args = ['-fm', 'bar']; + const options = { foo: { type: 'boolean', short: 'f', help: 'help text' }, + moo: { type: 'string', short: 'm', help: 'help text' } }; + const printUsage = ['-f, --foo help text', + '-m, --moo help text']; + const expected = { values: { __proto__: null, foo: true, moo: 'bar' }, positionals: [], printUsage }; + const result = parseArgs({ args, options, allowPositionals: true }); + assert.deepStrictEqual(result, expected); +}); + +test('when help value for short option and value is added, then add help text', () => { + const args = ['-fFILE']; + const options = { foo: { type: 'string', short: 'f', help: 'help text' } }; + const printUsage = ['-f, --foo help text']; + const expected = { values: { __proto__: null, foo: 'FILE' }, positionals: [], printUsage }; + const result = parseArgs({ args, options, allowPositionals: true }); + assert.deepStrictEqual(result, expected); +}); + +test('when help value for lone long option is added, then add help text', () => { + const args = ['--foo', 'bar']; + const options = { foo: { type: 'string', help: 'help text' } }; + const printUsage = ['--foo help text']; + const expected = { values: { __proto__: null, foo: 'bar' }, positionals: [], printUsage }; + const result = parseArgs({ args, options, allowPositionals: true }); + assert.deepStrictEqual(result, expected); +}); + +test('when help value for lone long option and value is added, then add help text', () => { + const args = ['--foo=bar']; + const options = { foo: { type: 'string', help: 'help text' } }; + const printUsage = ['--foo help text']; + const expected = { values: { __proto__: null, foo: 'bar' }, positionals: [], printUsage }; + const result = parseArgs({ args, options, allowPositionals: true }); + assert.deepStrictEqual(result, expected); +}); + +test('help 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('when help value is added, then add initial help text', () => { + const args = ['-f', 'bar']; + const options = { foo: { type: 'string', short: 'f', help: 'help text' } }; + const help = 'Description for some awesome stuff:'; + const printUsage = [help, '-f, --foo help text']; + const expected = { values: { __proto__: null, foo: 'bar' }, positionals: [], printUsage }; + const result = parseArgs({ args, options, help }); + assert.deepStrictEqual(result, expected); +}); + +test('enableHelpPrinting config must be a boolean', () => { + const args = ['-f', 'bar']; + const options = { foo: { type: 'string', short: 'f', help: 'help text' } }; + const help = 'Description for some awesome stuff:'; + const enableHelpPrinting = 'not a boolean'; + assert.throws(() => { + parseArgs({ args, options, help, enableHelpPrinting }); + }, /The "enableHelpPrinting" argument must be of type boolean/ + ); +}); + +function setupConsoleAndExit() { + const originalLog = console.log; + const originalExit = process.exit; + + let output = ''; + let exitCode = null; + + console.log = (message) => { + output += message + '\n'; + }; + + process.exit = (code) => { + exitCode = code; + }; + + function restore() { + console.log = originalLog; + process.exit = originalExit; + } + + return { getOutput: () => output, getExitCode: () => exitCode, restore }; +} + +test('when enableHelpPrinting config is true, print all help text and exit', () => { + const { getOutput, getExitCode, restore } = setupConsoleAndExit(); + + try { + const args = [ + '-a', 'val1', '--beta', '-c', 'val3', '--delta', 'val4', '-e', + '--foxtrot', 'val6', '--golf', '-h', 'val8', '--india', 'val9', '-j', + ]; + const options = { + alpha: { type: 'string', short: 'a', help: 'Alpha option help' }, + beta: { type: 'boolean', short: 'b', help: 'Beta option help' }, + charlie: { type: 'string', short: 'c', help: 'Charlie option help' }, + 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', short: 'h', help: 'Hotel option help' }, + india: { type: 'string', help: 'India option help' }, + 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:'; + + parseArgs({ args, options, help, enableHelpPrinting: true }); + } finally { + restore(); + } + + const expectedOutput = + 'Description for some awesome stuff:\n' + + '-a, --alpha Alpha option help\n' + + '-b, --beta Beta option help\n' + + '-c, --charlie Charlie option help\n' + + '--delta Delta option help\n' + + '-e, --echo Echo option help\n' + + '--foxtrot Foxtrot option help\n' + + '--golf Golf option help\n' + + '-h, --hotel Hotel option help\n' + + '--india India option help\n' + + '-j, --juliet Juliet option help\n' + + '-L, --looooooooooooooongHelpText \n' + + ' Very long option help text for demonstration purposes\n'; + + assert.strictEqual(getExitCode(), 0); + assert.strictEqual(getOutput(), expectedOutput); +}); + +test('when enableHelpPrinting config is true, but no help text is available', () => { + const { getOutput, getExitCode, restore } = setupConsoleAndExit(); + + try { + const args = ['-a', 'val1']; + const options = { alpha: { type: 'string', short: 'a' } }; + + parseArgs({ args, options, enableHelpPrinting: true }); + } finally { + restore(); + } + + assert.strictEqual(getExitCode(), 0); + assert.strictEqual(getOutput(), 'No help text available.\n'); +});