Skip to content

Add support to print help/usage of util.parseArgs #58875

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions doc/api/util.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 general help text.
- version:
- v22.4.0
- v20.16.0
Expand Down Expand Up @@ -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`.
Expand All @@ -1973,13 +1978,16 @@ 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.
* `tokens` {Object\[] | undefined} See [parseArgs tokens](#parseargs-tokens)
section. Only returned if `config` includes `tokens: true`.
* `printUsage` {string | undefined} Formatted help text for all options provided. Only included if general `help` text
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In some of the original discussion, printUsage was a function and hence the name. It was suggested that simpler to just return a string, which is what you have done here. So I think the name could be changed.

Suggested change
* `printUsage` {string | undefined} Formatted help text for all options provided. Only included if general `help` text
* `usage` {string | undefined} Formatted help text for all options provided. Only included if general `help` text

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally think a function is better, because then it allows the implementation to defer formatting of the text if it wishes to.

is available.

Provides a higher level API for command-line argument parsing than interacting
with `process.argv` directly. Takes a specification for the expected arguments
Expand Down Expand Up @@ -2025,6 +2033,86 @@ 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. To use this feature, provide
general help text using the `help` config property, and also
add a `help` property to each option can be optionally included.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
add a `help` property to each option can be optionally included.
a `help` property to each option can be optionally included.


```mjs
import { parseArgs } from 'node:util';

const options = {
verbose: {
type: 'boolean',
short: 'v',
},
help: {
type: 'boolean',
short: 'h',
help: 'Prints command line options',
},
output: {
type: 'string',
help: 'Output directory',
},
};

// Get serialized help text in result
const result = parseArgs({
options,
help: 'My CLI Tool v1.0\n\nProcess files with various options.',
});

if (result.printUsage) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the current implementation, printUsage is always defined with this example code?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

printUsage will be available on result only if help config or help text on options are present.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (result.printUsage) {
if (result.values.help) {

console.log(result.printUsage);
// Prints:
// My CLI Tool v1.0
//
// Process files with various options.
// -v, --verbose
// -h, --help. Prints command line options
// --output <arg> Output directory
}
```

```cjs
const { parseArgs } = require('node:util');

const options = {
verbose: {
type: 'boolean',
short: 'v',
},
help: {
type: 'boolean',
short: 'h',
help: 'Prints command line options',
},
output: {
type: 'string',
help: 'Output directory',
},
};

// Get serialized help text in result
const result = parseArgs({
options,
help: 'My CLI Tool v1.0\n\nProcess files with various options.',
});

if (result.printUsage) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (result.printUsage) {
if (result.values.help) {

console.log(result.printUsage);
// Prints:
// My CLI Tool v1.0
//
// Process files with various options.
// -v, --verbose
// -h, --help. Prints command line options
// --output <arg> Output directory
}
```

### `parseArgs` `tokens`

Detailed parse information is available for adding custom behaviors by
Expand Down
62 changes: 61 additions & 1 deletion lib/internal/util/parse_args/parse_args.js
Original file line number Diff line number Diff line change
Expand Up @@ -304,13 +304,51 @@ 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 <arg> help text'
*/
function formatHelpTextForPrint(longOption, optionConfig) {
const layoutSpacing = 30;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found print has some logic related to layout spacing, maybe we can use something similar here.


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 += ' <arg>';
} else if (type === 'boolean') {
helpTextForPrint += '';
Comment on lines +330 to +331
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think these two lines do anything?

}
if (help) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest that options without explicit help text are still included in the formatted help. This makes basic help simple. Add any help field, including describing the overall program, and you get formatted help returned with all the available options included.

Yes, some users will want "hidden" options. But I think that is uncommon, and more useful to make it easy to get help text for simple programs with (well named) options, without needing to describe each option.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(--charlie and --india in the big example in the tests.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's also a lot easier to filter out help text you don't want, than to recreate and format help text you do want.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shadowspawn @ljharb sounds good! I just pushed the changes related to this and removed the automatic printing, thanks for all the help so far!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was imagining that supplying any help text, whether the general help or for an option, would trigger returning the formatted help in the results. The current implementation requires passing a general text property. (And the documentation says that too, so self consistent.)

I don't feel strongly about this. See what others think.

If the help without general text was allowed it would look like:

% node index.cjs --help 
-v, --verbose
-h, --help                    Prints command line options
--output <arg>                Output directory

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;
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 };
const help = objectGetOwn(config, 'help') ?? '';
// Bundle these up for passing to strict-mode checks.
const parseConfig = { args, strict, options, allowPositionals, allowNegative };

Expand All @@ -321,11 +359,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']);
Expand Down Expand Up @@ -361,6 +399,11 @@ const parseArgs = (config = kEmptyObject) => {
}
validator(defaultValue, `options.${longOption}.default`);
}

const helpOption = objectGetOwn(optionConfig, 'help');
if (ObjectHasOwn(optionConfig, 'help')) {
validateString(helpOption, `options.${longOption}.help`);
}
},
);

Expand Down Expand Up @@ -403,6 +446,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.printUsage = printUsage;
}

return result;
};
Expand Down
128 changes: 128 additions & 0 deletions test/parallel/test-parse-args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1062,3 +1062,131 @@ test('auto-detect --no-foo as negated when strict:false and allowNegative', () =
process.argv = holdArgv;
process.execArgv = holdExecArgv;
});

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 help arg with help value for lone short option is added, then add help text', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of the test seems to be describing the arguments to be parsed rather than the interesting variations of the formatted help. I might be misunderstanding the intent.

Perhaps:

Suggested change
test('when help arg with help value for lone short option is added, then add help text', () => {
test('when option has short and long flags, then both appear in usage', () => {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was lone perhaps referring to there only being one option? Single option is fine as testing one thing at a time is good for unit tests.

I think things of interest to test are:

  • option with no short flag
  • option with long and short flag
  • string option
  • boolean option
  • option without help text
  • long option that causes wrap

You are testing all of these (so good coverage), but the test names seem to be describing the supplied args rather than the supplied options.

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 <arg> help text';
const expected = { values: { __proto__: null, foo: 'bar' }, positionals: [], printUsage };
const result = parseArgs({ args, options, allowPositionals: true, help });
assert.deepStrictEqual(result, expected);
});

test('when help arg with 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 help = 'Description for some awesome stuff:';
const printUsage = help + '\n-f, --foo help text\n-m, --moo <arg> help text';
const expected = { values: { __proto__: null, foo: true, moo: 'bar' }, positionals: [], printUsage };
const result = parseArgs({ args, options, allowPositionals: true, help });
assert.deepStrictEqual(result, expected);
});

test('when help arg with 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 help = 'Description for some awesome stuff:';
const printUsage = help + '\n-f, --foo <arg> help text';
const expected = { values: { __proto__: null, foo: 'FILE' }, positionals: [], printUsage };
const result = parseArgs({ args, options, allowPositionals: true, help });
assert.deepStrictEqual(result, expected);
});

test('when help arg with 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 help = 'Description for some awesome stuff:';
const printUsage = help + '\n--foo <arg> help text';
const expected = { values: { __proto__: null, foo: 'bar' }, positionals: [], printUsage };
const result = parseArgs({ args, options, allowPositionals: true, help });
assert.deepStrictEqual(result, expected);
});

test('when help arg with 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 help = 'Description for some awesome stuff:';
const printUsage = help + '\n--foo <arg> help text';
const expected = { values: { __proto__: null, foo: 'bar' }, positionals: [], printUsage };
const result = parseArgs({ args, options, allowPositionals: true, help });
assert.deepStrictEqual(result, expected);
});

test('when help arg with help values and without explicit help texts, then add help text', () => {
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 <arg> Alpha option help\n' +
'-b, --beta Beta option help\n' +
'-c, --charlie <arg>\n' +
'--delta <arg> Delta option help\n' +
'-e, --echo Echo option help\n' +
'--foxtrot <arg> Foxtrot option help\n' +
'--golf Golf option help\n' +
'--hotel <arg> Hotel option help\n' +
'--india <arg>\n' +
'-j, --juliet Juliet option help\n' +
'-L, --looooooooooooooongHelpText <arg>\n' +
' Very long option help text for demonstration purposes';

assert.strictEqual(result.printUsage, printUsage);
});

test('when help arg but no help text is available, then add help text', () => {
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 <arg>\n' +
'--help';

const result = parseArgs({ args, options, help });

assert.strictEqual(result.printUsage, printUsage);
});
Loading