help.js 14 KB

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