minus-h
Version:
Add help generation to APIs created with Node's util.parseArgs function
172 lines (171 loc) • 5.76 kB
JavaScript
import { EOL } from 'node:os';
import { LineWrap } from '@cto.af/linewrap';
import { parse } from 'node:path';
import { parseArgs } from 'node:util';
const DEFAULT_ARG_NAME = 'value';
function normalizeOptions(config) {
config ??= {};
config.options ??= {};
config.options.help ??= {
short: 'h',
type: 'boolean',
description: 'display help for command',
};
config.outputStream ??= process.stderr;
config.exit ??= process.exit;
return config;
}
function* generateHelp(config, opts) {
const cfg = normalizeOptions(config);
const lw = new LineWrap({
width: process.stdout.columns,
...opts,
});
const { name } = parse(config.scriptName ?? process.argv[1]);
let usg = `Usage: ${name}`;
let max = -Infinity;
if (cfg.options) {
usg += ' [options]';
for (const [long, info] of Object.entries(cfg.options)) {
let len = long.length + 2; // --
if (info.short) {
len += 3; // ,-s
}
if (info.type === 'string') {
const argName = info.argumentName ?? DEFAULT_ARG_NAME;
len += argName.length + 3;
}
max = Math.max(len, max);
}
}
if (cfg.allowPositionals || (cfg.strict === false)) {
const argName = cfg.argumentName ?? 'arguments';
usg += ` [${argName}]`;
max = Math.max(argName.length, max);
}
yield usg;
yield '';
if (cfg.description) {
yield* lw.lines(cfg.description);
yield '';
}
max += 4; // 2 on each side
const w = opts?.width ?? process.stdout.columns;
const indented = new LineWrap({
...opts,
indent: max,
indentFirst: false,
width: Math.max(max + 2, w), // At least two chars per line
});
if (cfg.argumentDescription) {
yield 'Arguments:';
const wrapped = indented.lines(cfg.argumentDescription);
yield ` ${cfg.argumentName}`.padEnd(max, ' ') + wrapped.next().value;
yield* wrapped;
yield '';
}
if (cfg.options) {
yield 'Options:';
const sorted = Object
.entries(cfg.options)
.sort(([longA, infoA], [longB, infoB]) => {
const a = infoA.short ?? longA;
const b = infoB.short ?? longB;
return a.localeCompare(b);
});
for (const [long, info] of sorted) {
let param = `--${long}`;
if (info.short) {
param = `-${info.short},${param}`;
}
if (info.type === 'string') {
const argName = info.argumentName ?? DEFAULT_ARG_NAME;
param += ` <${argName}>`;
}
param = ` ${param}`;
let desc = info.description ?? '';
if (info.choices && (info.choices.length > 0)) {
if (desc) {
desc += ' ';
}
desc += '(choices: ';
desc += info.choices.map(c => JSON.stringify(c)).join(', ');
desc += ')';
}
if (info.default != null) {
if (desc) {
desc += ' ';
}
desc += `Default: ${JSON.stringify(info.default)}`;
}
if (desc) {
const wrapped = indented.lines(desc);
yield param.padEnd(max, ' ') + wrapped.next().value;
yield* wrapped;
}
else {
yield param;
}
}
}
}
export function usage(config, options) {
const cfg = normalizeOptions(config);
for (const line of generateHelp(cfg, options)) {
cfg.outputStream?.write(line);
cfg.outputStream?.write(options?.newline ?? EOL);
}
cfg.exit?.(64);
}
function isCodeError(e) {
return (e instanceof Error) && (Object.prototype.hasOwnProperty.call(e, 'code'));
}
const USAGE_ERRORS = [
'ERR_PARSE_ARGS_INVALID_OPTION_VALUE',
'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL',
'ERR_PARSE_ARGS_UNKNOWN_OPTION',
];
/**
* Wrap `util.parseArgs()`, adding a "-h,--help" argument. It takes one or
* two arguments. The wrapping width defaults to your terminal width (via
* [process.stdout.columns](https://nodejs.org/api/tty.html#writestreamcolumns)).
*
* @param config An augmented version of the options passed to
* `util.parseArgs()`, with descriptions provided for options as well as the
* command as a whole.
* @param options How to do line wrapping?
* @returns The parsed results.
*/
export function parseArgsWithHelp(config, options) {
const cfg = normalizeOptions(config);
let results = null;
try {
results = parseArgs(cfg);
}
catch (e) {
if (isCodeError(e) && USAGE_ERRORS.includes(e.code)) {
cfg.outputStream?.write(e.message);
cfg.outputStream?.write(EOL);
cfg.outputStream?.write(EOL);
usage(cfg, options);
}
throw e;
}
if (results?.values?.help) {
usage(cfg, options);
}
if (cfg.options) {
for (const [long, info] of Object.entries(cfg.options)) {
if (info.choices) {
const val = results.values[long];
if ((typeof val === 'string') && !info.choices.includes(val)) {
cfg.outputStream?.write(`Option '--${long} <${info.argumentName ?? DEFAULT_ARG_NAME}>' argument must be one of ${JSON.stringify(info.choices)}`);
cfg.outputStream?.write(EOL);
cfg.outputStream?.write(EOL);
usage(cfg, options);
}
}
}
}
return results;
}