help.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
  1. const { humanReadableArgName } = require('./argument.js');
  2. /**
  3. * TypeScript import types for JSDoc, used by Visual Studio Code IntelliSense and `npm run typescript-checkJS`
  4. * https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#import-types
  5. * @typedef { import("./argument.js").Argument } Argument
  6. * @typedef { import("./command.js").Command } Command
  7. * @typedef { import("./option.js").Option } Option
  8. */
  9. // Although this is a class, methods are static in style to allow override using subclass or just functions.
  10. class Help {
  11. constructor() {
  12. this.helpWidth = undefined;
  13. this.minWidthToWrap = 40;
  14. this.sortSubcommands = false;
  15. this.sortOptions = false;
  16. this.showGlobalOptions = false;
  17. }
  18. /**
  19. * prepareContext is called by Commander after applying overrides from `Command.configureHelp()`
  20. * and just before calling `formatHelp()`.
  21. *
  22. * Commander just uses the helpWidth and the rest is provided for optional use by more complex subclasses.
  23. *
  24. * @param {{ error?: boolean, helpWidth?: number, outputHasColors?: boolean }} contextOptions
  25. */
  26. prepareContext(contextOptions) {
  27. this.helpWidth = this.helpWidth ?? contextOptions.helpWidth ?? 80;
  28. }
  29. /**
  30. * Get an array of the visible subcommands. Includes a placeholder for the implicit help command, if there is one.
  31. *
  32. * @param {Command} cmd
  33. * @returns {Command[]}
  34. */
  35. visibleCommands(cmd) {
  36. const visibleCommands = cmd.commands.filter((cmd) => !cmd._hidden);
  37. const helpCommand = cmd._getHelpCommand();
  38. if (helpCommand && !helpCommand._hidden) {
  39. visibleCommands.push(helpCommand);
  40. }
  41. if (this.sortSubcommands) {
  42. visibleCommands.sort((a, b) => {
  43. // @ts-ignore: because overloaded return type
  44. return a.name().localeCompare(b.name());
  45. });
  46. }
  47. return visibleCommands;
  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
  60. ? option.short.replace(/^-/, '')
  61. : option.long.replace(/^--/, '');
  62. };
  63. return getSortKey(a).localeCompare(getSortKey(b));
  64. }
  65. /**
  66. * Get an array of the visible options. Includes a placeholder for the implicit help option, if there is one.
  67. *
  68. * @param {Command} cmd
  69. * @returns {Option[]}
  70. */
  71. visibleOptions(cmd) {
  72. const visibleOptions = cmd.options.filter((option) => !option.hidden);
  73. // Built-in help option.
  74. const helpOption = cmd._getHelpOption();
  75. if (helpOption && !helpOption.hidden) {
  76. // Automatically hide conflicting flags. Bit dubious but a historical behaviour that is convenient for single-command programs.
  77. const removeShort = helpOption.short && cmd._findOption(helpOption.short);
  78. const removeLong = helpOption.long && cmd._findOption(helpOption.long);
  79. if (!removeShort && !removeLong) {
  80. visibleOptions.push(helpOption); // no changes needed
  81. } else if (helpOption.long && !removeLong) {
  82. visibleOptions.push(
  83. cmd.createOption(helpOption.long, helpOption.description),
  84. );
  85. } else if (helpOption.short && !removeShort) {
  86. visibleOptions.push(
  87. cmd.createOption(helpOption.short, helpOption.description),
  88. );
  89. }
  90. }
  91. if (this.sortOptions) {
  92. visibleOptions.sort(this.compareOptions);
  93. }
  94. return visibleOptions;
  95. }
  96. /**
  97. * Get an array of the visible global options. (Not including help.)
  98. *
  99. * @param {Command} cmd
  100. * @returns {Option[]}
  101. */
  102. visibleGlobalOptions(cmd) {
  103. if (!this.showGlobalOptions) return [];
  104. const globalOptions = [];
  105. for (
  106. let ancestorCmd = cmd.parent;
  107. ancestorCmd;
  108. ancestorCmd = ancestorCmd.parent
  109. ) {
  110. const visibleOptions = ancestorCmd.options.filter(
  111. (option) => !option.hidden,
  112. );
  113. globalOptions.push(...visibleOptions);
  114. }
  115. if (this.sortOptions) {
  116. globalOptions.sort(this.compareOptions);
  117. }
  118. return globalOptions;
  119. }
  120. /**
  121. * Get an array of the arguments if any have a description.
  122. *
  123. * @param {Command} cmd
  124. * @returns {Argument[]}
  125. */
  126. visibleArguments(cmd) {
  127. // Side effect! Apply the legacy descriptions before the arguments are displayed.
  128. if (cmd._argsDescription) {
  129. cmd.registeredArguments.forEach((argument) => {
  130. argument.description =
  131. argument.description || cmd._argsDescription[argument.name()] || '';
  132. });
  133. }
  134. // If there are any arguments with a description then return all the arguments.
  135. if (cmd.registeredArguments.find((argument) => argument.description)) {
  136. return cmd.registeredArguments;
  137. }
  138. return [];
  139. }
  140. /**
  141. * Get the command term to show in the list of subcommands.
  142. *
  143. * @param {Command} cmd
  144. * @returns {string}
  145. */
  146. subcommandTerm(cmd) {
  147. // Legacy. Ignores custom usage string, and nested commands.
  148. const args = cmd.registeredArguments
  149. .map((arg) => humanReadableArgName(arg))
  150. .join(' ');
  151. return (
  152. cmd._name +
  153. (cmd._aliases[0] ? '|' + cmd._aliases[0] : '') +
  154. (cmd.options.length ? ' [options]' : '') + // simplistic check for non-help option
  155. (args ? ' ' + args : '')
  156. );
  157. }
  158. /**
  159. * Get the option term to show in the list of options.
  160. *
  161. * @param {Option} option
  162. * @returns {string}
  163. */
  164. optionTerm(option) {
  165. return option.flags;
  166. }
  167. /**
  168. * Get the argument term to show in the list of arguments.
  169. *
  170. * @param {Argument} argument
  171. * @returns {string}
  172. */
  173. argumentTerm(argument) {
  174. return argument.name();
  175. }
  176. /**
  177. * Get the longest command term length.
  178. *
  179. * @param {Command} cmd
  180. * @param {Help} helper
  181. * @returns {number}
  182. */
  183. longestSubcommandTermLength(cmd, helper) {
  184. return helper.visibleCommands(cmd).reduce((max, command) => {
  185. return Math.max(
  186. max,
  187. this.displayWidth(
  188. helper.styleSubcommandTerm(helper.subcommandTerm(command)),
  189. ),
  190. );
  191. }, 0);
  192. }
  193. /**
  194. * Get the longest option term length.
  195. *
  196. * @param {Command} cmd
  197. * @param {Help} helper
  198. * @returns {number}
  199. */
  200. longestOptionTermLength(cmd, helper) {
  201. return helper.visibleOptions(cmd).reduce((max, option) => {
  202. return Math.max(
  203. max,
  204. this.displayWidth(helper.styleOptionTerm(helper.optionTerm(option))),
  205. );
  206. }, 0);
  207. }
  208. /**
  209. * Get the longest global option term length.
  210. *
  211. * @param {Command} cmd
  212. * @param {Help} helper
  213. * @returns {number}
  214. */
  215. longestGlobalOptionTermLength(cmd, helper) {
  216. return helper.visibleGlobalOptions(cmd).reduce((max, option) => {
  217. return Math.max(
  218. max,
  219. this.displayWidth(helper.styleOptionTerm(helper.optionTerm(option))),
  220. );
  221. }, 0);
  222. }
  223. /**
  224. * Get the longest argument term length.
  225. *
  226. * @param {Command} cmd
  227. * @param {Help} helper
  228. * @returns {number}
  229. */
  230. longestArgumentTermLength(cmd, helper) {
  231. return helper.visibleArguments(cmd).reduce((max, argument) => {
  232. return Math.max(
  233. max,
  234. this.displayWidth(
  235. helper.styleArgumentTerm(helper.argumentTerm(argument)),
  236. ),
  237. );
  238. }, 0);
  239. }
  240. /**
  241. * Get the command usage to be displayed at the top of the built-in help.
  242. *
  243. * @param {Command} cmd
  244. * @returns {string}
  245. */
  246. commandUsage(cmd) {
  247. // Usage
  248. let cmdName = cmd._name;
  249. if (cmd._aliases[0]) {
  250. cmdName = cmdName + '|' + cmd._aliases[0];
  251. }
  252. let ancestorCmdNames = '';
  253. for (
  254. let ancestorCmd = cmd.parent;
  255. ancestorCmd;
  256. ancestorCmd = ancestorCmd.parent
  257. ) {
  258. ancestorCmdNames = ancestorCmd.name() + ' ' + ancestorCmdNames;
  259. }
  260. return ancestorCmdNames + cmdName + ' ' + cmd.usage();
  261. }
  262. /**
  263. * Get the description for the command.
  264. *
  265. * @param {Command} cmd
  266. * @returns {string}
  267. */
  268. commandDescription(cmd) {
  269. // @ts-ignore: because overloaded return type
  270. return cmd.description();
  271. }
  272. /**
  273. * Get the subcommand summary to show in the list of subcommands.
  274. * (Fallback to description for backwards compatibility.)
  275. *
  276. * @param {Command} cmd
  277. * @returns {string}
  278. */
  279. subcommandDescription(cmd) {
  280. // @ts-ignore: because overloaded return type
  281. return cmd.summary() || cmd.description();
  282. }
  283. /**
  284. * Get the option description to show in the list of options.
  285. *
  286. * @param {Option} option
  287. * @return {string}
  288. */
  289. optionDescription(option) {
  290. const extraInfo = [];
  291. if (option.argChoices) {
  292. extraInfo.push(
  293. // use stringify to match the display of the default value
  294. `choices: ${option.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`,
  295. );
  296. }
  297. if (option.defaultValue !== undefined) {
  298. // default for boolean and negated more for programmer than end user,
  299. // but show true/false for boolean option as may be for hand-rolled env or config processing.
  300. const showDefault =
  301. option.required ||
  302. option.optional ||
  303. (option.isBoolean() && typeof option.defaultValue === 'boolean');
  304. if (showDefault) {
  305. extraInfo.push(
  306. `default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`,
  307. );
  308. }
  309. }
  310. // preset for boolean and negated are more for programmer than end user
  311. if (option.presetArg !== undefined && option.optional) {
  312. extraInfo.push(`preset: ${JSON.stringify(option.presetArg)}`);
  313. }
  314. if (option.envVar !== undefined) {
  315. extraInfo.push(`env: ${option.envVar}`);
  316. }
  317. if (extraInfo.length > 0) {
  318. return `${option.description} (${extraInfo.join(', ')})`;
  319. }
  320. return option.description;
  321. }
  322. /**
  323. * Get the argument description to show in the list of arguments.
  324. *
  325. * @param {Argument} argument
  326. * @return {string}
  327. */
  328. argumentDescription(argument) {
  329. const extraInfo = [];
  330. if (argument.argChoices) {
  331. extraInfo.push(
  332. // use stringify to match the display of the default value
  333. `choices: ${argument.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`,
  334. );
  335. }
  336. if (argument.defaultValue !== undefined) {
  337. extraInfo.push(
  338. `default: ${argument.defaultValueDescription || JSON.stringify(argument.defaultValue)}`,
  339. );
  340. }
  341. if (extraInfo.length > 0) {
  342. const extraDescription = `(${extraInfo.join(', ')})`;
  343. if (argument.description) {
  344. return `${argument.description} ${extraDescription}`;
  345. }
  346. return extraDescription;
  347. }
  348. return argument.description;
  349. }
  350. /**
  351. * Generate the built-in help text.
  352. *
  353. * @param {Command} cmd
  354. * @param {Help} helper
  355. * @returns {string}
  356. */
  357. formatHelp(cmd, helper) {
  358. const termWidth = helper.padWidth(cmd, helper);
  359. const helpWidth = helper.helpWidth ?? 80; // in case prepareContext() was not called
  360. function callFormatItem(term, description) {
  361. return helper.formatItem(term, termWidth, description, helper);
  362. }
  363. // Usage
  364. let output = [
  365. `${helper.styleTitle('Usage:')} ${helper.styleUsage(helper.commandUsage(cmd))}`,
  366. '',
  367. ];
  368. // Description
  369. const commandDescription = helper.commandDescription(cmd);
  370. if (commandDescription.length > 0) {
  371. output = output.concat([
  372. helper.boxWrap(
  373. helper.styleCommandDescription(commandDescription),
  374. helpWidth,
  375. ),
  376. '',
  377. ]);
  378. }
  379. // Arguments
  380. const argumentList = helper.visibleArguments(cmd).map((argument) => {
  381. return callFormatItem(
  382. helper.styleArgumentTerm(helper.argumentTerm(argument)),
  383. helper.styleArgumentDescription(helper.argumentDescription(argument)),
  384. );
  385. });
  386. if (argumentList.length > 0) {
  387. output = output.concat([
  388. helper.styleTitle('Arguments:'),
  389. ...argumentList,
  390. '',
  391. ]);
  392. }
  393. // Options
  394. const optionList = helper.visibleOptions(cmd).map((option) => {
  395. return callFormatItem(
  396. helper.styleOptionTerm(helper.optionTerm(option)),
  397. helper.styleOptionDescription(helper.optionDescription(option)),
  398. );
  399. });
  400. if (optionList.length > 0) {
  401. output = output.concat([
  402. helper.styleTitle('Options:'),
  403. ...optionList,
  404. '',
  405. ]);
  406. }
  407. if (helper.showGlobalOptions) {
  408. const globalOptionList = helper
  409. .visibleGlobalOptions(cmd)
  410. .map((option) => {
  411. return callFormatItem(
  412. helper.styleOptionTerm(helper.optionTerm(option)),
  413. helper.styleOptionDescription(helper.optionDescription(option)),
  414. );
  415. });
  416. if (globalOptionList.length > 0) {
  417. output = output.concat([
  418. helper.styleTitle('Global Options:'),
  419. ...globalOptionList,
  420. '',
  421. ]);
  422. }
  423. }
  424. // Commands
  425. const commandList = helper.visibleCommands(cmd).map((cmd) => {
  426. return callFormatItem(
  427. helper.styleSubcommandTerm(helper.subcommandTerm(cmd)),
  428. helper.styleSubcommandDescription(helper.subcommandDescription(cmd)),
  429. );
  430. });
  431. if (commandList.length > 0) {
  432. output = output.concat([
  433. helper.styleTitle('Commands:'),
  434. ...commandList,
  435. '',
  436. ]);
  437. }
  438. return output.join('\n');
  439. }
  440. /**
  441. * Return display width of string, ignoring ANSI escape sequences. Used in padding and wrapping calculations.
  442. *
  443. * @param {string} str
  444. * @returns {number}
  445. */
  446. displayWidth(str) {
  447. return stripColor(str).length;
  448. }
  449. /**
  450. * Style the title for displaying in the help. Called with 'Usage:', 'Options:', etc.
  451. *
  452. * @param {string} str
  453. * @returns {string}
  454. */
  455. styleTitle(str) {
  456. return str;
  457. }
  458. styleUsage(str) {
  459. // Usage has lots of parts the user might like to color separately! Assume default usage string which is formed like:
  460. // command subcommand [options] [command] <foo> [bar]
  461. return str
  462. .split(' ')
  463. .map((word) => {
  464. if (word === '[options]') return this.styleOptionText(word);
  465. if (word === '[command]') return this.styleSubcommandText(word);
  466. if (word[0] === '[' || word[0] === '<')
  467. return this.styleArgumentText(word);
  468. return this.styleCommandText(word); // Restrict to initial words?
  469. })
  470. .join(' ');
  471. }
  472. styleCommandDescription(str) {
  473. return this.styleDescriptionText(str);
  474. }
  475. styleOptionDescription(str) {
  476. return this.styleDescriptionText(str);
  477. }
  478. styleSubcommandDescription(str) {
  479. return this.styleDescriptionText(str);
  480. }
  481. styleArgumentDescription(str) {
  482. return this.styleDescriptionText(str);
  483. }
  484. styleDescriptionText(str) {
  485. return str;
  486. }
  487. styleOptionTerm(str) {
  488. return this.styleOptionText(str);
  489. }
  490. styleSubcommandTerm(str) {
  491. // This is very like usage with lots of parts! Assume default string which is formed like:
  492. // subcommand [options] <foo> [bar]
  493. return str
  494. .split(' ')
  495. .map((word) => {
  496. if (word === '[options]') return this.styleOptionText(word);
  497. if (word[0] === '[' || word[0] === '<')
  498. return this.styleArgumentText(word);
  499. return this.styleSubcommandText(word); // Restrict to initial words?
  500. })
  501. .join(' ');
  502. }
  503. styleArgumentTerm(str) {
  504. return this.styleArgumentText(str);
  505. }
  506. styleOptionText(str) {
  507. return str;
  508. }
  509. styleArgumentText(str) {
  510. return str;
  511. }
  512. styleSubcommandText(str) {
  513. return str;
  514. }
  515. styleCommandText(str) {
  516. return str;
  517. }
  518. /**
  519. * Calculate the pad width from the maximum term length.
  520. *
  521. * @param {Command} cmd
  522. * @param {Help} helper
  523. * @returns {number}
  524. */
  525. padWidth(cmd, helper) {
  526. return Math.max(
  527. helper.longestOptionTermLength(cmd, helper),
  528. helper.longestGlobalOptionTermLength(cmd, helper),
  529. helper.longestSubcommandTermLength(cmd, helper),
  530. helper.longestArgumentTermLength(cmd, helper),
  531. );
  532. }
  533. /**
  534. * Detect manually wrapped and indented strings by checking for line break followed by whitespace.
  535. *
  536. * @param {string} str
  537. * @returns {boolean}
  538. */
  539. preformatted(str) {
  540. return /\n[^\S\r\n]/.test(str);
  541. }
  542. /**
  543. * Format the "item", which consists of a term and description. Pad the term and wrap the description, indenting the following lines.
  544. *
  545. * So "TTT", 5, "DDD DDDD DD DDD" might be formatted for this.helpWidth=17 like so:
  546. * TTT DDD DDDD
  547. * DD DDD
  548. *
  549. * @param {string} term
  550. * @param {number} termWidth
  551. * @param {string} description
  552. * @param {Help} helper
  553. * @returns {string}
  554. */
  555. formatItem(term, termWidth, description, helper) {
  556. const itemIndent = 2;
  557. const itemIndentStr = ' '.repeat(itemIndent);
  558. if (!description) return itemIndentStr + term;
  559. // Pad the term out to a consistent width, so descriptions are aligned.
  560. const paddedTerm = term.padEnd(
  561. termWidth + term.length - helper.displayWidth(term),
  562. );
  563. // Format the description.
  564. const spacerWidth = 2; // between term and description
  565. const helpWidth = this.helpWidth ?? 80; // in case prepareContext() was not called
  566. const remainingWidth = helpWidth - termWidth - spacerWidth - itemIndent;
  567. let formattedDescription;
  568. if (
  569. remainingWidth < this.minWidthToWrap ||
  570. helper.preformatted(description)
  571. ) {
  572. formattedDescription = description;
  573. } else {
  574. const wrappedDescription = helper.boxWrap(description, remainingWidth);
  575. formattedDescription = wrappedDescription.replace(
  576. /\n/g,
  577. '\n' + ' '.repeat(termWidth + spacerWidth),
  578. );
  579. }
  580. // Construct and overall indent.
  581. return (
  582. itemIndentStr +
  583. paddedTerm +
  584. ' '.repeat(spacerWidth) +
  585. formattedDescription.replace(/\n/g, `\n${itemIndentStr}`)
  586. );
  587. }
  588. /**
  589. * Wrap a string at whitespace, preserving existing line breaks.
  590. * Wrapping is skipped if the width is less than `minWidthToWrap`.
  591. *
  592. * @param {string} str
  593. * @param {number} width
  594. * @returns {string}
  595. */
  596. boxWrap(str, width) {
  597. if (width < this.minWidthToWrap) return str;
  598. const rawLines = str.split(/\r\n|\n/);
  599. // split up text by whitespace
  600. const chunkPattern = /[\s]*[^\s]+/g;
  601. const wrappedLines = [];
  602. rawLines.forEach((line) => {
  603. const chunks = line.match(chunkPattern);
  604. if (chunks === null) {
  605. wrappedLines.push('');
  606. return;
  607. }
  608. let sumChunks = [chunks.shift()];
  609. let sumWidth = this.displayWidth(sumChunks[0]);
  610. chunks.forEach((chunk) => {
  611. const visibleWidth = this.displayWidth(chunk);
  612. // Accumulate chunks while they fit into width.
  613. if (sumWidth + visibleWidth <= width) {
  614. sumChunks.push(chunk);
  615. sumWidth += visibleWidth;
  616. return;
  617. }
  618. wrappedLines.push(sumChunks.join(''));
  619. const nextChunk = chunk.trimStart(); // trim space at line break
  620. sumChunks = [nextChunk];
  621. sumWidth = this.displayWidth(nextChunk);
  622. });
  623. wrappedLines.push(sumChunks.join(''));
  624. });
  625. return wrappedLines.join('\n');
  626. }
  627. }
  628. /**
  629. * Strip style ANSI escape sequences from the string. In particular, SGR (Select Graphic Rendition) codes.
  630. *
  631. * @param {string} str
  632. * @returns {string}
  633. * @package
  634. */
  635. function stripColor(str) {
  636. // eslint-disable-next-line no-control-regex
  637. const sgrPattern = /\x1b\[\d*(;\d*)*m/g;
  638. return str.replace(sgrPattern, '');
  639. }
  640. exports.Help = Help;
  641. exports.stripColor = stripColor;