462 lines
14 KiB
JavaScript
462 lines
14 KiB
JavaScript
|
const { humanReadableArgName } = require('./argument.js');
|
||
|
|
||
|
/**
|
||
|
* TypeScript import types for JSDoc, used by Visual Studio Code IntelliSense and `npm run typescript-checkJS`
|
||
|
* https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#import-types
|
||
|
* @typedef { import("./argument.js").Argument } Argument
|
||
|
* @typedef { import("./command.js").Command } Command
|
||
|
* @typedef { import("./option.js").Option } Option
|
||
|
*/
|
||
|
|
||
|
// @ts-check
|
||
|
|
||
|
// Although this is a class, methods are static in style to allow override using subclass or just functions.
|
||
|
class Help {
|
||
|
constructor() {
|
||
|
this.helpWidth = undefined;
|
||
|
this.sortSubcommands = false;
|
||
|
this.sortOptions = false;
|
||
|
this.showGlobalOptions = false;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get an array of the visible subcommands. Includes a placeholder for the implicit help command, if there is one.
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @returns {Command[]}
|
||
|
*/
|
||
|
|
||
|
visibleCommands(cmd) {
|
||
|
const visibleCommands = cmd.commands.filter(cmd => !cmd._hidden);
|
||
|
if (cmd._hasImplicitHelpCommand()) {
|
||
|
// Create a command matching the implicit help command.
|
||
|
const [, helpName, helpArgs] = cmd._helpCommandnameAndArgs.match(/([^ ]+) *(.*)/);
|
||
|
const helpCommand = cmd.createCommand(helpName)
|
||
|
.helpOption(false);
|
||
|
helpCommand.description(cmd._helpCommandDescription);
|
||
|
if (helpArgs) helpCommand.arguments(helpArgs);
|
||
|
visibleCommands.push(helpCommand);
|
||
|
}
|
||
|
if (this.sortSubcommands) {
|
||
|
visibleCommands.sort((a, b) => {
|
||
|
// @ts-ignore: overloaded return type
|
||
|
return a.name().localeCompare(b.name());
|
||
|
});
|
||
|
}
|
||
|
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.
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @returns {Option[]}
|
||
|
*/
|
||
|
|
||
|
visibleOptions(cmd) {
|
||
|
const visibleOptions = cmd.options.filter((option) => !option.hidden);
|
||
|
// Implicit help
|
||
|
const showShortHelpFlag = cmd._hasHelpOption && cmd._helpShortFlag && !cmd._findOption(cmd._helpShortFlag);
|
||
|
const showLongHelpFlag = cmd._hasHelpOption && !cmd._findOption(cmd._helpLongFlag);
|
||
|
if (showShortHelpFlag || showLongHelpFlag) {
|
||
|
let helpOption;
|
||
|
if (!showShortHelpFlag) {
|
||
|
helpOption = cmd.createOption(cmd._helpLongFlag, cmd._helpDescription);
|
||
|
} else if (!showLongHelpFlag) {
|
||
|
helpOption = cmd.createOption(cmd._helpShortFlag, cmd._helpDescription);
|
||
|
} else {
|
||
|
helpOption = cmd.createOption(cmd._helpFlags, cmd._helpDescription);
|
||
|
}
|
||
|
visibleOptions.push(helpOption);
|
||
|
}
|
||
|
if (this.sortOptions) {
|
||
|
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.
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @returns {Argument[]}
|
||
|
*/
|
||
|
|
||
|
visibleArguments(cmd) {
|
||
|
// Side effect! Apply the legacy descriptions before the arguments are displayed.
|
||
|
if (cmd._argsDescription) {
|
||
|
cmd._args.forEach(argument => {
|
||
|
argument.description = argument.description || cmd._argsDescription[argument.name()] || '';
|
||
|
});
|
||
|
}
|
||
|
|
||
|
// If there are any arguments with a description then return all the arguments.
|
||
|
if (cmd._args.find(argument => argument.description)) {
|
||
|
return cmd._args;
|
||
|
}
|
||
|
return [];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the command term to show in the list of subcommands.
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
|
||
|
subcommandTerm(cmd) {
|
||
|
// Legacy. Ignores custom usage string, and nested commands.
|
||
|
const args = cmd._args.map(arg => humanReadableArgName(arg)).join(' ');
|
||
|
return cmd._name +
|
||
|
(cmd._aliases[0] ? '|' + cmd._aliases[0] : '') +
|
||
|
(cmd.options.length ? ' [options]' : '') + // simplistic check for non-help option
|
||
|
(args ? ' ' + args : '');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the option term to show in the list of options.
|
||
|
*
|
||
|
* @param {Option} option
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
|
||
|
optionTerm(option) {
|
||
|
return option.flags;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the argument term to show in the list of arguments.
|
||
|
*
|
||
|
* @param {Argument} argument
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
|
||
|
argumentTerm(argument) {
|
||
|
return argument.name();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the longest command term length.
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @param {Help} helper
|
||
|
* @returns {number}
|
||
|
*/
|
||
|
|
||
|
longestSubcommandTermLength(cmd, helper) {
|
||
|
return helper.visibleCommands(cmd).reduce((max, command) => {
|
||
|
return Math.max(max, helper.subcommandTerm(command).length);
|
||
|
}, 0);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the longest option term length.
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @param {Help} helper
|
||
|
* @returns {number}
|
||
|
*/
|
||
|
|
||
|
longestOptionTermLength(cmd, helper) {
|
||
|
return helper.visibleOptions(cmd).reduce((max, option) => {
|
||
|
return Math.max(max, helper.optionTerm(option).length);
|
||
|
}, 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.
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @param {Help} helper
|
||
|
* @returns {number}
|
||
|
*/
|
||
|
|
||
|
longestArgumentTermLength(cmd, helper) {
|
||
|
return helper.visibleArguments(cmd).reduce((max, argument) => {
|
||
|
return Math.max(max, helper.argumentTerm(argument).length);
|
||
|
}, 0);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the command usage to be displayed at the top of the built-in help.
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
|
||
|
commandUsage(cmd) {
|
||
|
// Usage
|
||
|
let cmdName = cmd._name;
|
||
|
if (cmd._aliases[0]) {
|
||
|
cmdName = cmdName + '|' + cmd._aliases[0];
|
||
|
}
|
||
|
let parentCmdNames = '';
|
||
|
for (let parentCmd = cmd.parent; parentCmd; parentCmd = parentCmd.parent) {
|
||
|
parentCmdNames = parentCmd.name() + ' ' + parentCmdNames;
|
||
|
}
|
||
|
return parentCmdNames + cmdName + ' ' + cmd.usage();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the description for the command.
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
|
||
|
commandDescription(cmd) {
|
||
|
// @ts-ignore: overloaded return type
|
||
|
return cmd.description();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the subcommand summary to show in the list of subcommands.
|
||
|
* (Fallback to description for backwards compatibility.)
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
|
||
|
subcommandDescription(cmd) {
|
||
|
// @ts-ignore: overloaded return type
|
||
|
return cmd.summary() || cmd.description();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the option description to show in the list of options.
|
||
|
*
|
||
|
* @param {Option} option
|
||
|
* @return {string}
|
||
|
*/
|
||
|
|
||
|
optionDescription(option) {
|
||
|
const extraInfo = [];
|
||
|
|
||
|
if (option.argChoices) {
|
||
|
extraInfo.push(
|
||
|
// use stringify to match the display of the default value
|
||
|
`choices: ${option.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`);
|
||
|
}
|
||
|
if (option.defaultValue !== undefined) {
|
||
|
// default for boolean and negated more for programmer than end user,
|
||
|
// but show true/false for boolean option as may be for hand-rolled env or config processing.
|
||
|
const showDefault = option.required || option.optional ||
|
||
|
(option.isBoolean() && typeof option.defaultValue === 'boolean');
|
||
|
if (showDefault) {
|
||
|
extraInfo.push(`default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`);
|
||
|
}
|
||
|
}
|
||
|
// preset for boolean and negated are more for programmer than end user
|
||
|
if (option.presetArg !== undefined && option.optional) {
|
||
|
extraInfo.push(`preset: ${JSON.stringify(option.presetArg)}`);
|
||
|
}
|
||
|
if (option.envVar !== undefined) {
|
||
|
extraInfo.push(`env: ${option.envVar}`);
|
||
|
}
|
||
|
if (extraInfo.length > 0) {
|
||
|
return `${option.description} (${extraInfo.join(', ')})`;
|
||
|
}
|
||
|
|
||
|
return option.description;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get the argument description to show in the list of arguments.
|
||
|
*
|
||
|
* @param {Argument} argument
|
||
|
* @return {string}
|
||
|
*/
|
||
|
|
||
|
argumentDescription(argument) {
|
||
|
const extraInfo = [];
|
||
|
if (argument.argChoices) {
|
||
|
extraInfo.push(
|
||
|
// use stringify to match the display of the default value
|
||
|
`choices: ${argument.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`);
|
||
|
}
|
||
|
if (argument.defaultValue !== undefined) {
|
||
|
extraInfo.push(`default: ${argument.defaultValueDescription || JSON.stringify(argument.defaultValue)}`);
|
||
|
}
|
||
|
if (extraInfo.length > 0) {
|
||
|
const extraDescripton = `(${extraInfo.join(', ')})`;
|
||
|
if (argument.description) {
|
||
|
return `${argument.description} ${extraDescripton}`;
|
||
|
}
|
||
|
return extraDescripton;
|
||
|
}
|
||
|
return argument.description;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Generate the built-in help text.
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @param {Help} helper
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
|
||
|
formatHelp(cmd, helper) {
|
||
|
const termWidth = helper.padWidth(cmd, helper);
|
||
|
const helpWidth = helper.helpWidth || 80;
|
||
|
const itemIndentWidth = 2;
|
||
|
const itemSeparatorWidth = 2; // between term and description
|
||
|
function formatItem(term, description) {
|
||
|
if (description) {
|
||
|
const fullText = `${term.padEnd(termWidth + itemSeparatorWidth)}${description}`;
|
||
|
return helper.wrap(fullText, helpWidth - itemIndentWidth, termWidth + itemSeparatorWidth);
|
||
|
}
|
||
|
return term;
|
||
|
}
|
||
|
function formatList(textArray) {
|
||
|
return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth));
|
||
|
}
|
||
|
|
||
|
// Usage
|
||
|
let output = [`Usage: ${helper.commandUsage(cmd)}`, ''];
|
||
|
|
||
|
// Description
|
||
|
const commandDescription = helper.commandDescription(cmd);
|
||
|
if (commandDescription.length > 0) {
|
||
|
output = output.concat([commandDescription, '']);
|
||
|
}
|
||
|
|
||
|
// Arguments
|
||
|
const argumentList = helper.visibleArguments(cmd).map((argument) => {
|
||
|
return formatItem(helper.argumentTerm(argument), helper.argumentDescription(argument));
|
||
|
});
|
||
|
if (argumentList.length > 0) {
|
||
|
output = output.concat(['Arguments:', formatList(argumentList), '']);
|
||
|
}
|
||
|
|
||
|
// Options
|
||
|
const optionList = helper.visibleOptions(cmd).map((option) => {
|
||
|
return formatItem(helper.optionTerm(option), helper.optionDescription(option));
|
||
|
});
|
||
|
if (optionList.length > 0) {
|
||
|
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));
|
||
|
});
|
||
|
if (commandList.length > 0) {
|
||
|
output = output.concat(['Commands:', formatList(commandList), '']);
|
||
|
}
|
||
|
|
||
|
return output.join('\n');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Calculate the pad width from the maximum term length.
|
||
|
*
|
||
|
* @param {Command} cmd
|
||
|
* @param {Help} helper
|
||
|
* @returns {number}
|
||
|
*/
|
||
|
|
||
|
padWidth(cmd, helper) {
|
||
|
return Math.max(
|
||
|
helper.longestOptionTermLength(cmd, helper),
|
||
|
helper.longestGlobalOptionTermLength(cmd, helper),
|
||
|
helper.longestSubcommandTermLength(cmd, helper),
|
||
|
helper.longestArgumentTermLength(cmd, helper)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Wrap the given string to width characters per line, with lines after the first indented.
|
||
|
* Do not wrap if insufficient room for wrapping (minColumnWidth), or string is manually formatted.
|
||
|
*
|
||
|
* @param {string} str
|
||
|
* @param {number} width
|
||
|
* @param {number} indent
|
||
|
* @param {number} [minColumnWidth=40]
|
||
|
* @return {string}
|
||
|
*
|
||
|
*/
|
||
|
|
||
|
wrap(str, width, indent, minColumnWidth = 40) {
|
||
|
// Detect manually wrapped and indented strings by searching for line breaks
|
||
|
// followed by multiple spaces/tabs.
|
||
|
if (str.match(/[\n]\s+/)) return str;
|
||
|
// Do not wrap if not enough room for a wrapped column of text (as could end up with a word per line).
|
||
|
const columnWidth = width - indent;
|
||
|
if (columnWidth < minColumnWidth) return str;
|
||
|
|
||
|
const leadingStr = str.slice(0, indent);
|
||
|
const columnText = str.slice(indent);
|
||
|
|
||
|
const indentString = ' '.repeat(indent);
|
||
|
const regex = new RegExp('.{1,' + (columnWidth - 1) + '}([\\s\u200B]|$)|[^\\s\u200B]+?([\\s\u200B]|$)', 'g');
|
||
|
const lines = columnText.match(regex) || [];
|
||
|
return leadingStr + lines.map((line, i) => {
|
||
|
if (line.slice(-1) === '\n') {
|
||
|
line = line.slice(0, line.length - 1);
|
||
|
}
|
||
|
return ((i > 0) ? indentString : '') + line.trimRight();
|
||
|
}).join('\n');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
exports.Help = Help;
|