From f78c4ee01db67d9f78c8b183a529f818bd0a4a23 Mon Sep 17 00:00:00 2001 From: Sergei Vizel Date: Sat, 17 Apr 2021 08:26:36 +0300 Subject: [PATCH] Split 'Command' & 'Help' (#1) --- index.js | 24 ++- src/argument.js | 17 +++ src/command.js | 395 +----------------------------------------------- src/errors.js | 19 +++ src/help.js | 368 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 435 insertions(+), 388 deletions(-) create mode 100644 src/help.js diff --git a/index.js b/index.js index 8bb314bd9..600194997 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,32 @@ const { Argument } = require('./src/argument'); -const { Command, Help } = require('./src/command'); +const { Command: BaseCommand } = require('./src/command'); const { CommanderError, InvalidOptionArgumentError } = require('./src/errors'); const { Option } = require('./src/option'); +const { Help } = require('./src/help'); // @ts-check +class Command extends BaseCommand { + /** + * @inheritdoc + */ + + createCommand(...args) { + return new Command(...args); + }; + + /** + * You can customise the help with a subclass of Help by overriding createHelp, + * or by overriding Help properties using configureHelp(). + * + * @return {Help} + */ + + createHelp() { + return Object.assign(new Help(), this.configureHelp()); + }; +} + /** * Expose the root command. */ diff --git a/src/argument.js b/src/argument.js index 281f6bba0..a88e84510 100644 --- a/src/argument.js +++ b/src/argument.js @@ -46,4 +46,21 @@ class Argument { }; } +/** + * Takes an argument and returns its human readable equivalent for help usage. + * + * @param {Argument} arg + * @return {string} + * @api private + */ + +function humanReadableArgName(arg) { + const nameOutput = arg.name() + (arg.variadic === true ? '...' : ''); + + return arg.required + ? '<' + nameOutput + '>' + : '[' + nameOutput + ']'; +} + module.exports.Argument = Argument; +module.exports.humanReadableArgName = humanReadableArgName; diff --git a/src/command.js b/src/command.js index 2f7b730cc..d66046423 100644 --- a/src/command.js +++ b/src/command.js @@ -3,8 +3,8 @@ const childProcess = require('child_process'); const path = require('path'); const fs = require('fs'); -const { Argument } = require('./argument'); -const { CommanderError } = require('./errors'); +const { Argument, humanReadableArgName } = require('./argument'); +const { CommanderError, NotImplementedError } = require('./errors'); const { Option, parseOptionFlags } = require('./option'); // Command and Help have circular dependency so both declared here. @@ -151,14 +151,15 @@ class Command extends EventEmitter { }; /** - * You can customise the help with a subclass of Help by overriding createHelp, - * or by overriding Help properties using configureHelp(). - * - * @return {Help} + * @return {{helpWidth: number|undefined, formatHelp: Function}} */ createHelp() { - return Object.assign(new Help(), this.configureHelp()); + return Object.assign({ + formatHelp: function() { + throw new NotImplementedError('formatHelp not implemented.'); + } + }, this.configureHelp()); }; /** @@ -1621,385 +1622,6 @@ function incrementNodeInspectorPort(args) { }); } -// 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.sortSubcommands = false; - this.sortOptions = false; - } - - /** - * 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); - if (cmd._hasImplicitHelpCommand()) { - // Create a command matching the implicit help command. - const [, helpName, helpArgs] = cmd._helpCommandnameAndArgs.match(/([^ ]+) *(.*)/); - const helpCommand = cmd.createCommand(helpName) - .helpOption(false); - helpCommand.description(cmd._helpCommandDescription); - if (helpArgs) helpCommand.arguments(helpArgs); - visibleCommands.push(helpCommand); - } - if (this.sortSubcommands) { - visibleCommands.sort((a, b) => { - return a.name().localeCompare(b.name()); - }); - } - return visibleCommands; - } - - /** - * 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); - // Implicit help - const showShortHelpFlag = cmd._hasHelpOption && cmd._helpShortFlag && !cmd._findOption(cmd._helpShortFlag); - const showLongHelpFlag = cmd._hasHelpOption && !cmd._findOption(cmd._helpLongFlag); - if (showShortHelpFlag || showLongHelpFlag) { - let helpOption; - if (!showShortHelpFlag) { - helpOption = cmd.createOption(cmd._helpLongFlag, cmd._helpDescription); - } else if (!showLongHelpFlag) { - helpOption = cmd.createOption(cmd._helpShortFlag, cmd._helpDescription); - } else { - helpOption = cmd.createOption(cmd._helpFlags, cmd._helpDescription); - } - visibleOptions.push(helpOption); - } - if (this.sortOptions) { - const getSortKey = (option) => { - // WYSIWYG for order displayed in help with short before long, no special handling for negated. - return option.short ? option.short.replace(/^-/, '') : option.long.replace(/^--/, ''); - }; - visibleOptions.sort((a, b) => { - return getSortKey(a).localeCompare(getSortKey(b)); - }); - } - return visibleOptions; - } - - /** - * 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._args.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._args.find(argument => argument.description)) { - return cmd._args; - }; - 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._args.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, helper.subcommandTerm(command).length); - }, 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, helper.optionTerm(option).length); - }, 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, helper.argumentTerm(argument).length); - }, 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 parentCmdNames = ''; - for (let parentCmd = cmd.parent; parentCmd; parentCmd = parentCmd.parent) { - parentCmdNames = parentCmd.name() + ' ' + parentCmdNames; - } - return parentCmdNames + cmdName + ' ' + cmd.usage(); - } - - /** - * Get the description for the command. - * - * @param {Command} cmd - * @returns {string} - */ - - commandDescription(cmd) { - // @ts-ignore: overloaded return type - return cmd.description(); - } - - /** - * Get the command description to show in the list of subcommands. - * - * @param {Command} cmd - * @returns {string} - */ - - subcommandDescription(cmd) { - // @ts-ignore: overloaded return type - return cmd.description(); - } - - /** - * Get the option description to show in the list of options. - * - * @param {Option} option - * @return {string} - */ - - optionDescription(option) { - if (option.negate) { - return option.description; - } - 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) { - extraInfo.push(`default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`); - } - 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) { - 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; - 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)); - } - - // Usage - let output = [`Usage: ${helper.commandUsage(cmd)}`, '']; - - // Description - const commandDescription = helper.commandDescription(cmd); - if (commandDescription.length > 0) { - output = output.concat([commandDescription, '']); - } - - // Arguments - const argumentList = helper.visibleArguments(cmd).map((argument) => { - return formatItem(helper.argumentTerm(argument), helper.argumentDescription(argument)); - }); - if (argumentList.length > 0) { - output = output.concat(['Arguments:', formatList(argumentList), '']); - } - - // Options - const optionList = helper.visibleOptions(cmd).map((option) => { - return formatItem(helper.optionTerm(option), helper.optionDescription(option)); - }); - if (optionList.length > 0) { - output = output.concat(['Options:', formatList(optionList), '']); - } - - // Commands - const commandList = helper.visibleCommands(cmd).map((cmd) => { - return formatItem(helper.subcommandTerm(cmd), helper.subcommandDescription(cmd)); - }); - if (commandList.length > 0) { - output = output.concat(['Commands:', formatList(commandList), '']); - } - - return output.join('\n'); - } - - /** - * 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.longestSubcommandTermLength(cmd, helper), - helper.longestArgumentTermLength(cmd, helper) - ); - }; - - /** - * 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. - * - * @param {string} str - * @param {number} width - * @param {number} indent - * @param {number} [minColumnWidth=40] - * @return {string} - * - */ - - wrap(str, width, indent, minColumnWidth = 40) { - // Detect manually wrapped and indented strings by searching for line breaks - // followed by multiple spaces/tabs. - if (str.match(/[\n]\s+/)) 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.substr(0, indent); - const columnText = str.substr(indent); - - const indentString = ' '.repeat(indent); - const regex = new RegExp('.{1,' + (columnWidth - 1) + '}([\\s\u200B]|$)|[^\\s\u200B]+?([\\s\u200B]|$)', 'g'); - const lines = columnText.match(regex) || []; - return leadingStr + lines.map((line, i) => { - if (line.slice(-1) === '\n') { - line = line.slice(0, line.length - 1); - } - return ((i > 0) ? indentString : '') + line.trimRight(); - }).join('\n'); - } -} - -/** - * Takes an argument and returns its human readable equivalent for help usage. - * - * @param {Argument} arg - * @return {string} - * @api private - */ - -function humanReadableArgName(arg) { - const nameOutput = arg.name() + (arg.variadic === true ? '...' : ''); - - return arg.required - ? '<' + nameOutput + '>' - : '[' + nameOutput + ']'; -} - /** * Output help information if help flags specified * @@ -2018,4 +1640,3 @@ function outputHelpIfRequested(cmd, args) { } module.exports.Command = Command; -module.exports.Help = Help; diff --git a/src/errors.js b/src/errors.js index de26f2a1c..1f57f9cca 100644 --- a/src/errors.js +++ b/src/errors.js @@ -41,5 +41,24 @@ class InvalidOptionArgumentError extends CommanderError { } } +/** + * NotImplementedError class + * @class + */ +class NotImplementedError extends CommanderError { + /** + * Constructs the NotImplementedError class + * @param {string} [message] human-readable description of the error + * @constructor + */ + constructor(message) { + super(1, 'commander.notImplemented', message); + // properly capture stack trace in Node.js + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + } +} + module.exports.CommanderError = CommanderError; module.exports.InvalidOptionArgumentError = InvalidOptionArgumentError; +module.exports.NotImplementedError = NotImplementedError; diff --git a/src/help.js b/src/help.js new file mode 100644 index 000000000..66dd061d5 --- /dev/null +++ b/src/help.js @@ -0,0 +1,368 @@ +const { humanReadableArgName } = require('./argument'); + +// @ts-check + +// 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.sortSubcommands = false; + this.sortOptions = false; + } + + /** + * 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); + if (cmd._hasImplicitHelpCommand()) { + // Create a command matching the implicit help command. + const [, helpName, helpArgs] = cmd._helpCommandnameAndArgs.match(/([^ ]+) *(.*)/); + const helpCommand = cmd.createCommand(helpName) + .helpOption(false); + helpCommand.description(cmd._helpCommandDescription); + if (helpArgs) helpCommand.arguments(helpArgs); + visibleCommands.push(helpCommand); + } + if (this.sortSubcommands) { + visibleCommands.sort((a, b) => { + return a.name().localeCompare(b.name()); + }); + } + return visibleCommands; + } + + /** + * 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); + // Implicit help + const showShortHelpFlag = cmd._hasHelpOption && cmd._helpShortFlag && !cmd._findOption(cmd._helpShortFlag); + const showLongHelpFlag = cmd._hasHelpOption && !cmd._findOption(cmd._helpLongFlag); + if (showShortHelpFlag || showLongHelpFlag) { + let helpOption; + if (!showShortHelpFlag) { + helpOption = cmd.createOption(cmd._helpLongFlag, cmd._helpDescription); + } else if (!showLongHelpFlag) { + helpOption = cmd.createOption(cmd._helpShortFlag, cmd._helpDescription); + } else { + helpOption = cmd.createOption(cmd._helpFlags, cmd._helpDescription); + } + visibleOptions.push(helpOption); + } + if (this.sortOptions) { + const getSortKey = (option) => { + // WYSIWYG for order displayed in help with short before long, no special handling for negated. + return option.short ? option.short.replace(/^-/, '') : option.long.replace(/^--/, ''); + }; + visibleOptions.sort((a, b) => { + return getSortKey(a).localeCompare(getSortKey(b)); + }); + } + return visibleOptions; + } + + /** + * 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._args.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._args.find(argument => argument.description)) { + return cmd._args; + }; + 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._args.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, helper.subcommandTerm(command).length); + }, 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, helper.optionTerm(option).length); + }, 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, helper.argumentTerm(argument).length); + }, 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 parentCmdNames = ''; + for (let parentCmd = cmd.parent; parentCmd; parentCmd = parentCmd.parent) { + parentCmdNames = parentCmd.name() + ' ' + parentCmdNames; + } + return parentCmdNames + cmdName + ' ' + cmd.usage(); + } + + /** + * Get the description for the command. + * + * @param {Command} cmd + * @returns {string} + */ + + commandDescription(cmd) { + // @ts-ignore: overloaded return type + return cmd.description(); + } + + /** + * Get the command description to show in the list of subcommands. + * + * @param {Command} cmd + * @returns {string} + */ + + subcommandDescription(cmd) { + // @ts-ignore: overloaded return type + return cmd.description(); + } + + /** + * Get the option description to show in the list of options. + * + * @param {Option} option + * @return {string} + */ + + optionDescription(option) { + if (option.negate) { + return option.description; + } + 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) { + extraInfo.push(`default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`); + } + 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) { + 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; + 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)); + } + + // Usage + let output = [`Usage: ${helper.commandUsage(cmd)}`, '']; + + // Description + const commandDescription = helper.commandDescription(cmd); + if (commandDescription.length > 0) { + output = output.concat([commandDescription, '']); + } + + // Arguments + const argumentList = helper.visibleArguments(cmd).map((argument) => { + return formatItem(helper.argumentTerm(argument), helper.argumentDescription(argument)); + }); + if (argumentList.length > 0) { + output = output.concat(['Arguments:', formatList(argumentList), '']); + } + + // Options + const optionList = helper.visibleOptions(cmd).map((option) => { + return formatItem(helper.optionTerm(option), helper.optionDescription(option)); + }); + if (optionList.length > 0) { + output = output.concat(['Options:', formatList(optionList), '']); + } + + // Commands + const commandList = helper.visibleCommands(cmd).map((cmd) => { + return formatItem(helper.subcommandTerm(cmd), helper.subcommandDescription(cmd)); + }); + if (commandList.length > 0) { + output = output.concat(['Commands:', formatList(commandList), '']); + } + + return output.join('\n'); + } + + /** + * 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.longestSubcommandTermLength(cmd, helper), + helper.longestArgumentTermLength(cmd, helper) + ); + }; + + /** + * 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. + * + * @param {string} str + * @param {number} width + * @param {number} indent + * @param {number} [minColumnWidth=40] + * @return {string} + * + */ + + wrap(str, width, indent, minColumnWidth = 40) { + // Detect manually wrapped and indented strings by searching for line breaks + // followed by multiple spaces/tabs. + if (str.match(/[\n]\s+/)) 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.substr(0, indent); + const columnText = str.substr(indent); + + const indentString = ' '.repeat(indent); + const regex = new RegExp('.{1,' + (columnWidth - 1) + '}([\\s\u200B]|$)|[^\\s\u200B]+?([\\s\u200B]|$)', 'g'); + const lines = columnText.match(regex) || []; + return leadingStr + lines.map((line, i) => { + if (line.slice(-1) === '\n') { + line = line.slice(0, line.length - 1); + } + return ((i > 0) ? indentString : '') + line.trimRight(); + }).join('\n'); + } +} + +module.exports.Help = Help;