710 lines
19 KiB
JavaScript
710 lines
19 KiB
JavaScript
const { humanReadableArgName } = require('./argument.js');
|
|
|
|
/**
|
|
* TypeScript import types for JSDoc, used by Visual Studio Code IntelliSense and `npm run typescript-checkJS`
|
|
* https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#import-types
|
|
* @typedef { import("./argument.js").Argument } Argument
|
|
* @typedef { import("./command.js").Command } Command
|
|
* @typedef { import("./option.js").Option } Option
|
|
*/
|
|
|
|
// Although this is a class, methods are static in style to allow override using subclass or just functions.
|
|
class Help {
|
|
constructor() {
|
|
this.helpWidth = undefined;
|
|
this.minWidthToWrap = 40;
|
|
this.sortSubcommands = false;
|
|
this.sortOptions = false;
|
|
this.showGlobalOptions = false;
|
|
}
|
|
|
|
/**
|
|
* prepareContext is called by Commander after applying overrides from `Command.configureHelp()`
|
|
* and just before calling `formatHelp()`.
|
|
*
|
|
* Commander just uses the helpWidth and the rest is provided for optional use by more complex subclasses.
|
|
*
|
|
* @param {{ error?: boolean, helpWidth?: number, outputHasColors?: boolean }} contextOptions
|
|
*/
|
|
prepareContext(contextOptions) {
|
|
this.helpWidth = this.helpWidth ?? contextOptions.helpWidth ?? 80;
|
|
}
|
|
|
|
/**
|
|
* Get an array of the visible subcommands. Includes a placeholder for the implicit help command, if there is one.
|
|
*
|
|
* @param {Command} cmd
|
|
* @returns {Command[]}
|
|
*/
|
|
|
|
visibleCommands(cmd) {
|
|
const visibleCommands = cmd.commands.filter((cmd) => !cmd._hidden);
|
|
const helpCommand = cmd._getHelpCommand();
|
|
if (helpCommand && !helpCommand._hidden) {
|
|
visibleCommands.push(helpCommand);
|
|
}
|
|
if (this.sortSubcommands) {
|
|
visibleCommands.sort((a, b) => {
|
|
// @ts-ignore: because overloaded return type
|
|
return a.name().localeCompare(b.name());
|
|
});
|
|
}
|
|
return visibleCommands;
|
|
}
|
|
|
|
/**
|
|
* Compare options for sort.
|
|
*
|
|
* @param {Option} a
|
|
* @param {Option} b
|
|
* @returns {number}
|
|
*/
|
|
compareOptions(a, b) {
|
|
const getSortKey = (option) => {
|
|
// WYSIWYG for order displayed in help. Short used for comparison if present. No special handling for negated.
|
|
return option.short
|
|
? option.short.replace(/^-/, '')
|
|
: option.long.replace(/^--/, '');
|
|
};
|
|
return getSortKey(a).localeCompare(getSortKey(b));
|
|
}
|
|
|
|
/**
|
|
* Get an array of the visible options. Includes a placeholder for the implicit help option, if there is one.
|
|
*
|
|
* @param {Command} cmd
|
|
* @returns {Option[]}
|
|
*/
|
|
|
|
visibleOptions(cmd) {
|
|
const visibleOptions = cmd.options.filter((option) => !option.hidden);
|
|
// Built-in help option.
|
|
const helpOption = cmd._getHelpOption();
|
|
if (helpOption && !helpOption.hidden) {
|
|
// Automatically hide conflicting flags. Bit dubious but a historical behaviour that is convenient for single-command programs.
|
|
const removeShort = helpOption.short && cmd._findOption(helpOption.short);
|
|
const removeLong = helpOption.long && cmd._findOption(helpOption.long);
|
|
if (!removeShort && !removeLong) {
|
|
visibleOptions.push(helpOption); // no changes needed
|
|
} else if (helpOption.long && !removeLong) {
|
|
visibleOptions.push(
|
|
cmd.createOption(helpOption.long, helpOption.description),
|
|
);
|
|
} else if (helpOption.short && !removeShort) {
|
|
visibleOptions.push(
|
|
cmd.createOption(helpOption.short, helpOption.description),
|
|
);
|
|
}
|
|
}
|
|
if (this.sortOptions) {
|
|
visibleOptions.sort(this.compareOptions);
|
|
}
|
|
return visibleOptions;
|
|
}
|
|
|
|
/**
|
|
* Get an array of the visible global options. (Not including help.)
|
|
*
|
|
* @param {Command} cmd
|
|
* @returns {Option[]}
|
|
*/
|
|
|
|
visibleGlobalOptions(cmd) {
|
|
if (!this.showGlobalOptions) return [];
|
|
|
|
const globalOptions = [];
|
|
for (
|
|
let ancestorCmd = cmd.parent;
|
|
ancestorCmd;
|
|
ancestorCmd = ancestorCmd.parent
|
|
) {
|
|
const visibleOptions = ancestorCmd.options.filter(
|
|
(option) => !option.hidden,
|
|
);
|
|
globalOptions.push(...visibleOptions);
|
|
}
|
|
if (this.sortOptions) {
|
|
globalOptions.sort(this.compareOptions);
|
|
}
|
|
return globalOptions;
|
|
}
|
|
|
|
/**
|
|
* Get an array of the arguments if any have a description.
|
|
*
|
|
* @param {Command} cmd
|
|
* @returns {Argument[]}
|
|
*/
|
|
|
|
visibleArguments(cmd) {
|
|
// Side effect! Apply the legacy descriptions before the arguments are displayed.
|
|
if (cmd._argsDescription) {
|
|
cmd.registeredArguments.forEach((argument) => {
|
|
argument.description =
|
|
argument.description || cmd._argsDescription[argument.name()] || '';
|
|
});
|
|
}
|
|
|
|
// If there are any arguments with a description then return all the arguments.
|
|
if (cmd.registeredArguments.find((argument) => argument.description)) {
|
|
return cmd.registeredArguments;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Get the command term to show in the list of subcommands.
|
|
*
|
|
* @param {Command} cmd
|
|
* @returns {string}
|
|
*/
|
|
|
|
subcommandTerm(cmd) {
|
|
// Legacy. Ignores custom usage string, and nested commands.
|
|
const args = cmd.registeredArguments
|
|
.map((arg) => humanReadableArgName(arg))
|
|
.join(' ');
|
|
return (
|
|
cmd._name +
|
|
(cmd._aliases[0] ? '|' + cmd._aliases[0] : '') +
|
|
(cmd.options.length ? ' [options]' : '') + // simplistic check for non-help option
|
|
(args ? ' ' + args : '')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the option term to show in the list of options.
|
|
*
|
|
* @param {Option} option
|
|
* @returns {string}
|
|
*/
|
|
|
|
optionTerm(option) {
|
|
return option.flags;
|
|
}
|
|
|
|
/**
|
|
* Get the argument term to show in the list of arguments.
|
|
*
|
|
* @param {Argument} argument
|
|
* @returns {string}
|
|
*/
|
|
|
|
argumentTerm(argument) {
|
|
return argument.name();
|
|
}
|
|
|
|
/**
|
|
* Get the longest command term length.
|
|
*
|
|
* @param {Command} cmd
|
|
* @param {Help} helper
|
|
* @returns {number}
|
|
*/
|
|
|
|
longestSubcommandTermLength(cmd, helper) {
|
|
return helper.visibleCommands(cmd).reduce((max, command) => {
|
|
return Math.max(
|
|
max,
|
|
this.displayWidth(
|
|
helper.styleSubcommandTerm(helper.subcommandTerm(command)),
|
|
),
|
|
);
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Get the longest option term length.
|
|
*
|
|
* @param {Command} cmd
|
|
* @param {Help} helper
|
|
* @returns {number}
|
|
*/
|
|
|
|
longestOptionTermLength(cmd, helper) {
|
|
return helper.visibleOptions(cmd).reduce((max, option) => {
|
|
return Math.max(
|
|
max,
|
|
this.displayWidth(helper.styleOptionTerm(helper.optionTerm(option))),
|
|
);
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Get the longest global option term length.
|
|
*
|
|
* @param {Command} cmd
|
|
* @param {Help} helper
|
|
* @returns {number}
|
|
*/
|
|
|
|
longestGlobalOptionTermLength(cmd, helper) {
|
|
return helper.visibleGlobalOptions(cmd).reduce((max, option) => {
|
|
return Math.max(
|
|
max,
|
|
this.displayWidth(helper.styleOptionTerm(helper.optionTerm(option))),
|
|
);
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Get the longest argument term length.
|
|
*
|
|
* @param {Command} cmd
|
|
* @param {Help} helper
|
|
* @returns {number}
|
|
*/
|
|
|
|
longestArgumentTermLength(cmd, helper) {
|
|
return helper.visibleArguments(cmd).reduce((max, argument) => {
|
|
return Math.max(
|
|
max,
|
|
this.displayWidth(
|
|
helper.styleArgumentTerm(helper.argumentTerm(argument)),
|
|
),
|
|
);
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Get the command usage to be displayed at the top of the built-in help.
|
|
*
|
|
* @param {Command} cmd
|
|
* @returns {string}
|
|
*/
|
|
|
|
commandUsage(cmd) {
|
|
// Usage
|
|
let cmdName = cmd._name;
|
|
if (cmd._aliases[0]) {
|
|
cmdName = cmdName + '|' + cmd._aliases[0];
|
|
}
|
|
let ancestorCmdNames = '';
|
|
for (
|
|
let ancestorCmd = cmd.parent;
|
|
ancestorCmd;
|
|
ancestorCmd = ancestorCmd.parent
|
|
) {
|
|
ancestorCmdNames = ancestorCmd.name() + ' ' + ancestorCmdNames;
|
|
}
|
|
return ancestorCmdNames + cmdName + ' ' + cmd.usage();
|
|
}
|
|
|
|
/**
|
|
* Get the description for the command.
|
|
*
|
|
* @param {Command} cmd
|
|
* @returns {string}
|
|
*/
|
|
|
|
commandDescription(cmd) {
|
|
// @ts-ignore: because overloaded return type
|
|
return cmd.description();
|
|
}
|
|
|
|
/**
|
|
* Get the subcommand summary to show in the list of subcommands.
|
|
* (Fallback to description for backwards compatibility.)
|
|
*
|
|
* @param {Command} cmd
|
|
* @returns {string}
|
|
*/
|
|
|
|
subcommandDescription(cmd) {
|
|
// @ts-ignore: because overloaded return type
|
|
return cmd.summary() || cmd.description();
|
|
}
|
|
|
|
/**
|
|
* Get the option description to show in the list of options.
|
|
*
|
|
* @param {Option} option
|
|
* @return {string}
|
|
*/
|
|
|
|
optionDescription(option) {
|
|
const extraInfo = [];
|
|
|
|
if (option.argChoices) {
|
|
extraInfo.push(
|
|
// use stringify to match the display of the default value
|
|
`choices: ${option.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`,
|
|
);
|
|
}
|
|
if (option.defaultValue !== undefined) {
|
|
// default for boolean and negated more for programmer than end user,
|
|
// but show true/false for boolean option as may be for hand-rolled env or config processing.
|
|
const showDefault =
|
|
option.required ||
|
|
option.optional ||
|
|
(option.isBoolean() && typeof option.defaultValue === 'boolean');
|
|
if (showDefault) {
|
|
extraInfo.push(
|
|
`default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`,
|
|
);
|
|
}
|
|
}
|
|
// preset for boolean and negated are more for programmer than end user
|
|
if (option.presetArg !== undefined && option.optional) {
|
|
extraInfo.push(`preset: ${JSON.stringify(option.presetArg)}`);
|
|
}
|
|
if (option.envVar !== undefined) {
|
|
extraInfo.push(`env: ${option.envVar}`);
|
|
}
|
|
if (extraInfo.length > 0) {
|
|
return `${option.description} (${extraInfo.join(', ')})`;
|
|
}
|
|
|
|
return option.description;
|
|
}
|
|
|
|
/**
|
|
* Get the argument description to show in the list of arguments.
|
|
*
|
|
* @param {Argument} argument
|
|
* @return {string}
|
|
*/
|
|
|
|
argumentDescription(argument) {
|
|
const extraInfo = [];
|
|
if (argument.argChoices) {
|
|
extraInfo.push(
|
|
// use stringify to match the display of the default value
|
|
`choices: ${argument.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`,
|
|
);
|
|
}
|
|
if (argument.defaultValue !== undefined) {
|
|
extraInfo.push(
|
|
`default: ${argument.defaultValueDescription || JSON.stringify(argument.defaultValue)}`,
|
|
);
|
|
}
|
|
if (extraInfo.length > 0) {
|
|
const extraDescription = `(${extraInfo.join(', ')})`;
|
|
if (argument.description) {
|
|
return `${argument.description} ${extraDescription}`;
|
|
}
|
|
return extraDescription;
|
|
}
|
|
return argument.description;
|
|
}
|
|
|
|
/**
|
|
* Generate the built-in help text.
|
|
*
|
|
* @param {Command} cmd
|
|
* @param {Help} helper
|
|
* @returns {string}
|
|
*/
|
|
|
|
formatHelp(cmd, helper) {
|
|
const termWidth = helper.padWidth(cmd, helper);
|
|
const helpWidth = helper.helpWidth ?? 80; // in case prepareContext() was not called
|
|
|
|
function callFormatItem(term, description) {
|
|
return helper.formatItem(term, termWidth, description, helper);
|
|
}
|
|
|
|
// Usage
|
|
let output = [
|
|
`${helper.styleTitle('Usage:')} ${helper.styleUsage(helper.commandUsage(cmd))}`,
|
|
'',
|
|
];
|
|
|
|
// Description
|
|
const commandDescription = helper.commandDescription(cmd);
|
|
if (commandDescription.length > 0) {
|
|
output = output.concat([
|
|
helper.boxWrap(
|
|
helper.styleCommandDescription(commandDescription),
|
|
helpWidth,
|
|
),
|
|
'',
|
|
]);
|
|
}
|
|
|
|
// Arguments
|
|
const argumentList = helper.visibleArguments(cmd).map((argument) => {
|
|
return callFormatItem(
|
|
helper.styleArgumentTerm(helper.argumentTerm(argument)),
|
|
helper.styleArgumentDescription(helper.argumentDescription(argument)),
|
|
);
|
|
});
|
|
if (argumentList.length > 0) {
|
|
output = output.concat([
|
|
helper.styleTitle('Arguments:'),
|
|
...argumentList,
|
|
'',
|
|
]);
|
|
}
|
|
|
|
// Options
|
|
const optionList = helper.visibleOptions(cmd).map((option) => {
|
|
return callFormatItem(
|
|
helper.styleOptionTerm(helper.optionTerm(option)),
|
|
helper.styleOptionDescription(helper.optionDescription(option)),
|
|
);
|
|
});
|
|
if (optionList.length > 0) {
|
|
output = output.concat([
|
|
helper.styleTitle('Options:'),
|
|
...optionList,
|
|
'',
|
|
]);
|
|
}
|
|
|
|
if (helper.showGlobalOptions) {
|
|
const globalOptionList = helper
|
|
.visibleGlobalOptions(cmd)
|
|
.map((option) => {
|
|
return callFormatItem(
|
|
helper.styleOptionTerm(helper.optionTerm(option)),
|
|
helper.styleOptionDescription(helper.optionDescription(option)),
|
|
);
|
|
});
|
|
if (globalOptionList.length > 0) {
|
|
output = output.concat([
|
|
helper.styleTitle('Global Options:'),
|
|
...globalOptionList,
|
|
'',
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Commands
|
|
const commandList = helper.visibleCommands(cmd).map((cmd) => {
|
|
return callFormatItem(
|
|
helper.styleSubcommandTerm(helper.subcommandTerm(cmd)),
|
|
helper.styleSubcommandDescription(helper.subcommandDescription(cmd)),
|
|
);
|
|
});
|
|
if (commandList.length > 0) {
|
|
output = output.concat([
|
|
helper.styleTitle('Commands:'),
|
|
...commandList,
|
|
'',
|
|
]);
|
|
}
|
|
|
|
return output.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Return display width of string, ignoring ANSI escape sequences. Used in padding and wrapping calculations.
|
|
*
|
|
* @param {string} str
|
|
* @returns {number}
|
|
*/
|
|
displayWidth(str) {
|
|
return stripColor(str).length;
|
|
}
|
|
|
|
/**
|
|
* Style the title for displaying in the help. Called with 'Usage:', 'Options:', etc.
|
|
*
|
|
* @param {string} str
|
|
* @returns {string}
|
|
*/
|
|
styleTitle(str) {
|
|
return str;
|
|
}
|
|
|
|
styleUsage(str) {
|
|
// Usage has lots of parts the user might like to color separately! Assume default usage string which is formed like:
|
|
// command subcommand [options] [command] <foo> [bar]
|
|
return str
|
|
.split(' ')
|
|
.map((word) => {
|
|
if (word === '[options]') return this.styleOptionText(word);
|
|
if (word === '[command]') return this.styleSubcommandText(word);
|
|
if (word[0] === '[' || word[0] === '<')
|
|
return this.styleArgumentText(word);
|
|
return this.styleCommandText(word); // Restrict to initial words?
|
|
})
|
|
.join(' ');
|
|
}
|
|
styleCommandDescription(str) {
|
|
return this.styleDescriptionText(str);
|
|
}
|
|
styleOptionDescription(str) {
|
|
return this.styleDescriptionText(str);
|
|
}
|
|
styleSubcommandDescription(str) {
|
|
return this.styleDescriptionText(str);
|
|
}
|
|
styleArgumentDescription(str) {
|
|
return this.styleDescriptionText(str);
|
|
}
|
|
styleDescriptionText(str) {
|
|
return str;
|
|
}
|
|
styleOptionTerm(str) {
|
|
return this.styleOptionText(str);
|
|
}
|
|
styleSubcommandTerm(str) {
|
|
// This is very like usage with lots of parts! Assume default string which is formed like:
|
|
// subcommand [options] <foo> [bar]
|
|
return str
|
|
.split(' ')
|
|
.map((word) => {
|
|
if (word === '[options]') return this.styleOptionText(word);
|
|
if (word[0] === '[' || word[0] === '<')
|
|
return this.styleArgumentText(word);
|
|
return this.styleSubcommandText(word); // Restrict to initial words?
|
|
})
|
|
.join(' ');
|
|
}
|
|
styleArgumentTerm(str) {
|
|
return this.styleArgumentText(str);
|
|
}
|
|
styleOptionText(str) {
|
|
return str;
|
|
}
|
|
styleArgumentText(str) {
|
|
return str;
|
|
}
|
|
styleSubcommandText(str) {
|
|
return str;
|
|
}
|
|
styleCommandText(str) {
|
|
return str;
|
|
}
|
|
|
|
/**
|
|
* Calculate the pad width from the maximum term length.
|
|
*
|
|
* @param {Command} cmd
|
|
* @param {Help} helper
|
|
* @returns {number}
|
|
*/
|
|
|
|
padWidth(cmd, helper) {
|
|
return Math.max(
|
|
helper.longestOptionTermLength(cmd, helper),
|
|
helper.longestGlobalOptionTermLength(cmd, helper),
|
|
helper.longestSubcommandTermLength(cmd, helper),
|
|
helper.longestArgumentTermLength(cmd, helper),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Detect manually wrapped and indented strings by checking for line break followed by whitespace.
|
|
*
|
|
* @param {string} str
|
|
* @returns {boolean}
|
|
*/
|
|
preformatted(str) {
|
|
return /\n[^\S\r\n]/.test(str);
|
|
}
|
|
|
|
/**
|
|
* Format the "item", which consists of a term and description. Pad the term and wrap the description, indenting the following lines.
|
|
*
|
|
* So "TTT", 5, "DDD DDDD DD DDD" might be formatted for this.helpWidth=17 like so:
|
|
* TTT DDD DDDD
|
|
* DD DDD
|
|
*
|
|
* @param {string} term
|
|
* @param {number} termWidth
|
|
* @param {string} description
|
|
* @param {Help} helper
|
|
* @returns {string}
|
|
*/
|
|
formatItem(term, termWidth, description, helper) {
|
|
const itemIndent = 2;
|
|
const itemIndentStr = ' '.repeat(itemIndent);
|
|
if (!description) return itemIndentStr + term;
|
|
|
|
// Pad the term out to a consistent width, so descriptions are aligned.
|
|
const paddedTerm = term.padEnd(
|
|
termWidth + term.length - helper.displayWidth(term),
|
|
);
|
|
|
|
// Format the description.
|
|
const spacerWidth = 2; // between term and description
|
|
const helpWidth = this.helpWidth ?? 80; // in case prepareContext() was not called
|
|
const remainingWidth = helpWidth - termWidth - spacerWidth - itemIndent;
|
|
let formattedDescription;
|
|
if (
|
|
remainingWidth < this.minWidthToWrap ||
|
|
helper.preformatted(description)
|
|
) {
|
|
formattedDescription = description;
|
|
} else {
|
|
const wrappedDescription = helper.boxWrap(description, remainingWidth);
|
|
formattedDescription = wrappedDescription.replace(
|
|
/\n/g,
|
|
'\n' + ' '.repeat(termWidth + spacerWidth),
|
|
);
|
|
}
|
|
|
|
// Construct and overall indent.
|
|
return (
|
|
itemIndentStr +
|
|
paddedTerm +
|
|
' '.repeat(spacerWidth) +
|
|
formattedDescription.replace(/\n/g, `\n${itemIndentStr}`)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Wrap a string at whitespace, preserving existing line breaks.
|
|
* Wrapping is skipped if the width is less than `minWidthToWrap`.
|
|
*
|
|
* @param {string} str
|
|
* @param {number} width
|
|
* @returns {string}
|
|
*/
|
|
boxWrap(str, width) {
|
|
if (width < this.minWidthToWrap) return str;
|
|
|
|
const rawLines = str.split(/\r\n|\n/);
|
|
// split up text by whitespace
|
|
const chunkPattern = /[\s]*[^\s]+/g;
|
|
const wrappedLines = [];
|
|
rawLines.forEach((line) => {
|
|
const chunks = line.match(chunkPattern);
|
|
if (chunks === null) {
|
|
wrappedLines.push('');
|
|
return;
|
|
}
|
|
|
|
let sumChunks = [chunks.shift()];
|
|
let sumWidth = this.displayWidth(sumChunks[0]);
|
|
chunks.forEach((chunk) => {
|
|
const visibleWidth = this.displayWidth(chunk);
|
|
// Accumulate chunks while they fit into width.
|
|
if (sumWidth + visibleWidth <= width) {
|
|
sumChunks.push(chunk);
|
|
sumWidth += visibleWidth;
|
|
return;
|
|
}
|
|
wrappedLines.push(sumChunks.join(''));
|
|
|
|
const nextChunk = chunk.trimStart(); // trim space at line break
|
|
sumChunks = [nextChunk];
|
|
sumWidth = this.displayWidth(nextChunk);
|
|
});
|
|
wrappedLines.push(sumChunks.join(''));
|
|
});
|
|
|
|
return wrappedLines.join('\n');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strip style ANSI escape sequences from the string. In particular, SGR (Select Graphic Rendition) codes.
|
|
*
|
|
* @param {string} str
|
|
* @returns {string}
|
|
* @package
|
|
*/
|
|
|
|
function stripColor(str) {
|
|
// eslint-disable-next-line no-control-regex
|
|
const sgrPattern = /\x1b\[\d*(;\d*)*m/g;
|
|
return str.replace(sgrPattern, '');
|
|
}
|
|
|
|
exports.Help = Help;
|
|
exports.stripColor = stripColor;
|