|
|
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; }
/** * 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; }
/** * 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) { const getSortKey = (option) => { // WYSIWYG for order displayed in help with short before long, no special handling for negated.
return option.short ? option.short.replace(/^-/, '') : option.long.replace(/^--/, ''); }; visibleOptions.sort((a, b) => { return getSortKey(a).localeCompare(getSortKey(b)); }); } return visibleOptions; }
/** * 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 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 command description to show in the list of subcommands. * * @param {Command} cmd * @returns {string} */
subcommandDescription(cmd) { // @ts-ignore: overloaded return type
return cmd.description(); }
/** * Get the option description to show in the list of options. * * @param {Option} option * @return {string} */
optionDescription(option) { const extraInfo = []; // Some of these do not make sense for negated boolean and suppress for backwards compatibility.
if (option.argChoices && !option.negate) { 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 && !option.negate) { extraInfo.push(`default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`); } 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), '']); }
// 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.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.substr(0, indent); const columnText = str.substr(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;
|