diff --git a/lib/error.js b/lib/error.js index e7cde9cc7..48c73f1e0 100644 --- a/lib/error.js +++ b/lib/error.js @@ -30,7 +30,7 @@ class CommanderError extends Error { class InvalidArgumentError extends CommanderError { /** * Constructs the InvalidArgumentError class - * @param {string} [message] explanation of why argument is invalid + * @param {string} message explanation of why argument is invalid * @constructor */ constructor(message) { diff --git a/lib/help.js b/lib/help.js index 14e0fb9f3..02acf1191 100644 --- a/lib/help.js +++ b/lib/help.js @@ -1,4 +1,5 @@ const { humanReadableArgName } = require('./argument.js'); +const { CommanderError } = require('./error.js'); /** * TypeScript import types for JSDoc, used by Visual Studio Code IntelliSense and `npm run typescript-checkJS` @@ -6,6 +7,7 @@ const { humanReadableArgName } = require('./argument.js'); * @typedef { import("./argument.js").Argument } Argument * @typedef { import("./command.js").Command } Command * @typedef { import("./option.js").Option } Option + * @typedef { import("..").WrapOptions } WrapOptions */ // @ts-check @@ -14,6 +16,8 @@ const { humanReadableArgName } = require('./argument.js'); class Help { constructor() { this.helpWidth = undefined; + this.minWidthGuideline = 40; + this.preformatted = undefined; this.sortSubcommands = false; this.sortOptions = false; this.showGlobalOptions = false; @@ -348,17 +352,19 @@ 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 + const globalIndent = 2; + const columnGap = 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); + const fullText = `${term.padEnd(termWidth)}${description}`; + return helper.wrap(fullText, helpWidth, termWidth, { + globalIndent, columnGap + }); } - return term; + return ' '.repeat(globalIndent) + term; } function formatList(textArray) { - return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth)); + return textArray.join('\n'); } // Usage @@ -424,39 +430,237 @@ 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. - * + * @see {@link Help.wrap()} + * @overload + * @param {string} str + * @param {number} width + * @param {WrapOptions} [options] + * @return {string} + */ + + /** + * @see {@link Help.wrap()} + * @overload * @param {string} str * @param {number} width - * @param {number} indent - * @param {number} [minColumnWidth=40] + * @param {number} leadingStrLength + * @param {WrapOptions} [options] * @return {string} + */ + + /** + * @see {@link Help.wrap()} + * @overload + * @param {string} str + * @param {number} width + * @param {number} leadingStrLength + * @param {number} minWidthGuideline + * @param {WrapOptions} [options] + * @return {string} + */ + + /** + * @see {@link Help.wrap()} + * @overload + * @param {string} str + * @param {number} width + * @param {number} leadingStrLength + * @param {number} minWidthGuideline + * @param {boolean} preformatted + * @param {WrapOptions} [options] + * @return {string} + */ + + /** + * Merge left text column defined by first `leadingStrLength` characters of `str` with right text column defined by remaining characters, wrapping the output to `width - 1` characters per line. * + * Do not wrap if right column text is manually formatted. + * + * Lines containing left column are indented by `globalIndent - Math.min(0, fullIndent)` and all new lines due to overflow by `fullIndent` if it is positive and does not cause text display width to be too narrow, where `fullIndent = globalIndent + leadingStrWidth + columnGap + overflowShift` with `leadingStrWidth` being the computed width of the left column. `leadingStrWidth` and `columnGap` are omitted from the computation if right column text is manually formatted. + * + * `leadingStrLength`, `overflowShift`, `globalIndent` and `columnGap` all default to 0. + * + * Text display width is considered too narrow when it is less than `minWidthGuideline` which defaults to 40. + * + * Unless `preformatted` is specified explicitly, right column text is considered manually formatted if it includes a line break followed by a whitespace. + * + * @param {string} str + * @param {number} width + * @param {[Options]|[number, Options]|[number, number, Options]|[number, number, boolean, Options]} restArguments + * @return {string} */ + wrap(str, width, ...restArguments) { + const options = { + leadingStrLength: 0, + minWidthGuideline: this.minWidthGuideline, + preformatted: this.preformatted, + overflowShift: 0, + globalIndent: 0, + columnGap: 0 + }; + + // Options from individual option parameters + for (const [i, key] of Object.entries([ + 'leadingStrLength', 'minWidthGuideline', 'preformatted' + ])) { + if (+i === restArguments.length) break; + if (typeof restArguments[i] === 'object') break; + options[key] = restArguments[i]; + } + + // Options from `options` parameter + if (typeof restArguments.at(-1) === 'object') { + Object.assign(options, restArguments.pop()); + } + + let { + leadingStrLength, + minWidthGuideline, + preformatted, + overflowShift, + globalIndent, + columnGap + } = options; + + if (width === undefined) { + width = this.helpWidth ?? Number.POSITIVE_INFINITY; + } + + // TODO: Error message + if (width - 1 <= 0 || leadingStrLength < 0 || globalIndent < 0 || columnGap < 0) { + throw new CommanderError(0, 'commander.helpWrapInvalidArgument', ''); + } + + if (globalIndent >= width - 1) globalIndent = 0; + + let leadingStr = str.slice(0, leadingStrLength); + const columnText = str.slice(leadingStrLength).replaceAll('\r\n', '\n'); + + const newline = /\r?\n/; + if (preformatted === undefined) { + // Full \s characters, minus line terminators (ECMAScript 12.3 Line Terminators) + const whitespaceClass = '[^\\S\n\r\u2028\u2029]'; + // Detect manually wrapped and indented strings by searching for lines starting with spaces. + const preformattedRegex = new RegExp(`\n${whitespaceClass}`); + preformatted = preformattedRegex.test(leadingStr); + preformatted ||= preformattedRegex.test(columnText); + } + const nowrap = preformatted || width === Number.POSITIVE_INFINITY; + const lines = nowrap ? columnText.split(newline) : []; + + let leadingStrLines, leadingStrWidth; + const processLeadingStr = () => { + leadingStrLines = leadingStr.split(newline); + leadingStrWidth = leadingStrLines.reduce( + (max, line) => Math.max(max, line.length), 0 + ); + leadingStrLines.forEach((line, i) => { + leadingStrLines[i] = line.padEnd(leadingStrWidth); + }); + }; + processLeadingStr(); + + const missingLineCount = () => Math.max( + 0, leadingStrLines.length - lines.length + ); + const missingLineArray = () => Array(missingLineCount()).fill(''); + const pushMissingLines = () => lines.push(...missingLineArray()); + + // If negative, used to indent lines before overflow. + // If positive, used to indent overflowing lines unless width is insufficient. + // When computing the value, ignore indentation implied by leadingStr if preformatted. + let fullIndent = globalIndent + Number(!preformatted) * ( + leadingStrWidth + columnGap + ) + overflowShift; + if (Math.abs(fullIndent) >= width - 1) { + fullIndent = Math.abs(overflowShift) >= width - 1 ? overflowShift : 0; + } + + // Make overflowing lines appear shifted by negative overflowShift + // even if there is not enough room for such a shift + // by additionally indenting lines before overflow. + globalIndent -= Math.min(0, fullIndent); + + let overflowIndent = fullIndent; + let overflowWidth = width - overflowIndent; + if (overflowIndent < 0 || overflowWidth < minWidthGuideline) { + overflowIndent = 0; + overflowWidth = width; + } + + if (!nowrap) { + let columnWidth = width - globalIndent - leadingStrWidth - columnGap; + if (columnWidth - 1 <= 0) { + if (globalIndent + columnGap >= width - 1) columnGap = 0; + // Fit leadingStr in available width. + // Only really makes sense if it is one line. + leadingStr = this.wrap(leadingStr, width - globalIndent - columnGap); + processLeadingStr(); + columnWidth = 1; + } + + const zeroWidthSpace = '\u200B'; + const breakGroupWithoutLF = `[^\\S\n]|${zeroWidthSpace}|$`; + // Captured substring is used in code, + // so be careful with parentheses when changing the regex template. + // Prefer non-capturing groups. + const makeRegex = (width) => new RegExp( // minus one still necessary??? + // Capture as much text as will fit in a column of width (width - 1) + // without having to split words + `([^\n]{0,${width - 1}})` + + // and include all following breaks in match, stopping at the first \n, + // so that they can be collapsed. + `(?:\n|(?:${breakGroupWithoutLF})+\n?)` + + // If not possible, match exactly (width - 1) characters instead. + // In this case, a word has to be split. + // Indicated by the fact nothing was captured. + `|.{${width - 1}}`, + 'y' // expose and use lastIndex + ); + const columnRegex = makeRegex(columnWidth); + const overflowRegex = makeRegex(overflowWidth); + + let overflow = false; + let regex = columnRegex; + const setOverflow = (index) => { + overflow = true; + regex = overflowRegex; + regex.lastIndex = index; // consume non-overflowing part + }; + if (!(columnWidth - 1)) { + pushMissingLines(); + setOverflow(0); + } + + while (regex.lastIndex < columnText.length) { // input is not yet fully consumed + const { 0: match, 1: line, index } = regex.exec(columnText); + const fits = line != null; + if (!overflow && overflowShift < 0 && !fits) { + // If word does not fit in non-overflowing part, + // it might still fit in overflow when overflowShift is negative. + pushMissingLines(); + setOverflow(index); + } else { + lines.push(line ?? match); + if (!overflow && lines.length >= leadingStrLines.length) { + setOverflow(regex.lastIndex); + } + } + } + } - wrap(str, width, indent, minColumnWidth = 40) { - // Full \s characters, minus the linefeeds. - const indents = ' \\f\\t\\v\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000\ufeff'; - // Detect manually wrapped and indented strings by searching for line break followed by spaces. - const manualIndent = new RegExp(`[\\n][${indents}]+`); - if (str.match(manualIndent)) return str; - // Do not wrap if not enough room for a wrapped column of text (as could end up with a word per line). - const columnWidth = width - indent; - if (columnWidth < minColumnWidth) return str; - - const leadingStr = str.slice(0, indent); - const columnText = str.slice(indent).replace('\r\n', '\n'); - const indentString = ' '.repeat(indent); - const zeroWidthSpace = '\u200B'; - const breaks = `\\s${zeroWidthSpace}`; - // Match line end (so empty lines don't collapse), - // or as much text as will fit in column, or excess text up to first break. - const regex = new RegExp(`\n|.{1,${columnWidth - 1}}([${breaks}]|$)|[^${breaks}]+?([${breaks}]|$)`, 'g'); - const lines = columnText.match(regex) || []; - return leadingStr + lines.map((line, i) => { - if (line === '\n') return ''; // preserve empty lines - return ((i > 0) ? indentString : '') + line.trimEnd(); + const globalIndentString = ' '.repeat(globalIndent); + const columnGapString = ' '.repeat(columnGap); + const overflowIndentString = ' '.repeat(overflowIndent); + + pushMissingLines(); + return lines.map((line, i) => { + const prefix = i < leadingStrLines.length + ? globalIndentString + leadingStrLines[i] + columnGapString + : overflowIndentString; + return preformatted + ? line ? prefix + line : prefix.trimEnd() + : (prefix + line).trimEnd(); }).join('\n'); } } diff --git a/package-lock.json b/package-lock.json index c32c80e62..da2deae60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "commander", - "version": "10.0.1", + "version": "11.0.0", "license": "MIT", "devDependencies": { "@types/jest": "^29.2.4", diff --git a/tests/help.wrap.test.js b/tests/help.wrap.test.js index 0d12d8359..04f36e7bc 100644 --- a/tests/help.wrap.test.js +++ b/tests/help.wrap.test.js @@ -3,6 +3,9 @@ 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). +// ECMAScript 12.2 White Space +const whitespaces = '\t\v\f\ufeff \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000'; + describe('wrap', () => { test('when string fits into width then returns input', () => { const text = 'a '.repeat(24) + 'a'; @@ -18,6 +21,13 @@ describe('wrap', () => { expect(wrapped).toEqual(text); }); + test('when string and indent have equal length then returns input', () => { + const text = 'aaa'; + 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(); @@ -34,11 +44,45 @@ ${'a '.repeat(5)}a`); ${' '.repeat(10)}${'a '.repeat(5)}a`); }); - test('when width < 40 then do not wrap', () => { + test('when word exceeds width then wrap word overflow', () => { + const text = 'a'.repeat(60); + const helper = new commander.Help(); + const wrapped = helper.wrap(text, 50, 0); + expect(wrapped).toEqual(`${'a'.repeat(49)} +${'a'.repeat(11)}`); + }); + + test('when word exceeds width then wrap word overflow and indent', () => { + const text = 'a'.repeat(60); + const helper = new commander.Help(); + const wrapped = helper.wrap(text, 50, 3); + expect(wrapped).toEqual(`${'a'.repeat(49)} + ${'a'.repeat(11)}`); + }); + + test('when negative shift and first word exceeds column width then place in overflow', () => { + const text = ' '.repeat(5) + 'a'.repeat(49); + const helper = new commander.Help(); + const wrapped = helper.wrap(text, 50, 5, { overflowShift: -5 }); + expect(wrapped).toEqual(` +${'a'.repeat(49)}`); + }); + + test('when negative shift and first word exceeds overflow width then place in overflow', () => { + const text = ' '.repeat(5) + 'a'.repeat(60); + const helper = new commander.Help(); + const wrapped = helper.wrap(text, 50, 5, { overflowShift: -5 }); + expect(wrapped).toEqual(` +${'a'.repeat(49)} +${'a'.repeat(11)}`); + }); + + test('when width < 40 then wrap but do not indent', () => { const text = 'a '.repeat(30) + 'a'; const helper = new commander.Help(); - const wrapped = helper.wrap(text, 39, 0); - expect(wrapped).toEqual(text); + const wrapped = helper.wrap(text, 39, 10); + expect(wrapped).toEqual(`${'a '.repeat(18)}a +${'a '.repeat(11)}a`); }); test('when text has line break then respect and indent', () => { @@ -69,6 +113,41 @@ ${' '.repeat(10)}${'a '.repeat(5)}a`); expect(wrapped).toEqual('term description\n\n another line'); }); + test('when more whitespaces after line than available width then collapse all', () => { + const text = `abcd${whitespaces}\ne`; + const helper = new commander.Help(); + const wrapped = helper.wrap(text, 5); + expect(wrapped).toEqual('abcd\ne'); + }); + + test('when line of whitespaces then do not indent', () => { + const text = whitespaces; + const helper = new commander.Help(); + const wrapped = helper.wrap(text, 50, { + overflowShift: 3, + globalIndent: 3, + columnGap: 3 + }); + expect(wrapped).toEqual(''); + }); + + test('when not pre-formatted then trim ends of output lines', () => { + const text = '\na\n' + // leadingStr (first column) + '\n\na ' + // text to wrap and indent (new, second column) before overflow + '\n\na '; // overflowing lines of the text (column overflow) + const helper = new commander.Help(); + const wrapped = helper.wrap(text, 50, 3, { + overflowShift: -3, + globalIndent: 3, + columnGap: 3 + }); + expect(wrapped).toEqual(` + a + a + + a`); + }); + 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(); @@ -141,23 +220,26 @@ Commands: expect(program.helpInformation()).toBe(expectedOutput); }); - test('when not enough room then help not wrapped', () => { - // Not wrapping if less than 40 columns available for wrapping. + test('when not enough room then help wrapped but not indented', () => { + // Here, a limiting case is considered. Removal of one character from the command name will make 40 columns available for wrapping, which is the default minimum value for overflowing text width. 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.'; + const commandDescription = 'very long command description text which should be wrapped but not indented. Do fugiat eiusmod ipsum laboris excepteur pariatur sint ullamco tempor labore eu.'; program .configureHelp({ helpWidth: 60 }) - .command('1234567801234567890x', commandDescription); + .command('0123456789abcdef+', commandDescription); const expectedOutput = `Usage: [options] [command] Options: - -h, --help display help for command + -h, --help display help for command Commands: - 1234567801234567890x ${commandDescription} - help [command] display help for command + 0123456789abcdef+ very long command description text +which should be wrapped but not indented. Do fugiat eiusmod +ipsum laboris excepteur pariatur sint ullamco tempor labore +eu. + help [command] display help for command `; expect(program.helpInformation()).toBe(expectedOutput); @@ -180,10 +262,10 @@ Time can also be specified using special values: Options: ${optionSpec} select time - + Time can also be specified using special values: "dawn" - From night to sunrise. - + -h, --help display help for command `; diff --git a/typings/index.d.ts b/typings/index.d.ts index 695c3bd25..b596a88ff 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -193,9 +193,20 @@ export class Option { isBoolean(): boolean; } +export interface WrapOptions { + leadingStrLength?: number; + minWidthGuideline?: number; + preformatted?: boolean; + overflowShift?: number; + globalIndent?: number; + columnGap?: number; +} + export class Help { /** output helpWidth, long lines are wrapped to fit */ helpWidth?: number; + minWidthGuideline: number; + preformatted?: boolean; sortSubcommands: boolean; sortOptions: boolean; showGlobalOptions: boolean; @@ -241,10 +252,22 @@ export class Help { 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. + * Merge left text column defined by first `leadingStrLength` characters of `str` with right text column defined by remaining characters, wrapping the output to `width - 1` characters per line. + * + * Do not wrap if right column text is manually formatted. + * + * Lines containing left column are indented by `globalIndent - Math.min(0, fullIndent)` and all new lines due to overflow by `fullIndent` if it is positive and does not cause text display width to be too narrow, where `fullIndent = globalIndent + leadingStrWidth + columnGap + overflowShift` with `leadingStrWidth` being the computed width of the left column. `leadingStrWidth` and `columnGap` are omitted from the computation if right column text is manually formatted. + * + * `leadingStrLength`, `overflowShift`, `globalIndent` and `columnGap` all default to 0. + * + * Text display width is considered too narrow when it is less than `minWidthGuideline` which defaults to 40. + * + * Unless `preformatted` is specified explicitly, right column text is considered manually formatted if it includes a line break followed by a whitespace. */ - wrap(str: string, width: number, indent: number, minColumnWidth?: number): string; + wrap(str: string, width: number, options?: WrapOptions): string; + wrap(str: string, width: number, leadingStrLength: number, options?: WrapOptions): string; + wrap(str: string, width: number, leadingStrLength: number, minWidthGuideline: number, options?: WrapOptions): string; + wrap(str: string, width: number, leadingStrLength: number, minWidthGuideline: number, preformatted: boolean, options?: WrapOptions): string; /** Generate the built-in help text. */ formatHelp(cmd: Command, helper: Help): string;