help.js 14 KB

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