123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709 |
- 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.minWidthToWrap = 40;
- this.sortSubcommands = false;
- this.sortOptions = false;
- this.showGlobalOptions = false;
- }
- /**
- * prepareContext is called by Commander after applying overrides from `Command.configureHelp()`
- * and just before calling `formatHelp()`.
- *
- * Commander just uses the helpWidth and the rest is provided for optional use by more complex subclasses.
- *
- * @param {{ error?: boolean, helpWidth?: number, outputHasColors?: boolean }} contextOptions
- */
- prepareContext(contextOptions) {
- this.helpWidth = this.helpWidth ?? contextOptions.helpWidth ?? 80;
- }
- /**
- * 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,
- this.displayWidth(
- helper.styleSubcommandTerm(helper.subcommandTerm(command)),
- ),
- );
- }, 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,
- this.displayWidth(helper.styleOptionTerm(helper.optionTerm(option))),
- );
- }, 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,
- this.displayWidth(helper.styleOptionTerm(helper.optionTerm(option))),
- );
- }, 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,
- this.displayWidth(
- helper.styleArgumentTerm(helper.argumentTerm(argument)),
- ),
- );
- }, 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 extraDescription = `(${extraInfo.join(', ')})`;
- if (argument.description) {
- return `${argument.description} ${extraDescription}`;
- }
- return extraDescription;
- }
- 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; // in case prepareContext() was not called
- function callFormatItem(term, description) {
- return helper.formatItem(term, termWidth, description, helper);
- }
- // Usage
- let output = [
- `${helper.styleTitle('Usage:')} ${helper.styleUsage(helper.commandUsage(cmd))}`,
- '',
- ];
- // Description
- const commandDescription = helper.commandDescription(cmd);
- if (commandDescription.length > 0) {
- output = output.concat([
- helper.boxWrap(
- helper.styleCommandDescription(commandDescription),
- helpWidth,
- ),
- '',
- ]);
- }
- // Arguments
- const argumentList = helper.visibleArguments(cmd).map((argument) => {
- return callFormatItem(
- helper.styleArgumentTerm(helper.argumentTerm(argument)),
- helper.styleArgumentDescription(helper.argumentDescription(argument)),
- );
- });
- if (argumentList.length > 0) {
- output = output.concat([
- helper.styleTitle('Arguments:'),
- ...argumentList,
- '',
- ]);
- }
- // Options
- const optionList = helper.visibleOptions(cmd).map((option) => {
- return callFormatItem(
- helper.styleOptionTerm(helper.optionTerm(option)),
- helper.styleOptionDescription(helper.optionDescription(option)),
- );
- });
- if (optionList.length > 0) {
- output = output.concat([
- helper.styleTitle('Options:'),
- ...optionList,
- '',
- ]);
- }
- if (helper.showGlobalOptions) {
- const globalOptionList = helper
- .visibleGlobalOptions(cmd)
- .map((option) => {
- return callFormatItem(
- helper.styleOptionTerm(helper.optionTerm(option)),
- helper.styleOptionDescription(helper.optionDescription(option)),
- );
- });
- if (globalOptionList.length > 0) {
- output = output.concat([
- helper.styleTitle('Global Options:'),
- ...globalOptionList,
- '',
- ]);
- }
- }
- // Commands
- const commandList = helper.visibleCommands(cmd).map((cmd) => {
- return callFormatItem(
- helper.styleSubcommandTerm(helper.subcommandTerm(cmd)),
- helper.styleSubcommandDescription(helper.subcommandDescription(cmd)),
- );
- });
- if (commandList.length > 0) {
- output = output.concat([
- helper.styleTitle('Commands:'),
- ...commandList,
- '',
- ]);
- }
- return output.join('\n');
- }
- /**
- * Return display width of string, ignoring ANSI escape sequences. Used in padding and wrapping calculations.
- *
- * @param {string} str
- * @returns {number}
- */
- displayWidth(str) {
- return stripColor(str).length;
- }
- /**
- * Style the title for displaying in the help. Called with 'Usage:', 'Options:', etc.
- *
- * @param {string} str
- * @returns {string}
- */
- styleTitle(str) {
- return str;
- }
- styleUsage(str) {
- // Usage has lots of parts the user might like to color separately! Assume default usage string which is formed like:
- // command subcommand [options] [command] <foo> [bar]
- return str
- .split(' ')
- .map((word) => {
- if (word === '[options]') return this.styleOptionText(word);
- if (word === '[command]') return this.styleSubcommandText(word);
- if (word[0] === '[' || word[0] === '<')
- return this.styleArgumentText(word);
- return this.styleCommandText(word); // Restrict to initial words?
- })
- .join(' ');
- }
- styleCommandDescription(str) {
- return this.styleDescriptionText(str);
- }
- styleOptionDescription(str) {
- return this.styleDescriptionText(str);
- }
- styleSubcommandDescription(str) {
- return this.styleDescriptionText(str);
- }
- styleArgumentDescription(str) {
- return this.styleDescriptionText(str);
- }
- styleDescriptionText(str) {
- return str;
- }
- styleOptionTerm(str) {
- return this.styleOptionText(str);
- }
- styleSubcommandTerm(str) {
- // This is very like usage with lots of parts! Assume default string which is formed like:
- // subcommand [options] <foo> [bar]
- return str
- .split(' ')
- .map((word) => {
- if (word === '[options]') return this.styleOptionText(word);
- if (word[0] === '[' || word[0] === '<')
- return this.styleArgumentText(word);
- return this.styleSubcommandText(word); // Restrict to initial words?
- })
- .join(' ');
- }
- styleArgumentTerm(str) {
- return this.styleArgumentText(str);
- }
- styleOptionText(str) {
- return str;
- }
- styleArgumentText(str) {
- return str;
- }
- styleSubcommandText(str) {
- return str;
- }
- styleCommandText(str) {
- return str;
- }
- /**
- * 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),
- );
- }
- /**
- * Detect manually wrapped and indented strings by checking for line break followed by whitespace.
- *
- * @param {string} str
- * @returns {boolean}
- */
- preformatted(str) {
- return /\n[^\S\r\n]/.test(str);
- }
- /**
- * Format the "item", which consists of a term and description. Pad the term and wrap the description, indenting the following lines.
- *
- * So "TTT", 5, "DDD DDDD DD DDD" might be formatted for this.helpWidth=17 like so:
- * TTT DDD DDDD
- * DD DDD
- *
- * @param {string} term
- * @param {number} termWidth
- * @param {string} description
- * @param {Help} helper
- * @returns {string}
- */
- formatItem(term, termWidth, description, helper) {
- const itemIndent = 2;
- const itemIndentStr = ' '.repeat(itemIndent);
- if (!description) return itemIndentStr + term;
- // Pad the term out to a consistent width, so descriptions are aligned.
- const paddedTerm = term.padEnd(
- termWidth + term.length - helper.displayWidth(term),
- );
- // Format the description.
- const spacerWidth = 2; // between term and description
- const helpWidth = this.helpWidth ?? 80; // in case prepareContext() was not called
- const remainingWidth = helpWidth - termWidth - spacerWidth - itemIndent;
- let formattedDescription;
- if (
- remainingWidth < this.minWidthToWrap ||
- helper.preformatted(description)
- ) {
- formattedDescription = description;
- } else {
- const wrappedDescription = helper.boxWrap(description, remainingWidth);
- formattedDescription = wrappedDescription.replace(
- /\n/g,
- '\n' + ' '.repeat(termWidth + spacerWidth),
- );
- }
- // Construct and overall indent.
- return (
- itemIndentStr +
- paddedTerm +
- ' '.repeat(spacerWidth) +
- formattedDescription.replace(/\n/g, `\n${itemIndentStr}`)
- );
- }
- /**
- * Wrap a string at whitespace, preserving existing line breaks.
- * Wrapping is skipped if the width is less than `minWidthToWrap`.
- *
- * @param {string} str
- * @param {number} width
- * @returns {string}
- */
- boxWrap(str, width) {
- if (width < this.minWidthToWrap) return str;
- const rawLines = str.split(/\r\n|\n/);
- // split up text by whitespace
- const chunkPattern = /[\s]*[^\s]+/g;
- const wrappedLines = [];
- rawLines.forEach((line) => {
- const chunks = line.match(chunkPattern);
- if (chunks === null) {
- wrappedLines.push('');
- return;
- }
- let sumChunks = [chunks.shift()];
- let sumWidth = this.displayWidth(sumChunks[0]);
- chunks.forEach((chunk) => {
- const visibleWidth = this.displayWidth(chunk);
- // Accumulate chunks while they fit into width.
- if (sumWidth + visibleWidth <= width) {
- sumChunks.push(chunk);
- sumWidth += visibleWidth;
- return;
- }
- wrappedLines.push(sumChunks.join(''));
- const nextChunk = chunk.trimStart(); // trim space at line break
- sumChunks = [nextChunk];
- sumWidth = this.displayWidth(nextChunk);
- });
- wrappedLines.push(sumChunks.join(''));
- });
- return wrappedLines.join('\n');
- }
- }
- /**
- * Strip style ANSI escape sequences from the string. In particular, SGR (Select Graphic Rendition) codes.
- *
- * @param {string} str
- * @returns {string}
- * @package
- */
- function stripColor(str) {
- // eslint-disable-next-line no-control-regex
- const sgrPattern = /\x1b\[\d*(;\d*)*m/g;
- return str.replace(sgrPattern, '');
- }
- exports.Help = Help;
- exports.stripColor = stripColor;
|