help.js (14351B)
1 const { humanReadableArgName } = require('./argument.js'); 2 3 /** 4 * TypeScript import types for JSDoc, used by Visual Studio Code IntelliSense and `npm run typescript-checkJS` 5 * https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#import-types 6 * @typedef { import("./argument.js").Argument } Argument 7 * @typedef { import("./command.js").Command } Command 8 * @typedef { import("./option.js").Option } Option 9 */ 10 11 // Although this is a class, methods are static in style to allow override using subclass or just functions. 12 class Help { 13 constructor() { 14 this.helpWidth = undefined; 15 this.sortSubcommands = false; 16 this.sortOptions = false; 17 this.showGlobalOptions = false; 18 } 19 20 /** 21 * Get an array of the visible subcommands. Includes a placeholder for the implicit help command, if there is one. 22 * 23 * @param {Command} cmd 24 * @returns {Command[]} 25 */ 26 27 visibleCommands(cmd) { 28 const visibleCommands = cmd.commands.filter(cmd => !cmd._hidden); 29 if (cmd._hasImplicitHelpCommand()) { 30 // Create a command matching the implicit help command. 31 const [, helpName, helpArgs] = cmd._helpCommandnameAndArgs.match(/([^ ]+) *(.*)/); 32 const helpCommand = cmd.createCommand(helpName) 33 .helpOption(false); 34 helpCommand.description(cmd._helpCommandDescription); 35 if (helpArgs) helpCommand.arguments(helpArgs); 36 visibleCommands.push(helpCommand); 37 } 38 if (this.sortSubcommands) { 39 visibleCommands.sort((a, b) => { 40 // @ts-ignore: overloaded return type 41 return a.name().localeCompare(b.name()); 42 }); 43 } 44 return visibleCommands; 45 } 46 47 /** 48 * Compare options for sort. 49 * 50 * @param {Option} a 51 * @param {Option} b 52 * @returns number 53 */ 54 compareOptions(a, b) { 55 const getSortKey = (option) => { 56 // WYSIWYG for order displayed in help. Short used for comparison if present. No special handling for negated. 57 return option.short ? option.short.replace(/^-/, '') : option.long.replace(/^--/, ''); 58 }; 59 return getSortKey(a).localeCompare(getSortKey(b)); 60 } 61 62 /** 63 * Get an array of the visible options. Includes a placeholder for the implicit help option, if there is one. 64 * 65 * @param {Command} cmd 66 * @returns {Option[]} 67 */ 68 69 visibleOptions(cmd) { 70 const visibleOptions = cmd.options.filter((option) => !option.hidden); 71 // Implicit help 72 const showShortHelpFlag = cmd._hasHelpOption && cmd._helpShortFlag && !cmd._findOption(cmd._helpShortFlag); 73 const showLongHelpFlag = cmd._hasHelpOption && !cmd._findOption(cmd._helpLongFlag); 74 if (showShortHelpFlag || showLongHelpFlag) { 75 let helpOption; 76 if (!showShortHelpFlag) { 77 helpOption = cmd.createOption(cmd._helpLongFlag, cmd._helpDescription); 78 } else if (!showLongHelpFlag) { 79 helpOption = cmd.createOption(cmd._helpShortFlag, cmd._helpDescription); 80 } else { 81 helpOption = cmd.createOption(cmd._helpFlags, cmd._helpDescription); 82 } 83 visibleOptions.push(helpOption); 84 } 85 if (this.sortOptions) { 86 visibleOptions.sort(this.compareOptions); 87 } 88 return visibleOptions; 89 } 90 91 /** 92 * Get an array of the visible global options. (Not including help.) 93 * 94 * @param {Command} cmd 95 * @returns {Option[]} 96 */ 97 98 visibleGlobalOptions(cmd) { 99 if (!this.showGlobalOptions) return []; 100 101 const globalOptions = []; 102 for (let ancestorCmd = cmd.parent; ancestorCmd; ancestorCmd = ancestorCmd.parent) { 103 const visibleOptions = ancestorCmd.options.filter((option) => !option.hidden); 104 globalOptions.push(...visibleOptions); 105 } 106 if (this.sortOptions) { 107 globalOptions.sort(this.compareOptions); 108 } 109 return globalOptions; 110 } 111 112 /** 113 * Get an array of the arguments if any have a description. 114 * 115 * @param {Command} cmd 116 * @returns {Argument[]} 117 */ 118 119 visibleArguments(cmd) { 120 // Side effect! Apply the legacy descriptions before the arguments are displayed. 121 if (cmd._argsDescription) { 122 cmd.registeredArguments.forEach(argument => { 123 argument.description = argument.description || cmd._argsDescription[argument.name()] || ''; 124 }); 125 } 126 127 // If there are any arguments with a description then return all the arguments. 128 if (cmd.registeredArguments.find(argument => argument.description)) { 129 return cmd.registeredArguments; 130 } 131 return []; 132 } 133 134 /** 135 * Get the command term to show in the list of subcommands. 136 * 137 * @param {Command} cmd 138 * @returns {string} 139 */ 140 141 subcommandTerm(cmd) { 142 // Legacy. Ignores custom usage string, and nested commands. 143 const args = cmd.registeredArguments.map(arg => humanReadableArgName(arg)).join(' '); 144 return cmd._name + 145 (cmd._aliases[0] ? '|' + cmd._aliases[0] : '') + 146 (cmd.options.length ? ' [options]' : '') + // simplistic check for non-help option 147 (args ? ' ' + args : ''); 148 } 149 150 /** 151 * Get the option term to show in the list of options. 152 * 153 * @param {Option} option 154 * @returns {string} 155 */ 156 157 optionTerm(option) { 158 return option.flags; 159 } 160 161 /** 162 * Get the argument term to show in the list of arguments. 163 * 164 * @param {Argument} argument 165 * @returns {string} 166 */ 167 168 argumentTerm(argument) { 169 return argument.name(); 170 } 171 172 /** 173 * Get the longest command term length. 174 * 175 * @param {Command} cmd 176 * @param {Help} helper 177 * @returns {number} 178 */ 179 180 longestSubcommandTermLength(cmd, helper) { 181 return helper.visibleCommands(cmd).reduce((max, command) => { 182 return Math.max(max, helper.subcommandTerm(command).length); 183 }, 0); 184 } 185 186 /** 187 * Get the longest option term length. 188 * 189 * @param {Command} cmd 190 * @param {Help} helper 191 * @returns {number} 192 */ 193 194 longestOptionTermLength(cmd, helper) { 195 return helper.visibleOptions(cmd).reduce((max, option) => { 196 return Math.max(max, helper.optionTerm(option).length); 197 }, 0); 198 } 199 200 /** 201 * Get the longest global option term length. 202 * 203 * @param {Command} cmd 204 * @param {Help} helper 205 * @returns {number} 206 */ 207 208 longestGlobalOptionTermLength(cmd, helper) { 209 return helper.visibleGlobalOptions(cmd).reduce((max, option) => { 210 return Math.max(max, helper.optionTerm(option).length); 211 }, 0); 212 } 213 214 /** 215 * Get the longest argument term length. 216 * 217 * @param {Command} cmd 218 * @param {Help} helper 219 * @returns {number} 220 */ 221 222 longestArgumentTermLength(cmd, helper) { 223 return helper.visibleArguments(cmd).reduce((max, argument) => { 224 return Math.max(max, helper.argumentTerm(argument).length); 225 }, 0); 226 } 227 228 /** 229 * Get the command usage to be displayed at the top of the built-in help. 230 * 231 * @param {Command} cmd 232 * @returns {string} 233 */ 234 235 commandUsage(cmd) { 236 // Usage 237 let cmdName = cmd._name; 238 if (cmd._aliases[0]) { 239 cmdName = cmdName + '|' + cmd._aliases[0]; 240 } 241 let ancestorCmdNames = ''; 242 for (let ancestorCmd = cmd.parent; ancestorCmd; ancestorCmd = ancestorCmd.parent) { 243 ancestorCmdNames = ancestorCmd.name() + ' ' + ancestorCmdNames; 244 } 245 return ancestorCmdNames + cmdName + ' ' + cmd.usage(); 246 } 247 248 /** 249 * Get the description for the command. 250 * 251 * @param {Command} cmd 252 * @returns {string} 253 */ 254 255 commandDescription(cmd) { 256 // @ts-ignore: overloaded return type 257 return cmd.description(); 258 } 259 260 /** 261 * Get the subcommand summary to show in the list of subcommands. 262 * (Fallback to description for backwards compatibility.) 263 * 264 * @param {Command} cmd 265 * @returns {string} 266 */ 267 268 subcommandDescription(cmd) { 269 // @ts-ignore: overloaded return type 270 return cmd.summary() || cmd.description(); 271 } 272 273 /** 274 * Get the option description to show in the list of options. 275 * 276 * @param {Option} option 277 * @return {string} 278 */ 279 280 optionDescription(option) { 281 const extraInfo = []; 282 283 if (option.argChoices) { 284 extraInfo.push( 285 // use stringify to match the display of the default value 286 `choices: ${option.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`); 287 } 288 if (option.defaultValue !== undefined) { 289 // default for boolean and negated more for programmer than end user, 290 // but show true/false for boolean option as may be for hand-rolled env or config processing. 291 const showDefault = option.required || option.optional || 292 (option.isBoolean() && typeof option.defaultValue === 'boolean'); 293 if (showDefault) { 294 extraInfo.push(`default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`); 295 } 296 } 297 // preset for boolean and negated are more for programmer than end user 298 if (option.presetArg !== undefined && option.optional) { 299 extraInfo.push(`preset: ${JSON.stringify(option.presetArg)}`); 300 } 301 if (option.envVar !== undefined) { 302 extraInfo.push(`env: ${option.envVar}`); 303 } 304 if (extraInfo.length > 0) { 305 return `${option.description} (${extraInfo.join(', ')})`; 306 } 307 308 return option.description; 309 } 310 311 /** 312 * Get the argument description to show in the list of arguments. 313 * 314 * @param {Argument} argument 315 * @return {string} 316 */ 317 318 argumentDescription(argument) { 319 const extraInfo = []; 320 if (argument.argChoices) { 321 extraInfo.push( 322 // use stringify to match the display of the default value 323 `choices: ${argument.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`); 324 } 325 if (argument.defaultValue !== undefined) { 326 extraInfo.push(`default: ${argument.defaultValueDescription || JSON.stringify(argument.defaultValue)}`); 327 } 328 if (extraInfo.length > 0) { 329 const extraDescripton = `(${extraInfo.join(', ')})`; 330 if (argument.description) { 331 return `${argument.description} ${extraDescripton}`; 332 } 333 return extraDescripton; 334 } 335 return argument.description; 336 } 337 338 /** 339 * Generate the built-in help text. 340 * 341 * @param {Command} cmd 342 * @param {Help} helper 343 * @returns {string} 344 */ 345 346 formatHelp(cmd, helper) { 347 const termWidth = helper.padWidth(cmd, helper); 348 const helpWidth = helper.helpWidth || 80; 349 const itemIndentWidth = 2; 350 const itemSeparatorWidth = 2; // between term and description 351 function formatItem(term, description) { 352 if (description) { 353 const fullText = `${term.padEnd(termWidth + itemSeparatorWidth)}${description}`; 354 return helper.wrap(fullText, helpWidth - itemIndentWidth, termWidth + itemSeparatorWidth); 355 } 356 return term; 357 } 358 function formatList(textArray) { 359 return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth)); 360 } 361 362 // Usage 363 let output = [`Usage: ${helper.commandUsage(cmd)}`, '']; 364 365 // Description 366 const commandDescription = helper.commandDescription(cmd); 367 if (commandDescription.length > 0) { 368 output = output.concat([helper.wrap(commandDescription, helpWidth, 0), '']); 369 } 370 371 // Arguments 372 const argumentList = helper.visibleArguments(cmd).map((argument) => { 373 return formatItem(helper.argumentTerm(argument), helper.argumentDescription(argument)); 374 }); 375 if (argumentList.length > 0) { 376 output = output.concat(['Arguments:', formatList(argumentList), '']); 377 } 378 379 // Options 380 const optionList = helper.visibleOptions(cmd).map((option) => { 381 return formatItem(helper.optionTerm(option), helper.optionDescription(option)); 382 }); 383 if (optionList.length > 0) { 384 output = output.concat(['Options:', formatList(optionList), '']); 385 } 386 387 if (this.showGlobalOptions) { 388 const globalOptionList = helper.visibleGlobalOptions(cmd).map((option) => { 389 return formatItem(helper.optionTerm(option), helper.optionDescription(option)); 390 }); 391 if (globalOptionList.length > 0) { 392 output = output.concat(['Global Options:', formatList(globalOptionList), '']); 393 } 394 } 395 396 // Commands 397 const commandList = helper.visibleCommands(cmd).map((cmd) => { 398 return formatItem(helper.subcommandTerm(cmd), helper.subcommandDescription(cmd)); 399 }); 400 if (commandList.length > 0) { 401 output = output.concat(['Commands:', formatList(commandList), '']); 402 } 403 404 return output.join('\n'); 405 } 406 407 /** 408 * Calculate the pad width from the maximum term length. 409 * 410 * @param {Command} cmd 411 * @param {Help} helper 412 * @returns {number} 413 */ 414 415 padWidth(cmd, helper) { 416 return Math.max( 417 helper.longestOptionTermLength(cmd, helper), 418 helper.longestGlobalOptionTermLength(cmd, helper), 419 helper.longestSubcommandTermLength(cmd, helper), 420 helper.longestArgumentTermLength(cmd, helper) 421 ); 422 } 423 424 /** 425 * Wrap the given string to width characters per line, with lines after the first indented. 426 * Do not wrap if insufficient room for wrapping (minColumnWidth), or string is manually formatted. 427 * 428 * @param {string} str 429 * @param {number} width 430 * @param {number} indent 431 * @param {number} [minColumnWidth=40] 432 * @return {string} 433 * 434 */ 435 436 wrap(str, width, indent, minColumnWidth = 40) { 437 // Full \s characters, minus the linefeeds. 438 const indents = ' \\f\\t\\v\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000\ufeff'; 439 // Detect manually wrapped and indented strings by searching for line break followed by spaces. 440 const manualIndent = new RegExp(`[\\n][${indents}]+`); 441 if (str.match(manualIndent)) return str; 442 // Do not wrap if not enough room for a wrapped column of text (as could end up with a word per line). 443 const columnWidth = width - indent; 444 if (columnWidth < minColumnWidth) return str; 445 446 const leadingStr = str.slice(0, indent); 447 const columnText = str.slice(indent).replace('\r\n', '\n'); 448 const indentString = ' '.repeat(indent); 449 const zeroWidthSpace = '\u200B'; 450 const breaks = `\\s${zeroWidthSpace}`; 451 // Match line end (so empty lines don't collapse), 452 // or as much text as will fit in column, or excess text up to first break. 453 const regex = new RegExp(`\n|.{1,${columnWidth - 1}}([${breaks}]|$)|[^${breaks}]+?([${breaks}]|$)`, 'g'); 454 const lines = columnText.match(regex) || []; 455 return leadingStr + lines.map((line, i) => { 456 if (line === '\n') return ''; // preserve empty lines 457 return ((i > 0) ? indentString : '') + line.trimEnd(); 458 }).join('\n'); 459 } 460 } 461 462 exports.Help = Help;