Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework Help.wrap() #1904

Closed
wants to merge 11 commits into from
2 changes: 1 addition & 1 deletion lib/error.js
Expand Up @@ -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) {
Expand Down
187 changes: 156 additions & 31 deletions 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`
Expand Down Expand Up @@ -352,13 +353,21 @@ class Help {
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);
const fullText = `${term.padEnd(termWidth)}${description}`;
return helper.wrap(
fullText,
helpWidth,
termWidth,
40,
0,
itemIndentWidth,
itemSeparatorWidth
);
}
return term;
return ' '.repeat(itemIndentWidth) + term;
}
function formatList(textArray) {
return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth));
return textArray.join('\n');
}

// Usage
Expand Down Expand Up @@ -424,39 +433,155 @@ 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.
* 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 {number} indent
* @param {number} [minColumnWidth=40]
* @param {number} [leadingStrLength=0]
* @param {number} [minWidthGuideline=40]
* @param {number} [overflowShift=0]
* @param {number} [globalIndent=0]
* @param {number} [columnGap=0]
* @param {boolean} [preformatted]
* @return {string}
*
*/

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();
wrap(
str,
width,
leadingStrLength = 0,
minWidthGuideline = 40,
overflowShift = 0,
globalIndent = 0,
columnGap = 0,
preformatted
) {
// TODO: Error message
if (width < 0 || leadingStrLength < 0 || globalIndent < 0 || columnGap < 0) {
throw new CommanderError(0, 'commander.helpWrapInvalidArgument', '');
}

if (width === undefined) width = Infinity;

const newline = /\r?\n/;

const leadingStr = str.slice(0, leadingStrLength);
const leadingStrLines = leadingStr.split(newline);
const leadingStrWidth = leadingStrLines.reduce(
(max, line) => Math.max(max, line.length), 0
);
leadingStrLines.forEach((line, i) => {
leadingStrLines[i] = line.padEnd(leadingStrWidth);
});

let columnText = str.slice(leadingStrLength).replaceAll('\r\n', '\n');

if (preformatted === undefined) {
// Full \s characters, minus line terminatros (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(columnText);
}
const nowrap = preformatted || width === Number.POSITIVE_INFINITY;

const lines = nowrap ? columnText.split(newline) : [];
const missingLineCount = () => Math.max(
0, leadingStrLines.length - lines.length
);
const missingLineArray = () => Array(missingLineCount()).fill('');

// 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.
const fullIndent = globalIndent + Number(!preformatted) * (
leadingStrWidth + columnGap
) + overflowShift;

// 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) {
const columnWidth = width - globalIndent - leadingStrWidth - columnGap;

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) => {
columnText = columnText.slice(index); // consume non-overflowing part
overflow = true;
regex = overflowRegex;
};

while (regex.lastIndex < columnText.length) { // input is not yet fully consumed
const { 0: match, 1: line, index } = regex.exec(columnText);
if (!overflow && overflowShift < 0 && line == null) {
// If word does not fit in column,
// it might still fit in overflow when overflowShift is negative.
// Leave column empty for when user wants to add leading string manually afterwards.
lines.push(...missingLineArray());
setOverflow(index);
} else {
lines.push(line ?? match);
if (!overflow && lines.length >= leadingStrLines.length) {
setOverflow(regex.lastIndex);
}
}
}
}

const globalIndentString = ' '.repeat(globalIndent);
const columnGapString = ' '.repeat(columnGap);
const overflowIndentString = ' '.repeat(overflowIndent);

return lines.concat(missingLineArray()).map((line, i) => {
const prefix = i < leadingStrLines.length
? globalIndentString + leadingStrLines[i] + columnGapString
: overflowIndentString;
return preformatted
? line
? prefix + line
: prefix.trimEnd() + line // leadingStr is assumed to not be preformatted
: (prefix + line).trimEnd();
}).join('\n');
}
}
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

98 changes: 86 additions & 12 deletions tests/help.wrap.test.js
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -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, 40, -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, 40, -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', () => {
Expand Down Expand Up @@ -69,6 +113,33 @@ ${' '.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, 0);
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, 0, 40, 3, 3, 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, 40, -3, 3, 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();
Expand Down Expand Up @@ -141,23 +212,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);
Expand All @@ -180,10 +254,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
`;

Expand Down