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