Spaces:
Sleeping
Sleeping
| 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 | |
| */ | |
| // 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); | |
| const helpCommand = cmd._getHelpCommand(); | |
| if (helpCommand && !helpCommand._hidden) { | |
| visibleCommands.push(helpCommand); | |
| } | |
| if (this.sortSubcommands) { | |
| visibleCommands.sort((a, b) => { | |
| // @ts-ignore: because 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); | |
| // Built-in help option. | |
| const helpOption = cmd._getHelpOption(); | |
| if (helpOption && !helpOption.hidden) { | |
| // Automatically hide conflicting flags. Bit dubious but a historical behaviour that is convenient for single-command programs. | |
| const removeShort = helpOption.short && cmd._findOption(helpOption.short); | |
| const removeLong = helpOption.long && cmd._findOption(helpOption.long); | |
| if (!removeShort && !removeLong) { | |
| visibleOptions.push(helpOption); // no changes needed | |
| } else if (helpOption.long && !removeLong) { | |
| visibleOptions.push( | |
| cmd.createOption(helpOption.long, helpOption.description), | |
| ); | |
| } else if (helpOption.short && !removeShort) { | |
| visibleOptions.push( | |
| cmd.createOption(helpOption.short, helpOption.description), | |
| ); | |
| } | |
| } | |
| 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 ancestorCmd = cmd.parent; | |
| ancestorCmd; | |
| ancestorCmd = ancestorCmd.parent | |
| ) { | |
| const visibleOptions = ancestorCmd.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.registeredArguments.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.registeredArguments.find((argument) => argument.description)) { | |
| return cmd.registeredArguments; | |
| } | |
| 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.registeredArguments | |
| .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 ancestorCmdNames = ''; | |
| for ( | |
| let ancestorCmd = cmd.parent; | |
| ancestorCmd; | |
| ancestorCmd = ancestorCmd.parent | |
| ) { | |
| ancestorCmdNames = ancestorCmd.name() + ' ' + ancestorCmdNames; | |
| } | |
| return ancestorCmdNames + cmdName + ' ' + cmd.usage(); | |
| } | |
| /** | |
| * Get the description for the command. | |
| * | |
| * @param {Command} cmd | |
| * @returns {string} | |
| */ | |
| commandDescription(cmd) { | |
| // @ts-ignore: because 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: because 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([ | |
| helper.wrap(commandDescription, helpWidth, 0), | |
| '', | |
| ]); | |
| } | |
| // 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) { | |
| // Full \s characters, minus the linefeeds. | |
| const indents = | |
| ' \\f\\t\\v\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000\ufeff'; | |
| // Detect manually wrapped and indented strings by searching for line break followed by spaces. | |
| const manualIndent = new RegExp(`[\\n][${indents}]+`); | |
| if (str.match(manualIndent)) 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).replace('\r\n', '\n'); | |
| const indentString = ' '.repeat(indent); | |
| const zeroWidthSpace = '\u200B'; | |
| const breaks = `\\s${zeroWidthSpace}`; | |
| // Match line end (so empty lines don't collapse), | |
| // or as much text as will fit in column, or excess text up to first break. | |
| const regex = new RegExp( | |
| `\n|.{1,${columnWidth - 1}}([${breaks}]|$)|[^${breaks}]+?([${breaks}]|$)`, | |
| 'g', | |
| ); | |
| const lines = columnText.match(regex) || []; | |
| return ( | |
| leadingStr + | |
| lines | |
| .map((line, i) => { | |
| if (line === '\n') return ''; // preserve empty lines | |
| return (i > 0 ? indentString : '') + line.trimEnd(); | |
| }) | |
| .join('\n') | |
| ); | |
| } | |
| } | |
| exports.Help = Help; | |