397 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			397 lines
		
	
	
		
			12 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;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * 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;
 |