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
270 changes: 237 additions & 33 deletions lib/help.js
@@ -1,11 +1,13 @@
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`
* https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#import-types
* @typedef { import("./argument.js").Argument } Argument
* @typedef { import("./command.js").Command } Command
* @typedef { import("./option.js").Option } Option
* @typedef { import("..").WrapOptions } WrapOptions
*/

// @ts-check
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
}
}
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.