diff --git a/Readme.md b/Readme.md index 5b7045be9..4cc70c207 100644 --- a/Readme.md +++ b/Readme.md @@ -926,6 +926,7 @@ You can configure the Help behaviour by modifying data properties and methods us The data properties are: - `helpWidth`: specify the wrap width, useful for unit tests +- `minWidthToWrap`: specify required width to allow wrapping (default 40) - `sortSubcommands`: sort the subcommands alphabetically - `sortOptions`: sort the options alphabetically - `showGlobalOptions`: show a section with the global options from the parent command(s) @@ -941,6 +942,17 @@ program.configureHelp({ }); ``` +There are _style_ methods to add color to the help, like `styleTitle` and `styleOptionText`. There is built-in support for respecting +environment variables for `NO_COLOR`, `FORCE_COLOR`, and `CLIFORCE_COLOR`. + +Example file: [color-help.mjs](./examples/color-help.mjs) + +Other help configuration examples: +- [color-help-replacement.mjs](./examples/color-help-replacement.mjs) +- [help-centered.mjs](./examples/help-centered.mjs) +- [help-subcommands-usage.js](./examples/help-subcommands-usage.js) +- [man-style-help.mjs](./examples/man-style-help.mjs) + ## Custom event listeners You can execute custom actions by listening to command and option events. diff --git a/examples/color-help-replacement.mjs b/examples/color-help-replacement.mjs new file mode 100644 index 000000000..9a1c5f709 --- /dev/null +++ b/examples/color-help-replacement.mjs @@ -0,0 +1,113 @@ +import stripAnsi from 'strip-ansi'; +import wrapAnsi from 'wrap-ansi'; +import { + default as chalkStdOut, + chalkStderr as chalkStdErr, + supportsColor as supportsColorStdout, + supportsColorStderr, +} from 'chalk'; +import { Command, Help } from 'commander'; + +// Replace default color and wrapping support with Chalk packages as an example of +// a deep replacement of format and style support. + +// This example requires chalk and wrap-ansi and strip-ansi, and won't run +// from a clone of Commander repo without installing them first. +// +// For example using npm: +// npm install chalk wrap-ansi strip-ansi + +class MyHelp extends Help { + constructor() { + super(); + this.chalk = chalkStdOut; + } + + prepareContext(contextOptions) { + super.prepareContext(contextOptions); + if (contextOptions?.error) { + this.chalk = chalkStdErr; + } + } + + displayWidth(str) { + return stripAnsi(str).length; // use imported package + } + + boxWrap(str, width) { + return wrapAnsi(str, width, { hard: true }); // use imported package + } + + styleTitle(str) { + return this.chalk.bold(str); + } + styleCommandText(str) { + return this.chalk.cyan(str); + } + styleCommandDescription(str) { + return this.chalk.magenta(str); + } + styleItemDescription(str) { + return this.chalk.italic(str); + } + styleOptionText(str) { + return this.chalk.green(str); + } + styleArgumentText(str) { + return this.chalk.yellow(str); + } + styleSubcommandText(str) { + return this.chalk.blue(str); + } +} + +class MyCommand extends Command { + createCommand(name) { + return new MyCommand(name); + } + createHelp() { + return Object.assign(new MyHelp(), this.configureHelp()); + } +} + +const program = new MyCommand(); + +// Override the color detection to use Chalk's detection. +// Chalk overrides color support based on the `FORCE_COLOR` environment variable, +// and looks for --color and --no-color command-line options. +// See https://github.com/chalk/chalk?tab=readme-ov-file#supportscolor +// +// In general we want stripColor() to be consistent with displayWidth(). +program.configureOutput({ + getOutHasColors: () => supportsColorStdout, + getErrHasColors: () => supportsColorStderr, + stripColor: (str) => stripAnsi(str), +}); + +program.description('program description '.repeat(10)); +program + .option('-s', 'short description') + .option('--long ', 'long description '.repeat(10)) + .option('--color', 'force color output') // implemented by chalk + .option('--no-color', 'disable color output'); // implemented by chalk + +program.addHelpText('after', (context) => { + const chalk = context.error ? chalkStdErr : chalkStdOut; + return chalk.italic('\nThis is additional help text.'); +}); + +program.command('esses').description('sssss '.repeat(33)); + +program + .command('print') + .description('print files') + .argument('', 'files to queue for printing') + .option('--double-sided', 'print on both sides'); + +program.parse(); + +// Try the following (after installing the required packages): +// node color-help-replacement.mjs --help +// node color-help-replacement.mjs --no-color help +// FORCE_COLOR=0 node color-help-replacement.mjs help +// node color-help-replacement.mjs help print diff --git a/examples/color-help.mjs b/examples/color-help.mjs new file mode 100644 index 000000000..fac6c2265 --- /dev/null +++ b/examples/color-help.mjs @@ -0,0 +1,41 @@ +import { styleText } from 'node:util'; // from node v20.12.0 +import { Command } from 'commander'; + +// Customise colours and styles for help output. + +const program = new Command(); + +program.configureHelp({ + styleTitle: (str) => styleText('bold', str), + styleCommandText: (str) => styleText('cyan', str), + styleCommandDescription: (str) => styleText('magenta', str), + styleItemDescription: (str) => styleText('italic', str), + styleOptionText: (str) => styleText('green', str), + styleArgumentText: (str) => styleText('yellow', str), + styleSubcommandText: (str) => styleText('blue', str), +}); + +program.description('program description '.repeat(10)); +program + .option('-s', 'short description') + .option('--long ', 'long description '.repeat(10)); + +program.addHelpText( + 'after', + styleText('italic', '\nThis is additional help text.'), +); + +program.command('esses').description('sssss '.repeat(33)); + +program + .command('print') + .description('print files') + .argument('', 'files to queue for printing') + .option('--double-sided', 'print on both sides'); + +program.parse(); + +// Try the following: +// node color-help.mjs --help +// NO_COLOR=1 node color-help.mjs --help +// node color-help.mjs help print diff --git a/examples/help-centered.mjs b/examples/help-centered.mjs new file mode 100644 index 000000000..206a23cdd --- /dev/null +++ b/examples/help-centered.mjs @@ -0,0 +1,42 @@ +import { Command, Help } from 'commander'; + +// Right-justify the terms in the help output. +// Setup a subclass so we can do simple tweak of formatItem. + +class MyHelp extends Help { + formatItem(term, termWidth, description, helper) { + // Pre-pad the term at start instead of end. + const paddedTerm = term.padStart( + termWidth + term.length - helper.displayWidth(term), + ); + + return super.formatItem(paddedTerm, termWidth, description, helper); + } +} + +class MyCommand extends Command { + createCommand(name) { + return new MyCommand(name); + } + createHelp() { + return Object.assign(new MyHelp(), this.configureHelp()); + } +} + +const program = new MyCommand(); + +program.configureHelp({ MyCommand }); + +program + .option('-s', 'short flag') + .option('-f, --flag', 'short and long flag') + .option('--long ', 'long flag'); + +program.command('compile').alias('c').description('compile something'); + +program.command('run', 'run something').command('print', 'print something'); + +program.parse(); + +// Try the following: +// node help-centered.mjs --help diff --git a/examples/man-style-help.mjs b/examples/man-style-help.mjs new file mode 100644 index 000000000..b0fe7dcf9 --- /dev/null +++ b/examples/man-style-help.mjs @@ -0,0 +1,41 @@ +import { Command } from 'commander'; + +// Layout the help like a man page, with the description starting on the next line. + +function formatItem(term, termWidth, description, helper) { + const termIndent = 2; + const descIndent = 6; + const helpWidth = this.helpWidth || 80; + + // No need to pad term as on its own line. + const lines = [' '.repeat(termIndent) + term]; + + if (description) { + const boxText = helper.boxWrap(description, helpWidth - 6); + const descIndentText = ' '.repeat(descIndent); + lines.push( + descIndentText + boxText.split('\n').join('\n' + descIndentText), + ); + } + + lines.push(''); + return lines.join('\n'); +} + +const program = new Command(); + +program.configureHelp({ formatItem }); + +program + .option('-s', 'short flag') + .option('-f, --flag', 'short and long flag') + .option('--long ', 'l '.repeat(100)); + +program + .command('sub1', 'sssss '.repeat(33)) + .command('sub2', 'subcommand 2 description'); + +program.parse(); + +// Try the following: +// node man-style-help.mjs --help diff --git a/lib/command.js b/lib/command.js index fc55b7d01..cfcbfc5f2 100644 --- a/lib/command.js +++ b/lib/command.js @@ -6,7 +6,7 @@ const process = require('node:process'); const { Argument, humanReadableArgName } = require('./argument.js'); const { CommanderError } = require('./error.js'); -const { Help } = require('./help.js'); +const { Help, stripColor } = require('./help.js'); const { Option, DualOptions } = require('./option.js'); const { suggestSimilar } = require('./suggestSimilar'); @@ -56,15 +56,20 @@ class Command extends EventEmitter { this._showHelpAfterError = false; this._showSuggestionAfterError = true; - // see .configureOutput() for docs + // see configureOutput() for docs this._outputConfiguration = { writeOut: (str) => process.stdout.write(str), writeErr: (str) => process.stderr.write(str), + outputError: (str, write) => write(str), getOutHelpWidth: () => process.stdout.isTTY ? process.stdout.columns : undefined, getErrHelpWidth: () => process.stderr.isTTY ? process.stderr.columns : undefined, - outputError: (str, write) => write(str), + getOutHasColors: () => + useColor() ?? (process.stdout.isTTY && process.stdout.hasColors?.()), + getErrHasColors: () => + useColor() ?? (process.stderr.isTTY && process.stderr.hasColors?.()), + stripColor: (str) => stripColor(str), }; this._hidden = false; @@ -213,14 +218,18 @@ class Command extends EventEmitter { * * The configuration properties are all functions: * - * // functions to change where being written, stdout and stderr + * // change how output being written, defaults to stdout and stderr * writeOut(str) * writeErr(str) - * // matching functions to specify width for wrapping help + * // change how output being written for errors, defaults to writeErr + * outputError(str, write) // used for displaying errors and not used for displaying help + * // specify width for wrapping help * getOutHelpWidth() * getErrHelpWidth() - * // functions based on what is being written out - * outputError(str, write) // used for displaying errors, and not used for displaying help + * // color support, currently only used with Help + * getOutHasColors() + * getErrHasColors() + * stripColor() // used to remove ANSI escape codes if output does not have colours * * @param {object} [configuration] - configuration options * @return {(Command | object)} `this` command for chaining, or stored configuration @@ -2254,31 +2263,49 @@ Expecting one of '${allowedValues.join("', '")}'`); helpInformation(contextOptions) { const helper = this.createHelp(); - if (helper.helpWidth === undefined) { - helper.helpWidth = - contextOptions && contextOptions.error - ? this._outputConfiguration.getErrHelpWidth() - : this._outputConfiguration.getOutHelpWidth(); - } - return helper.formatHelp(this, helper); + const context = this._getOutputContext(contextOptions); + helper.prepareContext({ + error: context.error, + helpWidth: context.helpWidth, + outputHasColors: context.hasColors, + }); + const text = helper.formatHelp(this, helper); + if (context.hasColors) return text; + return this._outputConfiguration.stripColor(text); } /** + * @typedef HelpContext + * @type {object} + * @property {boolean} error + * @property {number} helpWidth + * @property {boolean} hasColors + * @property {function} write - includes stripColor if needed + * + * @returns {HelpContext} * @private */ - _getHelpContext(contextOptions) { + _getOutputContext(contextOptions) { contextOptions = contextOptions || {}; - const context = { error: !!contextOptions.error }; - let write; - if (context.error) { - write = (arg) => this._outputConfiguration.writeErr(arg); + const error = !!contextOptions.error; + let baseWrite; + let hasColors; + let helpWidth; + if (error) { + baseWrite = (str) => this._outputConfiguration.writeErr(str); + hasColors = this._outputConfiguration.getErrHasColors(); + helpWidth = this._outputConfiguration.getErrHelpWidth(); } else { - write = (arg) => this._outputConfiguration.writeOut(arg); + baseWrite = (str) => this._outputConfiguration.writeOut(str); + hasColors = this._outputConfiguration.getOutHasColors(); + helpWidth = this._outputConfiguration.getOutHelpWidth(); } - context.write = contextOptions.write || write; - context.command = this; - return context; + const write = (str) => { + if (!hasColors) str = this._outputConfiguration.stripColor(str); + return baseWrite(str); + }; + return { error, write, hasColors, helpWidth }; } /** @@ -2295,14 +2322,21 @@ Expecting one of '${allowedValues.join("', '")}'`); deprecatedCallback = contextOptions; contextOptions = undefined; } - const context = this._getHelpContext(contextOptions); + + const outputContext = this._getOutputContext(contextOptions); + /** @type {HelpTextEventContext} */ + const eventContext = { + error: outputContext.error, + write: outputContext.write, + command: this, + }; this._getCommandAndAncestors() .reverse() - .forEach((command) => command.emit('beforeAllHelp', context)); - this.emit('beforeHelp', context); + .forEach((command) => command.emit('beforeAllHelp', eventContext)); + this.emit('beforeHelp', eventContext); - let helpInformation = this.helpInformation(context); + let helpInformation = this.helpInformation({ error: outputContext.error }); if (deprecatedCallback) { helpInformation = deprecatedCallback(helpInformation); if ( @@ -2312,14 +2346,14 @@ Expecting one of '${allowedValues.join("', '")}'`); throw new Error('outputHelp callback must return a string or a Buffer'); } } - context.write(helpInformation); + outputContext.write(helpInformation); if (this._getHelpOption()?.long) { this.emit(this._getHelpOption().long); // deprecated } - this.emit('afterHelp', context); + this.emit('afterHelp', eventContext); this._getCommandAndAncestors().forEach((command) => - command.emit('afterAllHelp', context), + command.emit('afterAllHelp', eventContext), ); } @@ -2339,6 +2373,8 @@ Expecting one of '${allowedValues.join("', '")}'`); helpOption(flags, description) { // Support disabling built-in help option. if (typeof flags === 'boolean') { + // true is not an expected value. Do something sensible but no unit-test. + // istanbul ignore if if (flags) { this._helpOption = this._helpOption ?? undefined; // preserve existing option } else { @@ -2392,7 +2428,7 @@ Expecting one of '${allowedValues.join("', '")}'`); help(contextOptions) { this.outputHelp(contextOptions); - let exitCode = Number(process.exitCode ?? 0); // process.exitCode does allow a string of an integer, but we prefer just a number + let exitCode = Number(process.exitCode ?? 0); // process.exitCode does allow a string or an integer, but we prefer just a number if ( exitCode === 0 && contextOptions && @@ -2405,6 +2441,15 @@ Expecting one of '${allowedValues.join("', '")}'`); this._exit(exitCode, 'commander.help', '(outputHelp)'); } + /** + * // Do a little typing to coordinate emit and listener for the help text events. + * @typedef HelpTextEventContext + * @type {object} + * @property {boolean} error + * @property {Command} command + * @property {function} write + */ + /** * Add additional text to be displayed with the built-in help. * @@ -2415,14 +2460,16 @@ Expecting one of '${allowedValues.join("', '")}'`); * @param {(string | Function)} text - string to add, or a function returning a string * @return {Command} `this` command for chaining */ + addHelpText(position, text) { const allowedValues = ['beforeAll', 'before', 'after', 'afterAll']; if (!allowedValues.includes(position)) { throw new Error(`Unexpected value for position to addHelpText. Expecting one of '${allowedValues.join("', '")}'`); } + const helpEvent = `${position}Help`; - this.on(helpEvent, (context) => { + this.on(helpEvent, (/** @type {HelpTextEventContext} */ context) => { let helpStr; if (typeof text === 'function') { helpStr = text({ error: context.error, command: context.command }); @@ -2506,4 +2553,33 @@ function incrementNodeInspectorPort(args) { }); } +/** + * @returns {boolean | undefined} + * @package + */ +function useColor() { + // Test for common conventions. + // NB: the observed behaviour is in combination with how author adds color! For example: + // - we do not test NODE_DISABLE_COLORS, but util:styletext does + // - we do test NO_COLOR, but Chalk does not + // + // References: + // https://no-color.org + // https://bixense.com/clicolors/ + // https://github.com/nodejs/node/blob/0a00217a5f67ef4a22384cfc80eb6dd9a917fdc1/lib/internal/tty.js#L109 + // https://github.com/chalk/supports-color/blob/c214314a14bcb174b12b3014b2b0a8de375029ae/index.js#L33 + // (https://force-color.org recent web page from 2023, does not match major javascript implementations) + + if ( + process.env.NO_COLOR || + process.env.FORCE_COLOR === '0' || + process.env.FORCE_COLOR === 'false' + ) + return false; + if (process.env.FORCE_COLOR || process.env.CLICOLOR_FORCE !== undefined) + return true; + return undefined; +} + exports.Command = Command; +exports.useColor = useColor; // exporting for tests diff --git a/lib/help.js b/lib/help.js index 750b23ad9..8813e9ee0 100644 --- a/lib/help.js +++ b/lib/help.js @@ -12,11 +12,24 @@ const { humanReadableArgName } = require('./argument.js'); 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. * @@ -191,7 +204,12 @@ class Help { longestSubcommandTermLength(cmd, helper) { return helper.visibleCommands(cmd).reduce((max, command) => { - return Math.max(max, helper.subcommandTerm(command).length); + return Math.max( + max, + this.displayWidth( + helper.styleSubcommandTerm(helper.subcommandTerm(command)), + ), + ); }, 0); } @@ -205,7 +223,10 @@ class Help { longestOptionTermLength(cmd, helper) { return helper.visibleOptions(cmd).reduce((max, option) => { - return Math.max(max, helper.optionTerm(option).length); + return Math.max( + max, + this.displayWidth(helper.styleOptionTerm(helper.optionTerm(option))), + ); }, 0); } @@ -219,7 +240,10 @@ class Help { longestGlobalOptionTermLength(cmd, helper) { return helper.visibleGlobalOptions(cmd).reduce((max, option) => { - return Math.max(max, helper.optionTerm(option).length); + return Math.max( + max, + this.displayWidth(helper.styleOptionTerm(helper.optionTerm(option))), + ); }, 0); } @@ -233,7 +257,12 @@ class Help { longestArgumentTermLength(cmd, helper) { return helper.visibleArguments(cmd).reduce((max, argument) => { - return Math.max(max, helper.argumentTerm(argument).length); + return Math.max( + max, + this.displayWidth( + helper.styleArgumentTerm(helper.argumentTerm(argument)), + ), + ); }, 0); } @@ -369,71 +398,78 @@ class Help { 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)); + const helpWidth = helper.helpWidth ?? 80; // in case prepareContext() was not called + + function callFormatItem(term, description) { + return helper.formatItem( + term, + termWidth, + helper.styleItemDescription(description), + helper, + ); } // Usage - let output = [`Usage: ${helper.commandUsage(cmd)}`, '']; + let output = [ + `${helper.styleTitle('Usage:')} ${helper.styleUsage(helper.commandUsage(cmd))}`, + '', + ]; // Description const commandDescription = helper.commandDescription(cmd); if (commandDescription.length > 0) { output = output.concat([ - helper.wrap(commandDescription, helpWidth, 0), + helper.boxWrap( + helper.styleCommandDescription(commandDescription), + helpWidth, + ), '', ]); } // Arguments const argumentList = helper.visibleArguments(cmd).map((argument) => { - return formatItem( - helper.argumentTerm(argument), + return callFormatItem( + helper.styleArgumentTerm(helper.argumentTerm(argument)), helper.argumentDescription(argument), ); }); if (argumentList.length > 0) { - output = output.concat(['Arguments:', formatList(argumentList), '']); + output = output.concat([ + helper.styleTitle('Arguments:'), + ...argumentList, + '', + ]); } // Options const optionList = helper.visibleOptions(cmd).map((option) => { - return formatItem( - helper.optionTerm(option), + return callFormatItem( + helper.styleOptionTerm(helper.optionTerm(option)), helper.optionDescription(option), ); }); if (optionList.length > 0) { - output = output.concat(['Options:', formatList(optionList), '']); + output = output.concat([ + helper.styleTitle('Options:'), + ...optionList, + '', + ]); } - if (this.showGlobalOptions) { + if (helper.showGlobalOptions) { const globalOptionList = helper .visibleGlobalOptions(cmd) .map((option) => { - return formatItem( - helper.optionTerm(option), + return callFormatItem( + helper.styleOptionTerm(helper.optionTerm(option)), helper.optionDescription(option), ); }); if (globalOptionList.length > 0) { output = output.concat([ - 'Global Options:', - formatList(globalOptionList), + helper.styleTitle('Global Options:'), + ...globalOptionList, '', ]); } @@ -441,18 +477,97 @@ class Help { // Commands const commandList = helper.visibleCommands(cmd).map((cmd) => { - return formatItem( - helper.subcommandTerm(cmd), + return callFormatItem( + helper.styleSubcommandTerm(helper.subcommandTerm(cmd)), helper.subcommandDescription(cmd), ); }); if (commandList.length > 0) { - output = output.concat(['Commands:', formatList(commandList), '']); + 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] [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(' '); + } + styleItemDescription(str) { + return this.styleDescriptionText(str); + } + styleCommandDescription(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] [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. * @@ -471,50 +586,123 @@ class Help { } /** - * 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. + * Detect manually wrapped and indented strings by checking for line break followed by whitespace. * * @param {string} str - * @param {number} width - * @param {number} indent - * @param {number} [minColumnWidth=40] - * @return {string} - * + * @returns {boolean} */ + preformatted(str) { + return /\n[^\S\r\n]/.test(str); + } - 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', + /** + * 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), ); - const lines = columnText.match(regex) || []; + + // 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 ( - leadingStr + - lines - .map((line, i) => { - if (line === '\n') return ''; // preserve empty lines - return (i > 0 ? indentString : '') + line.trimEnd(); - }) - .join('\n') + 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; diff --git a/tests/command.configureOutput.test.js b/tests/command.configureOutput.test.js index 1f52c5d40..35eca71b4 100644 --- a/tests/command.configureOutput.test.js +++ b/tests/command.configureOutput.test.js @@ -1,4 +1,5 @@ const commander = require('../'); +const process = require('node:process'); test('when default writeErr() then error on stderr', () => { const writeSpy = jest @@ -248,13 +249,49 @@ test('when custom getErrHelpWidth and configureHelp:helpWidth then help error he expect(helpWidth).toBe(expectedColumns); }); +test('when no custom setup and call formatHelp direct then effective helpWidth is fallback 80', () => { + // Not an important case, but filling out testing coverage. + const helper = new commander.Help(); + let wrapWidth; + helper.boxWrap = (str, width) => { + wrapWidth = wrapWidth ?? width; + return ''; + }; + const program = new commander.Command() + .description('description') + .helpOption(false); + helper.formatHelp(program, helper); + expect(wrapWidth).toBe(80); +}); + +test('when no custom setup and call formatItem direct then effective helpWidth is fallback 80', () => { + // Not an important case, but filling out testing coverage. + const helper = new commander.Help(); + let wrapWidth; + helper.boxWrap = (str, width) => { + wrapWidth = wrapWidth ?? width; + return ''; + }; + + const termWidth = 8; + helper.formatItem('term', termWidth, 'description', helper); + const itemIndent = 2; + const spacerWidth = 2; // between term and description + const remainingWidth = 80 - termWidth - spacerWidth - itemIndent; + + expect(wrapWidth).toBe(remainingWidth); +}); + test('when set configureOutput then get configureOutput', () => { const outputOptions = { writeOut: jest.fn(), writeErr: jest.fn(), getOutHelpWidth: jest.fn(), getErrHelpWidth: jest.fn(), + getOutHasColors: jest.fn(), + getErrHasColors: jest.fn(), outputError: jest.fn(), + stripColor: jest.fn(), }; const program = new commander.Command(); program.configureOutput(outputOptions); @@ -291,3 +328,27 @@ test('when custom outputErr and writeErr and error then outputErr passed writeEr writeErr, ); }); + +describe.each([['getOutHasColors'], ['getErrHasColors']])( + '%s', + (configProperty) => { + // Tried and failed to mock/modify process.stdout.isTTY to test that part of implementation. + // Just test overrides work as expected! + + const getHasColors = new commander.Command().configureOutput()[ + configProperty + ]; + + test.each([ + [true, 'NO_COLOR', false], + [false, 'FORCE_COLOR', true], + [false, 'CLICOLOR_FORCE', true], + ])('when isTTY=%o but %s then returns %o', (isTTY, envvar, result) => { + const holdEnv = process.env[envvar]; + process.env[envvar] = '1'; + expect(getHasColors()).toBe(result); + if (holdEnv === undefined) delete process.env[envvar]; + else process.env[envvar] = holdEnv; + }); + }, +); diff --git a/tests/help.boxWrap.test.js b/tests/help.boxWrap.test.js new file mode 100644 index 000000000..d2e09449e --- /dev/null +++ b/tests/help.boxWrap.test.js @@ -0,0 +1,114 @@ +const commander = require('../'); + +test('when empty string then return empty string', () => { + const helper = new commander.Help(); + const text = ''; + const wrapped = helper.boxWrap(text, 50); + expect(wrapped).toEqual(text); +}); + +test('when string contains unix line breaks then return (unix) empty lines', () => { + const helper = new commander.Help(); + const text = '\n\n\n'; + const wrapped = helper.boxWrap(text, 50); + expect(wrapped).toEqual(text); +}); + +test('when string contains Windows line breaks then return (unix)) empty lines', () => { + const helper = new commander.Help(); + const text = '\r\n\r\n\r\n'; + const wrapped = helper.boxWrap(text, 50); + expect(wrapped).toEqual('\n\n\n'); +}); + +test('when string contains only whitespace then returns empty string (trim right)', () => { + const helper = new commander.Help(); + const text = ' \t '; + const wrapped = helper.boxWrap(text, 50); + expect(wrapped).toEqual(''); +}); + +test('when string contains leading whitespace then returns string with leading whitespace', () => { + const helper = new commander.Help(); + const text = ' abc'; + const wrapped = helper.boxWrap(text, 50); + expect(wrapped).toEqual(' abc'); +}); + +test('when string contains intermediate whitespace then returns string with intermediate whitespace', () => { + const helper = new commander.Help(); + const text = '123 456'; + const wrapped = helper.boxWrap(text, 50); + expect(wrapped).toEqual('123 456'); +}); + +test('when string contains trailing whitespace then returns string without trailing whitespace (trim right)', () => { + const helper = new commander.Help(); + const text = '123 456 '; + const wrapped = helper.boxWrap(text, 50); + expect(wrapped).toEqual('123 456'); +}); + +test('when string contains escape sequences then returns string with escape sequences', () => { + const helper = new commander.Help(); + const text = '123 456 '; + const wrapped = helper.boxWrap(text, 50); + expect(wrapped).toEqual('123 456'); +}); + +test('when string has enough words to wrap then returns two wrapped lines', () => { + const helper = new commander.Help(); + const text = '123 567 901 345 789 123 567 901 345 789 123 567 901'; + const wrapped = helper.boxWrap(text, 45); + expect(wrapped).toEqual( + '123 567 901 345 789 123 567 901 345 789 123\n567 901', + ); +}); + +test('when string has enough words and escape sequences to wrap then returns wrapped line with break ignoring escape sequences', () => { + const helper = new commander.Help(); + const CSI = '\u001b['; + const underlinedText = `${CSI}4mXYZ${CSI}24m`; + const text = `${underlinedText}${' ABC'.repeat(19)}`; + const wrapped = helper.boxWrap(text, 44); + expect(wrapped).toEqual(`${underlinedText} ${'ABC '.repeat(9)}ABC +${'ABC '.repeat(8)}ABC`); +}); + +test('when first word longer than width then soft wrap', () => { + const helper = new commander.Help(); + const text = 'abc'.repeat(20); + const wrapped = helper.boxWrap(text, 40); + expect(wrapped).toEqual(text); +}); + +test('when second word longer than width then wrap and soft wrap', () => { + const helper = new commander.Help(); + const text = 'xyz ' + 'abc'.repeat(20); + const wrapped = helper.boxWrap(text, 40); + expect(wrapped).toEqual(`xyz\n${'abc'.repeat(20)}`); +}); + +test('when set width 41 then wrap at 41', () => { + const helper = new commander.Help(); + const text = 'X'.repeat(39) + ' 1 2 3'; + let wrapped = helper.boxWrap(text, 40); + expect(wrapped).toEqual('X'.repeat(39) + '\n1 2 3'); + wrapped = helper.boxWrap(text, 41); + expect(wrapped).toEqual('X'.repeat(39) + ' 1\n2 3'); +}); + +test('when set width 12 (too small) then skip wrap', () => { + const helper = new commander.Help(); + const text = ' x'.repeat(8) + ' yy'.repeat(6); + let wrapped = helper.boxWrap(text, 14); + expect(wrapped).toEqual(text); +}); + +test('when set width 12 (and set minWrapWidth) then skip wrap', () => { + const helper = new commander.Help(); + helper.minWidthToWrap = 1; + const text = ' x'.repeat(8) + ' yy'.repeat(6); + let wrapped = helper.boxWrap(text, 14); + expect(wrapped).toEqual(' x x x x x x x\nx yy yy yy yy\nyy yy'); +}); diff --git a/tests/help.minWidthToWrap.test.js b/tests/help.minWidthToWrap.test.js new file mode 100644 index 000000000..998d85261 --- /dev/null +++ b/tests/help.minWidthToWrap.test.js @@ -0,0 +1,47 @@ +const commander = require('../'); + +describe('Help.minWidthToWrap', () => { + test('when enough width then wrap', () => { + const program = new commander.Command('program'); + program.description('x '.repeat(110)); + program.configureOutput({ + getOutHelpWidth: () => 100, // lots of room to wrap + }); + const helpText = program.helpInformation(); + const wrappedescription = + 'x '.repeat(50).trim() + + '\n' + + 'x '.repeat(50).trim() + + '\n' + + 'x '.repeat(10).trim(); + expect(helpText).toMatch(wrappedescription); + }); + + test('when not enough width then no wrap', () => { + const program = new commander.Command('program'); + program.description('x '.repeat(50)); + program.configureOutput({ + getOutHelpWidth: () => 30, // too narrow to wrap + }); + const helpText = program.helpInformation(); + const wrappedescription = 'x '.repeat(50); + expect(helpText).toMatch(wrappedescription); + }); + + test('when make minWidthToWrap small then wrap', () => { + const program = new commander.Command('program'); + program.description('x '.repeat(40)); + program.configureOutput({ + getOutHelpWidth: () => 30, + }); + program.configureHelp({ minWidthToWrap: 20 }); + const helpText = program.helpInformation(); + const wrappedescription = + 'x '.repeat(15).trimEnd() + + '\n' + + 'x '.repeat(15).trimEnd() + + '\n' + + 'x '.repeat(10).trimEnd(); + expect(helpText).toMatch(wrappedescription); + }); +}); diff --git a/tests/help.preformatted.test.js b/tests/help.preformatted.test.js new file mode 100644 index 000000000..5cc53c66f --- /dev/null +++ b/tests/help.preformatted.test.js @@ -0,0 +1,70 @@ +const { Help, Command } = require('../'); + +describe('preformatted', () => { + test('when single line then false', () => { + const helper = new Help(); + expect(helper.preformatted('a b c')).toBe(false); + }); + + test('when single line with leading whitespace then false', () => { + const helper = new Help(); + expect(helper.preformatted(' a b c')).toBe(false); + }); + + test('when unix line break not followed by whitespace then false', () => { + const helper = new Help(); + expect(helper.preformatted('a\nb\nc')).toBe(false); + }); + + test('when Windows line break not followed by whitespace then false', () => { + const helper = new Help(); + expect(helper.preformatted('a\r\nb\r\nc')).toBe(false); + }); + + test('when unix line followed by whitespace then true', () => { + const helper = new Help(); + expect(helper.preformatted('a\n b')).toBe(true); + }); + + test('when Windows line break followed by whitespace then true', () => { + const helper = new Help(); + expect(helper.preformatted('a\r\n b')).toBe(true); + }); + + test('when empty unix lines then false', () => { + const helper = new Help(); + expect(helper.preformatted('a\n\n')).toBe(false); + }); + + test('when empty Windows lines then false', () => { + const helper = new Help(); + expect(helper.preformatted('a\r\n\r\n')).toBe(false); + }); +}); + +test('end-to-end: when option description is preformatted then manual format is preserved', () => { + // #396: leave custom format alone, apart from space-space indent + const optionSpec = '-t, --time '; + const program = new Command(); + program.configureHelp({ helpWidth: 80 }).option( + optionSpec, + `select time + +Time can also be specified using special values: + "dawn" - From night to sunrise. +`, + ); + + const expectedOutput = `Usage: [options] + +Options: + ${optionSpec} select time + + Time can also be specified using special values: + "dawn" - From night to sunrise. + + -h, --help display help for command +`; + + expect(program.helpInformation()).toBe(expectedOutput); +}); diff --git a/tests/help.stripAnsi.test.js b/tests/help.stripAnsi.test.js new file mode 100644 index 000000000..420d4b482 --- /dev/null +++ b/tests/help.stripAnsi.test.js @@ -0,0 +1,65 @@ +const { stripColor } = require('../lib/help'); + +// https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters + +// Test the lowest level routine being used for SGR support. + +const ESC = '\u001b'; +const CSI = ESC + '['; + +test('SGR only', () => { + const str = `${CSI}4m`; + expect(stripColor(str)).toEqual(''); +}); + +test('SGR at start', () => { + const str = `${CSI}4mX`; + expect(stripColor(str)).toEqual('X'); +}); + +test('SGR in middle', () => { + const str = `X${CSI}4mY`; + expect(stripColor(str)).toEqual('XY'); +}); + +test('SGR at end', () => { + const str = `${CSI}4mY`; + expect(stripColor(str)).toEqual('Y'); +}); + +test('SGR pair', () => { + // underline and not underlined + const str = `${CSI}4mABC${CSI}24m`; + expect(stripColor(str)).toEqual('ABC'); +}); + +test('explicit reset with zero', () => { + const str = `${CSI}0m`; + expect(stripColor(str)).toEqual(''); +}); + +test('implicit reset without zero', () => { + const str = `${CSI}m`; + expect(stripColor(str)).toEqual(''); +}); + +test('multiple params: select foreground colour', () => { + const str = `${CSI}38;5;4m`; + expect(stripColor(str)).toEqual(''); +}); + +test('multiple params: general', () => { + const str = `${CSI}2;3;4m`; + expect(stripColor(str)).toEqual(''); +}); + +test('multiple params: missing param', () => { + // CSI sequences can omit number (which is then treated as 0) + const str = `${CSI};;m`; + expect(stripColor(str)).toEqual(''); +}); + +test('incomplete SGR sequence', () => { + const str = `${CSI}14X`; + expect(stripColor(str)).toEqual(`${CSI}14X`); +}); diff --git a/tests/help.style.test.js b/tests/help.style.test.js new file mode 100644 index 000000000..08e3c7f3d --- /dev/null +++ b/tests/help.style.test.js @@ -0,0 +1,248 @@ +const { Command } = require('../'); + +function red(str) { + // Use plain characters so not stripped in Jest failure messages. (Means displayWidth is bogus though.) + return `RED ${str} DER`; +} +function stripRed(str) { + return str.replace(/RED /g, '').replace(/ DER/g, ''); +} +function displayWidth(str) { + // Not really zero width for the "color", but pretend so spacing matches no-color output. + return stripRed(str).length; +} + +describe('override style methods and check help information', () => { + function makeProgram() { + const program = new Command('program') + .description('program description') + .argument('', 'arg description') + .configureOutput({ + getOutHasColors: () => true, // avoid interactions with testing environment + }); + program + .command('subcommand') + .description('sub description') + .option('--suboption') + .argument('[subarg]'); + + return program; + } + + const plainHelpInformation = makeProgram().helpInformation(); + + test('styleTitle', () => { + const program = makeProgram(); + program.configureHelp({ styleTitle: (str) => red(str) }, displayWidth); + const helpText = program.helpInformation(); + expect(helpText).toEqual( + plainHelpInformation + .replace('Usage:', red('Usage:')) + .replace('Arguments:', red('Arguments:')) + .replace('Options:', red('Options:')) + .replace('Commands:', red('Commands:')), + ); + }); + + test('styleUsage', () => { + const program = makeProgram(); + program.configureHelp({ styleUsage: (str) => red(str), displayWidth }); + const helpText = program.helpInformation(); + const usageString = program.createHelp().commandUsage(program); + expect(helpText).toEqual( + plainHelpInformation.replace(usageString, red(usageString)), + ); + }); + + test('styleItemDescription', () => { + const program = makeProgram(); + program.configureHelp({ + styleItemDescription: (str) => red(str), + displayWidth, + }); + const helpText = program.helpInformation(); + expect(helpText).toEqual( + plainHelpInformation + .replace('arg description', red('arg description')) + .replace('sub description', red('sub description')) + .replace(/display help for command/g, red('display help for command')), + ); + }); + + test('styleCommandDescription', () => { + const program = makeProgram(); + program.configureHelp({ + styleCommandDescription: (str) => red(str), + displayWidth, + }); + const helpText = program.helpInformation(); + expect(helpText).toEqual( + plainHelpInformation.replace( + 'program description', + red('program description'), + ), + ); + }); + + test('styleDescriptionText', () => { + const program = makeProgram(); + program.configureHelp({ + styleDescriptionText: (str) => red(str), + displayWidth, + }); + const helpText = program.helpInformation(); + expect(helpText).toEqual( + plainHelpInformation + .replace('program description', red('program description')) + .replace('arg description', red('arg description')) + .replace('sub description', red('sub description')) + .replace(/display help for command/g, red('display help for command')), + ); + }); + + test('styleOptionTerm', () => { + const program = makeProgram(); + program.configureHelp({ styleOptionTerm: (str) => red(str), displayWidth }); + const helpText = program.helpInformation(); + expect(helpText).toEqual( + plainHelpInformation.replace('-h, --help', red('-h, --help')), + ); + }); + + test('styleSubcommandTerm', () => { + const program = makeProgram(); + program.configureHelp({ + styleSubcommandTerm: (str) => red(str), + displayWidth, + }); + const helpText = program.helpInformation(); + expect(helpText).toEqual( + plainHelpInformation + .replace('help [command]', red('help [command]')) + .replace( + 'subcommand [options] [subarg]', + red('subcommand [options] [subarg]'), + ), + ); + }); + + test('styleArgumentTerm', () => { + const program = makeProgram(); + program.configureHelp({ + styleArgumentTerm: (str) => red(str), + displayWidth, + }); + const helpText = program.helpInformation(); + expect(helpText).toEqual( + plainHelpInformation.replace(' file ', ` ${red('file')} `), + ); + }); + + test('styleOptionText', () => { + const program = makeProgram(); + program.configureHelp({ + styleOptionText: (str) => red(str), + displayWidth, + }); + const helpText = program.helpInformation(); + expect(helpText).toEqual( + plainHelpInformation + .replace(/\[options\]/g, red('[options]')) + .replace('-h, --help', red('-h, --help')), + ); + }); + + test('styleArgumentText', () => { + const program = makeProgram(); + program.configureHelp({ + styleArgumentText: (str) => red(str), + displayWidth, + }); + const helpText = program.helpInformation(); + expect(helpText).toEqual( + plainHelpInformation + .replace('', red('')) + .replace(' file ', ` ${red('file')} `) + .replace('[subarg]', red('[subarg]')) + .replace('help [command]', `help ${red('[command]')}`), + ); + }); + + test('styleSubcommandText', () => { + const program = makeProgram(); + program.configureHelp({ + styleSubcommandText: (str) => red(str), + displayWidth, + }); + const helpText = program.helpInformation(); + expect(helpText).toEqual( + plainHelpInformation + .replace('[command] ', `${red('[command]')} `) + .replace('help [command]', `${red('help')} [command]`) + .replace('subcommand', red('subcommand')), + ); + }); + + test('styleCommandText', () => { + const program = makeProgram(); + program.configureHelp({ + styleCommandText: (str) => red(str), + displayWidth, + }); + const helpText = program.helpInformation(); + expect(helpText).toEqual( + plainHelpInformation.replace('program', red('program')), + ); + }); +}); + +describe('check styles with configureOutput overrides for color', () => { + function makeProgram(hasColors) { + const program = new Command('program'); + program.myHelpText = []; + program + .description('program description') + .argument('', 'arg description') + .configureOutput({ + getOutHasColors: () => hasColors, + stripColor: (str) => stripRed(str), + writeOut: (str) => { + program.myHelpText.push(str); + }, + }); + program.configureHelp({ + styleCommandText: (str) => red(str), + displayWidth, + }); + + return program; + } + + test('when getOutHasColors returns true then help has color', () => { + const program = makeProgram(true); + program.outputHelp(); + const helpText = program.myHelpText.join(''); + expect(helpText).toMatch(red('program')); + }); + + test('when getOutHasColors returns false then help does not have color', () => { + const program = makeProgram(false); + program.outputHelp(); + const helpText = program.myHelpText.join(''); + expect(helpText).not.toMatch(red('program')); + }); + + test('when getOutHasColors returns false then style still called', () => { + const program = makeProgram(true); + // Overwrite styleCommandText so we can track whether called. + let styleCalled = false; + const config = program.configureHelp(); + config.styleCommandText = (str) => { + styleCalled = true; + return red(str); + }; + program.configureHelp(config); + program.outputHelp(); + expect(styleCalled).toBe(true); + }); +}); diff --git a/tests/help.wrap.test.js b/tests/help.wrap.test.js deleted file mode 100644 index 063996be7..000000000 --- a/tests/help.wrap.test.js +++ /dev/null @@ -1,219 +0,0 @@ -const commander = require('../'); - -// These are tests of the Help class, not of the Command help. -// There is some overlap with the higher level Command tests (which predate Help). - -describe('wrap', () => { - test('when string fits into width then returns input', () => { - const text = 'a '.repeat(24) + 'a'; - const helper = new commander.Help(); - const wrapped = helper.wrap(text, 50, 3); - expect(wrapped).toEqual(text); - }); - - test('when string shorter than indent then returns input', () => { - const text = 'a'; - const helper = new commander.Help(); - const wrapped = helper.wrap(text, 50, 3); - expect(wrapped).toEqual(text); - }); - - test('when string exceeds width then wrap', () => { - const text = 'a '.repeat(30) + 'a'; - const helper = new commander.Help(); - const wrapped = helper.wrap(text, 50, 0); - expect(wrapped).toEqual(`${'a '.repeat(24)}a -${'a '.repeat(5)}a`); - }); - - test('when string exceeds width then wrap and indent', () => { - const text = 'a '.repeat(30) + 'a'; - const helper = new commander.Help(); - const wrapped = helper.wrap(text, 50, 10); - expect(wrapped).toEqual(`${'a '.repeat(24)}a -${' '.repeat(10)}${'a '.repeat(5)}a`); - }); - - test('when width < 40 then do not wrap', () => { - const text = 'a '.repeat(30) + 'a'; - const helper = new commander.Help(); - const wrapped = helper.wrap(text, 39, 0); - expect(wrapped).toEqual(text); - }); - - test('when text has line break then respect and indent', () => { - const text = 'term description\nanother line'; - const helper = new commander.Help(); - const wrapped = helper.wrap(text, 78, 5); - expect(wrapped).toEqual('term description\n another line'); - }); - - test('when text has consecutive line breaks then respect and indent', () => { - const text = 'term description\n\nanother line'; - const helper = new commander.Help(); - const wrapped = helper.wrap(text, 78, 5); - expect(wrapped).toEqual('term description\n\n another line'); - }); - - test('when text has Windows line break then respect and indent', () => { - const text = 'term description\r\nanother line'; - const helper = new commander.Help(); - const wrapped = helper.wrap(text, 78, 5); - expect(wrapped).toEqual('term description\n another line'); - }); - - test('when text has Windows consecutive line breaks then respect and indent', () => { - const text = 'term description\r\n\r\nanother line'; - const helper = new commander.Help(); - const wrapped = helper.wrap(text, 78, 5); - expect(wrapped).toEqual('term description\n\n another line'); - }); - - test('when text already formatted with line breaks and indent then do not touch', () => { - const text = 'term a '.repeat(25) + '\n ' + 'a '.repeat(25) + 'a'; - const helper = new commander.Help(); - const wrapped = helper.wrap(text, 78, 5); - expect(wrapped).toEqual(text); - }); -}); - -describe('wrapping by formatHelp', () => { - // Test auto wrap and indent with some manual strings. - // Fragile tests with complete help output. - - test('when long option description then wrap and indent', () => { - const program = new commander.Command(); - program - .configureHelp({ helpWidth: 80 }) - .option( - '-x --extra-long-option-switch', - 'kjsahdkajshkahd kajhsd akhds kashd kajhs dkha dkh aksd ka dkha kdh kasd ka kahs dkh sdkh askdh aksd kashdk ahsd kahs dkha skdh', - ); - - const expectedOutput = `Usage: [options] - -Options: - -x --extra-long-option-switch kjsahdkajshkahd kajhsd akhds kashd kajhs dkha - dkh aksd ka dkha kdh kasd ka kahs dkh sdkh - askdh aksd kashdk ahsd kahs dkha skdh - -h, --help display help for command -`; - - expect(program.helpInformation()).toBe(expectedOutput); - }); - - test('when long option description and default then wrap and indent', () => { - const program = new commander.Command(); - program - .configureHelp({ helpWidth: 80 }) - .option( - '-x --extra-long-option ', - 'kjsahdkajshkahd kajhsd akhds', - 'aaa bbb ccc ddd eee fff ggg', - ); - - const expectedOutput = `Usage: [options] - -Options: - -x --extra-long-option kjsahdkajshkahd kajhsd akhds (default: "aaa - bbb ccc ddd eee fff ggg") - -h, --help display help for command -`; - - expect(program.helpInformation()).toBe(expectedOutput); - }); - - test('when long subcommand description then wrap and indent', () => { - const program = new commander.Command(); - program - .configureHelp({ helpWidth: 80 }) - .option('-x --extra-long-option-switch', 'x') - .command( - 'alpha', - 'Lorem mollit quis dolor ex do eu quis ad insa a commodo esse.', - ); - - const expectedOutput = `Usage: [options] [command] - -Options: - -x --extra-long-option-switch x - -h, --help display help for command - -Commands: - alpha Lorem mollit quis dolor ex do eu quis ad insa - a commodo esse. - help [command] display help for command -`; - - expect(program.helpInformation()).toBe(expectedOutput); - }); - - test('when not enough room then help not wrapped', () => { - // Not wrapping if less than 40 columns available for wrapping. - const program = new commander.Command(); - const commandDescription = - 'description text of very long command which should not be automatically be wrapped. Do fugiat eiusmod ipsum laboris excepteur pariatur sint ullamco tempor labore eu.'; - program - .configureHelp({ helpWidth: 60 }) - .command('1234567801234567890x', commandDescription); - - const expectedOutput = `Usage: [options] [command] - -Options: - -h, --help display help for command - -Commands: - 1234567801234567890x ${commandDescription} - help [command] display help for command -`; - - expect(program.helpInformation()).toBe(expectedOutput); - }); - - test('when option description pre-formatted then only add small indent', () => { - // #396: leave custom format alone, apart from space-space indent - const optionSpec = '-t, --time '; - const program = new commander.Command(); - program.configureHelp({ helpWidth: 80 }).option( - optionSpec, - `select time - -Time can also be specified using special values: - "dawn" - From night to sunrise. -`, - ); - - const expectedOutput = `Usage: [options] - -Options: - ${optionSpec} select time - - Time can also be specified using special values: - "dawn" - From night to sunrise. - - -h, --help display help for command -`; - - expect(program.helpInformation()).toBe(expectedOutput); - }); - - test('when command description long then wrapped', () => { - const program = new commander.Command(); - program.configureHelp({ helpWidth: 80 }) - .description(`Do fugiat eiusmod ipsum laboris excepteur pariatur sint ullamco tempor labore eu Do fugiat eiusmod ipsum laboris excepteur pariatur sint ullamco tempor labore eu -After line break Do fugiat eiusmod ipsum laboris excepteur pariatur sint ullamco tempor labore eu Do fugiat eiusmod ipsum laboris excepteur pariatur sint ullamco tempor labore eu`); - const expectedOutput = `Usage: [options] - -Do fugiat eiusmod ipsum laboris excepteur pariatur sint ullamco tempor labore -eu Do fugiat eiusmod ipsum laboris excepteur pariatur sint ullamco tempor -labore eu -After line break Do fugiat eiusmod ipsum laboris excepteur pariatur sint -ullamco tempor labore eu Do fugiat eiusmod ipsum laboris excepteur pariatur -sint ullamco tempor labore eu - -Options: - -h, --help display help for command -`; - expect(program.helpInformation()).toBe(expectedOutput); - }); -}); diff --git a/tests/useColor.test.js b/tests/useColor.test.js new file mode 100644 index 000000000..e65f3d2dc --- /dev/null +++ b/tests/useColor.test.js @@ -0,0 +1,83 @@ +const { useColor } = require('../lib/command.js'); + +describe('internal useColor environment variable support', () => { + let holdNoColor = process.env.NO_COLOR; + let holdForceColor = process.env.FORCE_COLOR; + let holdCliColorForce = process.env.CLICOLOR_FORCE; + + beforeEach(() => { + delete process.env.NO_COLOR; + delete process.env.FORCE_COLOR; + delete process.env.CLICOLOR_FORCE; + }); + + afterAll(() => { + if (holdNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = holdNoColor; + + if (holdForceColor === undefined) delete process.env.FORCE_COLOR; + else process.env.FORCE_COLOR = holdForceColor; + + if (holdCliColorForce === undefined) delete process.env.CLICOLOR_FORCE; + else process.env.CLICOLOR_FORCE = holdCliColorForce; + }); + + test('when no ENV defined then returns undefined', () => { + expect(useColor()).toBeUndefined(); + }); + + // https://no-color.org + // + // Command-line software which adds ANSI color to its output by default should check for a NO_COLOR environment variable that, + // when present and not an empty string (regardless of its value), prevents the addition of ANSI color. + + test('when NO_COLOR defined then returns false', () => { + process.env.NO_COLOR = 'non-empty'; + expect(useColor()).toBe(false); + }); + + test('when NO_COLOR empty then returns undefined', () => { + process.env.NO_COLOR = ''; + expect(useColor()).toBe(undefined); + }); + + // https://bixense.com/clicolors/ + + test('when CLICOLOR_FORCE defined then returns true', () => { + process.env.CLICOLOR_FORCE = '1'; + expect(useColor()).toBe(true); + }); + + test('when CLICOLOR_FORCE empty then returns true', () => { + // Follow original Apple usage and test for existence, don't ignore empty value. + process.env.CLICOLOR_FORCE = ''; + expect(useColor()).toBe(true); + }); + + test('when CLICOLOR_FORCE and NO_COLOR defined then returns false', () => { + // NO_COLOR trumps CLICOLOR_FORCE + process.env.NO_COLOR = '1'; + process.env.CLICOLOR_FORCE = '1'; + expect(useColor()).toBe(false); + }); + + // chalk: https://github.com/chalk/supports-color/blob/c214314a14bcb174b12b3014b2b0a8de375029ae/index.js#L33 + // node: https://github.com/nodejs/node/blob/0a00217a5f67ef4a22384cfc80eb6dd9a917fdc1/lib/internal/tty.js#L109 + // (https://force-color.org recent web page from 2023, does not match major javascript implementations) + // + // Chalk ignores anything except for 0,1,2,3,4,true,false values. + // Node somewhat follows Chalk with 0,1,2,3,true, but treats empty as true and unexpected values as false. + // Test the expected Chalk values (which do produce same result in node). + + test.each([ + ['true', true], + ['false', false], + ['0', false], + ['1', true], + ['2', true], + ['3', true], + ])('when FORCE_COLOR=%s then returns %s', (value, result) => { + process.env.FORCE_COLOR = value; + expect(useColor()).toBe(result); + }); +}); diff --git a/typings/index.d.ts b/typings/index.d.ts index 0f48ae935..1c0c36b77 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -205,12 +205,25 @@ export class Option { export class Help { /** output helpWidth, long lines are wrapped to fit */ helpWidth?: number; + minWidthToWrap: number; sortSubcommands: boolean; sortOptions: boolean; showGlobalOptions: boolean; constructor(); + /* + * prepareContext is called by Commander after applying overrides from `Command.configureHelp()` + * and just before calling `formatHelp()`. + * + * Commander just uses the helpWidth and the others are provided for subclasses. + */ + prepareContext(contextOptions: { + error?: boolean; + helpWidth?: number; + outputHasColors?: boolean; + }): void; + /** Get the command term to show in the list of subcommands. */ subcommandTerm(cmd: Command): string; /** Get the command summary to show in the list of subcommands. */ @@ -246,18 +259,58 @@ export class Help { longestGlobalOptionTermLength(cmd: Command, helper: Help): number; /** Get the longest argument term length. */ longestArgumentTermLength(cmd: Command, helper: Help): number; + + /** Return display width of string, ignoring ANSI escape sequences. Used in padding and wrapping calculations. */ + displayWidth(str: string): number; + + /** Style the titles. Called with 'Usage:', 'Options:', etc. */ + styleTitle(title: string): string; + + /** Usage: */ + styleUsage(str: string): string; + /** Style for command name in usage string. */ + styleCommandText(str: string): string; + + styleItemDescription(str: string): string; + styleCommandDescription(str: string): string; + /** Base style used by descriptions. */ + styleDescriptionText(str: string): string; + + styleOptionTerm(str: string): string; + styleSubcommandTerm(str: string): string; + styleArgumentTerm(str: string): string; + + /** Base style used in terms and usage for options. */ + styleOptionText(str: string): string; + /** Base style used in terms and usage for subcommands. */ + styleSubcommandText(str: string): string; + /** Base style used in terms and usage for arguments. */ + styleArgumentText(str: string): string; + /** Calculate the pad width from the maximum term length. */ padWidth(cmd: Command, helper: Help): number; /** - * 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. + * Wrap a string at whitespace, preserving existing line breaks. + * Wrapping is skipped if the width is less than `minWidthToWrap`. */ - wrap( - str: string, - width: number, - indent: number, - minColumnWidth?: number, + boxWrap(str: string, width: number): string; + + /** Detect manually wrapped and indented strings by checking for line break followed by whitespace. */ + preformatted(str: string): boolean; + + /** + * 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 + */ + formatItem( + term: string, + termWidth: number, + description: string, + helper: Help, ): string; /** Generate the built-in help text. */ @@ -280,9 +333,14 @@ export interface AddHelpTextContext { export interface OutputConfiguration { writeOut?(str: string): void; writeErr?(str: string): void; + outputError?(str: string, write: (str: string) => void): void; + getOutHelpWidth?(): number; getErrHelpWidth?(): number; - outputError?(str: string, write: (str: string) => void): void; + + getOutHasColors?(): boolean; + getErrHasColors?(): boolean; + stripColor?(str: string): string; } export type AddHelpTextPosition = 'beforeAll' | 'before' | 'after' | 'afterAll'; diff --git a/typings/index.test-d.ts b/typings/index.test-d.ts index 9337def46..6b67a5361 100644 --- a/typings/index.test-d.ts +++ b/typings/index.test-d.ts @@ -545,11 +545,16 @@ expectType( writeErr: (str: string) => { console.error(str); }, - getOutHelpWidth: () => 80, - getErrHelpWidth: () => 80, outputError: (str: string, write: (str: string) => void) => { write(str); }, + + getOutHelpWidth: () => 80, + getErrHelpWidth: () => 80, + + getOutHasColors: () => true, + getErrHasColors: () => true, + stripColor: (str) => str, }), ); @@ -559,7 +564,11 @@ const helperCommand = new commander.Command(); const helperOption = new commander.Option('-a, --all'); const helperArgument = new commander.Argument(''); +helper.prepareContext({}); +helper.prepareContext({ helpWidth: 120, error: true, outputHasColors: false }); + expectType(helper.helpWidth); +expectType(helper.minWidthToWrap); expectType(helper.sortSubcommands); expectType(helper.sortOptions); expectType(helper.showGlobalOptions); @@ -584,7 +593,29 @@ expectType(helper.longestGlobalOptionTermLength(helperCommand, helper)); expectType(helper.longestArgumentTermLength(helperCommand, helper)); expectType(helper.padWidth(helperCommand, helper)); -expectType(helper.wrap('a b c', 50, 3)); +expectType(helper.displayWidth('some string')); +expectType(helper.boxWrap('a b c', 50)); +expectType( + helper.formatItem('--example', 12, 'example description', helper), +); +expectType(helper.preformatted('a\nb c')); + +expectType(helper.styleTitle('Usage:')); + +expectType(helper.styleUsage('foo [options] ')); +expectType(helper.styleCommandText('foo')); + +expectType(helper.styleItemDescription('description')); +expectType(helper.styleCommandDescription('description')); +expectType(helper.styleDescriptionText('description')); + +expectType(helper.styleOptionTerm('-a, --all')); +expectType(helper.styleSubcommandTerm('bar [options]')); +expectType(helper.styleArgumentTerm('')); + +expectType(helper.styleOptionText('-a, --all')); +expectType(helper.styleSubcommandText('bar')); +expectType(helper.styleArgumentText('')); expectType(helper.formatHelp(helperCommand, helper));