time-to-botec

Benchmark sampling in different programming languages
Log | Files | Refs | README

help.js (14351B)


      1 const { humanReadableArgName } = require('./argument.js');
      2 
      3 /**
      4  * TypeScript import types for JSDoc, used by Visual Studio Code IntelliSense and `npm run typescript-checkJS`
      5  * https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#import-types
      6  * @typedef { import("./argument.js").Argument } Argument
      7  * @typedef { import("./command.js").Command } Command
      8  * @typedef { import("./option.js").Option } Option
      9  */
     10 
     11 // Although this is a class, methods are static in style to allow override using subclass or just functions.
     12 class Help {
     13   constructor() {
     14     this.helpWidth = undefined;
     15     this.sortSubcommands = false;
     16     this.sortOptions = false;
     17     this.showGlobalOptions = false;
     18   }
     19 
     20   /**
     21    * Get an array of the visible subcommands. Includes a placeholder for the implicit help command, if there is one.
     22    *
     23    * @param {Command} cmd
     24    * @returns {Command[]}
     25    */
     26 
     27   visibleCommands(cmd) {
     28     const visibleCommands = cmd.commands.filter(cmd => !cmd._hidden);
     29     if (cmd._hasImplicitHelpCommand()) {
     30       // Create a command matching the implicit help command.
     31       const [, helpName, helpArgs] = cmd._helpCommandnameAndArgs.match(/([^ ]+) *(.*)/);
     32       const helpCommand = cmd.createCommand(helpName)
     33         .helpOption(false);
     34       helpCommand.description(cmd._helpCommandDescription);
     35       if (helpArgs) helpCommand.arguments(helpArgs);
     36       visibleCommands.push(helpCommand);
     37     }
     38     if (this.sortSubcommands) {
     39       visibleCommands.sort((a, b) => {
     40         // @ts-ignore: overloaded return type
     41         return a.name().localeCompare(b.name());
     42       });
     43     }
     44     return visibleCommands;
     45   }
     46 
     47   /**
     48    * Compare options for sort.
     49    *
     50    * @param {Option} a
     51    * @param {Option} b
     52    * @returns number
     53    */
     54   compareOptions(a, b) {
     55     const getSortKey = (option) => {
     56       // WYSIWYG for order displayed in help. Short used for comparison if present. No special handling for negated.
     57       return option.short ? option.short.replace(/^-/, '') : option.long.replace(/^--/, '');
     58     };
     59     return getSortKey(a).localeCompare(getSortKey(b));
     60   }
     61 
     62   /**
     63    * Get an array of the visible options. Includes a placeholder for the implicit help option, if there is one.
     64    *
     65    * @param {Command} cmd
     66    * @returns {Option[]}
     67    */
     68 
     69   visibleOptions(cmd) {
     70     const visibleOptions = cmd.options.filter((option) => !option.hidden);
     71     // Implicit help
     72     const showShortHelpFlag = cmd._hasHelpOption && cmd._helpShortFlag && !cmd._findOption(cmd._helpShortFlag);
     73     const showLongHelpFlag = cmd._hasHelpOption && !cmd._findOption(cmd._helpLongFlag);
     74     if (showShortHelpFlag || showLongHelpFlag) {
     75       let helpOption;
     76       if (!showShortHelpFlag) {
     77         helpOption = cmd.createOption(cmd._helpLongFlag, cmd._helpDescription);
     78       } else if (!showLongHelpFlag) {
     79         helpOption = cmd.createOption(cmd._helpShortFlag, cmd._helpDescription);
     80       } else {
     81         helpOption = cmd.createOption(cmd._helpFlags, cmd._helpDescription);
     82       }
     83       visibleOptions.push(helpOption);
     84     }
     85     if (this.sortOptions) {
     86       visibleOptions.sort(this.compareOptions);
     87     }
     88     return visibleOptions;
     89   }
     90 
     91   /**
     92    * Get an array of the visible global options. (Not including help.)
     93    *
     94    * @param {Command} cmd
     95    * @returns {Option[]}
     96    */
     97 
     98   visibleGlobalOptions(cmd) {
     99     if (!this.showGlobalOptions) return [];
    100 
    101     const globalOptions = [];
    102     for (let ancestorCmd = cmd.parent; ancestorCmd; ancestorCmd = ancestorCmd.parent) {
    103       const visibleOptions = ancestorCmd.options.filter((option) => !option.hidden);
    104       globalOptions.push(...visibleOptions);
    105     }
    106     if (this.sortOptions) {
    107       globalOptions.sort(this.compareOptions);
    108     }
    109     return globalOptions;
    110   }
    111 
    112   /**
    113    * Get an array of the arguments if any have a description.
    114    *
    115    * @param {Command} cmd
    116    * @returns {Argument[]}
    117    */
    118 
    119   visibleArguments(cmd) {
    120     // Side effect! Apply the legacy descriptions before the arguments are displayed.
    121     if (cmd._argsDescription) {
    122       cmd.registeredArguments.forEach(argument => {
    123         argument.description = argument.description || cmd._argsDescription[argument.name()] || '';
    124       });
    125     }
    126 
    127     // If there are any arguments with a description then return all the arguments.
    128     if (cmd.registeredArguments.find(argument => argument.description)) {
    129       return cmd.registeredArguments;
    130     }
    131     return [];
    132   }
    133 
    134   /**
    135    * Get the command term to show in the list of subcommands.
    136    *
    137    * @param {Command} cmd
    138    * @returns {string}
    139    */
    140 
    141   subcommandTerm(cmd) {
    142     // Legacy. Ignores custom usage string, and nested commands.
    143     const args = cmd.registeredArguments.map(arg => humanReadableArgName(arg)).join(' ');
    144     return cmd._name +
    145       (cmd._aliases[0] ? '|' + cmd._aliases[0] : '') +
    146       (cmd.options.length ? ' [options]' : '') + // simplistic check for non-help option
    147       (args ? ' ' + args : '');
    148   }
    149 
    150   /**
    151    * Get the option term to show in the list of options.
    152    *
    153    * @param {Option} option
    154    * @returns {string}
    155    */
    156 
    157   optionTerm(option) {
    158     return option.flags;
    159   }
    160 
    161   /**
    162    * Get the argument term to show in the list of arguments.
    163    *
    164    * @param {Argument} argument
    165    * @returns {string}
    166    */
    167 
    168   argumentTerm(argument) {
    169     return argument.name();
    170   }
    171 
    172   /**
    173    * Get the longest command term length.
    174    *
    175    * @param {Command} cmd
    176    * @param {Help} helper
    177    * @returns {number}
    178    */
    179 
    180   longestSubcommandTermLength(cmd, helper) {
    181     return helper.visibleCommands(cmd).reduce((max, command) => {
    182       return Math.max(max, helper.subcommandTerm(command).length);
    183     }, 0);
    184   }
    185 
    186   /**
    187    * Get the longest option term length.
    188    *
    189    * @param {Command} cmd
    190    * @param {Help} helper
    191    * @returns {number}
    192    */
    193 
    194   longestOptionTermLength(cmd, helper) {
    195     return helper.visibleOptions(cmd).reduce((max, option) => {
    196       return Math.max(max, helper.optionTerm(option).length);
    197     }, 0);
    198   }
    199 
    200   /**
    201    * Get the longest global option term length.
    202    *
    203    * @param {Command} cmd
    204    * @param {Help} helper
    205    * @returns {number}
    206    */
    207 
    208   longestGlobalOptionTermLength(cmd, helper) {
    209     return helper.visibleGlobalOptions(cmd).reduce((max, option) => {
    210       return Math.max(max, helper.optionTerm(option).length);
    211     }, 0);
    212   }
    213 
    214   /**
    215    * Get the longest argument term length.
    216    *
    217    * @param {Command} cmd
    218    * @param {Help} helper
    219    * @returns {number}
    220    */
    221 
    222   longestArgumentTermLength(cmd, helper) {
    223     return helper.visibleArguments(cmd).reduce((max, argument) => {
    224       return Math.max(max, helper.argumentTerm(argument).length);
    225     }, 0);
    226   }
    227 
    228   /**
    229    * Get the command usage to be displayed at the top of the built-in help.
    230    *
    231    * @param {Command} cmd
    232    * @returns {string}
    233    */
    234 
    235   commandUsage(cmd) {
    236     // Usage
    237     let cmdName = cmd._name;
    238     if (cmd._aliases[0]) {
    239       cmdName = cmdName + '|' + cmd._aliases[0];
    240     }
    241     let ancestorCmdNames = '';
    242     for (let ancestorCmd = cmd.parent; ancestorCmd; ancestorCmd = ancestorCmd.parent) {
    243       ancestorCmdNames = ancestorCmd.name() + ' ' + ancestorCmdNames;
    244     }
    245     return ancestorCmdNames + cmdName + ' ' + cmd.usage();
    246   }
    247 
    248   /**
    249    * Get the description for the command.
    250    *
    251    * @param {Command} cmd
    252    * @returns {string}
    253    */
    254 
    255   commandDescription(cmd) {
    256     // @ts-ignore: overloaded return type
    257     return cmd.description();
    258   }
    259 
    260   /**
    261    * Get the subcommand summary to show in the list of subcommands.
    262    * (Fallback to description for backwards compatibility.)
    263    *
    264    * @param {Command} cmd
    265    * @returns {string}
    266    */
    267 
    268   subcommandDescription(cmd) {
    269     // @ts-ignore: overloaded return type
    270     return cmd.summary() || cmd.description();
    271   }
    272 
    273   /**
    274    * Get the option description to show in the list of options.
    275    *
    276    * @param {Option} option
    277    * @return {string}
    278    */
    279 
    280   optionDescription(option) {
    281     const extraInfo = [];
    282 
    283     if (option.argChoices) {
    284       extraInfo.push(
    285         // use stringify to match the display of the default value
    286         `choices: ${option.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`);
    287     }
    288     if (option.defaultValue !== undefined) {
    289       // default for boolean and negated more for programmer than end user,
    290       // but show true/false for boolean option as may be for hand-rolled env or config processing.
    291       const showDefault = option.required || option.optional ||
    292         (option.isBoolean() && typeof option.defaultValue === 'boolean');
    293       if (showDefault) {
    294         extraInfo.push(`default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`);
    295       }
    296     }
    297     // preset for boolean and negated are more for programmer than end user
    298     if (option.presetArg !== undefined && option.optional) {
    299       extraInfo.push(`preset: ${JSON.stringify(option.presetArg)}`);
    300     }
    301     if (option.envVar !== undefined) {
    302       extraInfo.push(`env: ${option.envVar}`);
    303     }
    304     if (extraInfo.length > 0) {
    305       return `${option.description} (${extraInfo.join(', ')})`;
    306     }
    307 
    308     return option.description;
    309   }
    310 
    311   /**
    312    * Get the argument description to show in the list of arguments.
    313    *
    314    * @param {Argument} argument
    315    * @return {string}
    316    */
    317 
    318   argumentDescription(argument) {
    319     const extraInfo = [];
    320     if (argument.argChoices) {
    321       extraInfo.push(
    322         // use stringify to match the display of the default value
    323         `choices: ${argument.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`);
    324     }
    325     if (argument.defaultValue !== undefined) {
    326       extraInfo.push(`default: ${argument.defaultValueDescription || JSON.stringify(argument.defaultValue)}`);
    327     }
    328     if (extraInfo.length > 0) {
    329       const extraDescripton = `(${extraInfo.join(', ')})`;
    330       if (argument.description) {
    331         return `${argument.description} ${extraDescripton}`;
    332       }
    333       return extraDescripton;
    334     }
    335     return argument.description;
    336   }
    337 
    338   /**
    339    * Generate the built-in help text.
    340    *
    341    * @param {Command} cmd
    342    * @param {Help} helper
    343    * @returns {string}
    344    */
    345 
    346   formatHelp(cmd, helper) {
    347     const termWidth = helper.padWidth(cmd, helper);
    348     const helpWidth = helper.helpWidth || 80;
    349     const itemIndentWidth = 2;
    350     const itemSeparatorWidth = 2; // between term and description
    351     function formatItem(term, description) {
    352       if (description) {
    353         const fullText = `${term.padEnd(termWidth + itemSeparatorWidth)}${description}`;
    354         return helper.wrap(fullText, helpWidth - itemIndentWidth, termWidth + itemSeparatorWidth);
    355       }
    356       return term;
    357     }
    358     function formatList(textArray) {
    359       return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth));
    360     }
    361 
    362     // Usage
    363     let output = [`Usage: ${helper.commandUsage(cmd)}`, ''];
    364 
    365     // Description
    366     const commandDescription = helper.commandDescription(cmd);
    367     if (commandDescription.length > 0) {
    368       output = output.concat([helper.wrap(commandDescription, helpWidth, 0), '']);
    369     }
    370 
    371     // Arguments
    372     const argumentList = helper.visibleArguments(cmd).map((argument) => {
    373       return formatItem(helper.argumentTerm(argument), helper.argumentDescription(argument));
    374     });
    375     if (argumentList.length > 0) {
    376       output = output.concat(['Arguments:', formatList(argumentList), '']);
    377     }
    378 
    379     // Options
    380     const optionList = helper.visibleOptions(cmd).map((option) => {
    381       return formatItem(helper.optionTerm(option), helper.optionDescription(option));
    382     });
    383     if (optionList.length > 0) {
    384       output = output.concat(['Options:', formatList(optionList), '']);
    385     }
    386 
    387     if (this.showGlobalOptions) {
    388       const globalOptionList = helper.visibleGlobalOptions(cmd).map((option) => {
    389         return formatItem(helper.optionTerm(option), helper.optionDescription(option));
    390       });
    391       if (globalOptionList.length > 0) {
    392         output = output.concat(['Global Options:', formatList(globalOptionList), '']);
    393       }
    394     }
    395 
    396     // Commands
    397     const commandList = helper.visibleCommands(cmd).map((cmd) => {
    398       return formatItem(helper.subcommandTerm(cmd), helper.subcommandDescription(cmd));
    399     });
    400     if (commandList.length > 0) {
    401       output = output.concat(['Commands:', formatList(commandList), '']);
    402     }
    403 
    404     return output.join('\n');
    405   }
    406 
    407   /**
    408    * Calculate the pad width from the maximum term length.
    409    *
    410    * @param {Command} cmd
    411    * @param {Help} helper
    412    * @returns {number}
    413    */
    414 
    415   padWidth(cmd, helper) {
    416     return Math.max(
    417       helper.longestOptionTermLength(cmd, helper),
    418       helper.longestGlobalOptionTermLength(cmd, helper),
    419       helper.longestSubcommandTermLength(cmd, helper),
    420       helper.longestArgumentTermLength(cmd, helper)
    421     );
    422   }
    423 
    424   /**
    425    * Wrap the given string to width characters per line, with lines after the first indented.
    426    * Do not wrap if insufficient room for wrapping (minColumnWidth), or string is manually formatted.
    427    *
    428    * @param {string} str
    429    * @param {number} width
    430    * @param {number} indent
    431    * @param {number} [minColumnWidth=40]
    432    * @return {string}
    433    *
    434    */
    435 
    436   wrap(str, width, indent, minColumnWidth = 40) {
    437     // Full \s characters, minus the linefeeds.
    438     const indents = ' \\f\\t\\v\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000\ufeff';
    439     // Detect manually wrapped and indented strings by searching for line break followed by spaces.
    440     const manualIndent = new RegExp(`[\\n][${indents}]+`);
    441     if (str.match(manualIndent)) return str;
    442     // Do not wrap if not enough room for a wrapped column of text (as could end up with a word per line).
    443     const columnWidth = width - indent;
    444     if (columnWidth < minColumnWidth) return str;
    445 
    446     const leadingStr = str.slice(0, indent);
    447     const columnText = str.slice(indent).replace('\r\n', '\n');
    448     const indentString = ' '.repeat(indent);
    449     const zeroWidthSpace = '\u200B';
    450     const breaks = `\\s${zeroWidthSpace}`;
    451     // Match line end (so empty lines don't collapse),
    452     // or as much text as will fit in column, or excess text up to first break.
    453     const regex = new RegExp(`\n|.{1,${columnWidth - 1}}([${breaks}]|$)|[^${breaks}]+?([${breaks}]|$)`, 'g');
    454     const lines = columnText.match(regex) || [];
    455     return leadingStr + lines.map((line, i) => {
    456       if (line === '\n') return ''; // preserve empty lines
    457       return ((i > 0) ? indentString : '') + line.trimEnd();
    458     }).join('\n');
    459   }
    460 }
    461 
    462 exports.Help = Help;