| 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; |