help.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  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. }
  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. if (cmd._hasImplicitHelpCommand()) {
  26. // Create a command matching the implicit help command.
  27. const [, helpName, helpArgs] = cmd._helpCommandnameAndArgs.match(/([^ ]+) *(.*)/);
  28. const helpCommand = cmd.createCommand(helpName)
  29. .helpOption(false);
  30. helpCommand.description(cmd._helpCommandDescription);
  31. if (helpArgs) helpCommand.arguments(helpArgs);
  32. visibleCommands.push(helpCommand);
  33. }
  34. if (this.sortSubcommands) {
  35. visibleCommands.sort((a, b) => {
  36. // @ts-ignore: overloaded return type
  37. return a.name().localeCompare(b.name());
  38. });
  39. }
  40. return visibleCommands;
  41. }
  42. /**
  43. * Get an array of the visible options. Includes a placeholder for the implicit help option, if there is one.
  44. *
  45. * @param {Command} cmd
  46. * @returns {Option[]}
  47. */
  48. visibleOptions(cmd) {
  49. const visibleOptions = cmd.options.filter((option) => !option.hidden);
  50. // Implicit help
  51. const showShortHelpFlag = cmd._hasHelpOption && cmd._helpShortFlag && !cmd._findOption(cmd._helpShortFlag);
  52. const showLongHelpFlag = cmd._hasHelpOption && !cmd._findOption(cmd._helpLongFlag);
  53. if (showShortHelpFlag || showLongHelpFlag) {
  54. let helpOption;
  55. if (!showShortHelpFlag) {
  56. helpOption = cmd.createOption(cmd._helpLongFlag, cmd._helpDescription);
  57. } else if (!showLongHelpFlag) {
  58. helpOption = cmd.createOption(cmd._helpShortFlag, cmd._helpDescription);
  59. } else {
  60. helpOption = cmd.createOption(cmd._helpFlags, cmd._helpDescription);
  61. }
  62. visibleOptions.push(helpOption);
  63. }
  64. if (this.sortOptions) {
  65. const getSortKey = (option) => {
  66. // WYSIWYG for order displayed in help with short before long, no special handling for negated.
  67. return option.short ? option.short.replace(/^-/, '') : option.long.replace(/^--/, '');
  68. };
  69. visibleOptions.sort((a, b) => {
  70. return getSortKey(a).localeCompare(getSortKey(b));
  71. });
  72. }
  73. return visibleOptions;
  74. }
  75. /**
  76. * Get an array of the arguments if any have a description.
  77. *
  78. * @param {Command} cmd
  79. * @returns {Argument[]}
  80. */
  81. visibleArguments(cmd) {
  82. // Side effect! Apply the legacy descriptions before the arguments are displayed.
  83. if (cmd._argsDescription) {
  84. cmd._args.forEach(argument => {
  85. argument.description = argument.description || cmd._argsDescription[argument.name()] || '';
  86. });
  87. }
  88. // If there are any arguments with a description then return all the arguments.
  89. if (cmd._args.find(argument => argument.description)) {
  90. return cmd._args;
  91. };
  92. return [];
  93. }
  94. /**
  95. * Get the command term to show in the list of subcommands.
  96. *
  97. * @param {Command} cmd
  98. * @returns {string}
  99. */
  100. subcommandTerm(cmd) {
  101. // Legacy. Ignores custom usage string, and nested commands.
  102. const args = cmd._args.map(arg => humanReadableArgName(arg)).join(' ');
  103. return cmd._name +
  104. (cmd._aliases[0] ? '|' + cmd._aliases[0] : '') +
  105. (cmd.options.length ? ' [options]' : '') + // simplistic check for non-help option
  106. (args ? ' ' + args : '');
  107. }
  108. /**
  109. * Get the option term to show in the list of options.
  110. *
  111. * @param {Option} option
  112. * @returns {string}
  113. */
  114. optionTerm(option) {
  115. return option.flags;
  116. }
  117. /**
  118. * Get the argument term to show in the list of arguments.
  119. *
  120. * @param {Argument} argument
  121. * @returns {string}
  122. */
  123. argumentTerm(argument) {
  124. return argument.name();
  125. }
  126. /**
  127. * Get the longest command term length.
  128. *
  129. * @param {Command} cmd
  130. * @param {Help} helper
  131. * @returns {number}
  132. */
  133. longestSubcommandTermLength(cmd, helper) {
  134. return helper.visibleCommands(cmd).reduce((max, command) => {
  135. return Math.max(max, helper.subcommandTerm(command).length);
  136. }, 0);
  137. };
  138. /**
  139. * Get the longest option term length.
  140. *
  141. * @param {Command} cmd
  142. * @param {Help} helper
  143. * @returns {number}
  144. */
  145. longestOptionTermLength(cmd, helper) {
  146. return helper.visibleOptions(cmd).reduce((max, option) => {
  147. return Math.max(max, helper.optionTerm(option).length);
  148. }, 0);
  149. };
  150. /**
  151. * Get the longest argument term length.
  152. *
  153. * @param {Command} cmd
  154. * @param {Help} helper
  155. * @returns {number}
  156. */
  157. longestArgumentTermLength(cmd, helper) {
  158. return helper.visibleArguments(cmd).reduce((max, argument) => {
  159. return Math.max(max, helper.argumentTerm(argument).length);
  160. }, 0);
  161. };
  162. /**
  163. * Get the command usage to be displayed at the top of the built-in help.
  164. *
  165. * @param {Command} cmd
  166. * @returns {string}
  167. */
  168. commandUsage(cmd) {
  169. // Usage
  170. let cmdName = cmd._name;
  171. if (cmd._aliases[0]) {
  172. cmdName = cmdName + '|' + cmd._aliases[0];
  173. }
  174. let parentCmdNames = '';
  175. for (let parentCmd = cmd.parent; parentCmd; parentCmd = parentCmd.parent) {
  176. parentCmdNames = parentCmd.name() + ' ' + parentCmdNames;
  177. }
  178. return parentCmdNames + cmdName + ' ' + cmd.usage();
  179. }
  180. /**
  181. * Get the description for the command.
  182. *
  183. * @param {Command} cmd
  184. * @returns {string}
  185. */
  186. commandDescription(cmd) {
  187. // @ts-ignore: overloaded return type
  188. return cmd.description();
  189. }
  190. /**
  191. * Get the command description to show in the list of subcommands.
  192. *
  193. * @param {Command} cmd
  194. * @returns {string}
  195. */
  196. subcommandDescription(cmd) {
  197. // @ts-ignore: overloaded return type
  198. return cmd.description();
  199. }
  200. /**
  201. * Get the option description to show in the list of options.
  202. *
  203. * @param {Option} option
  204. * @return {string}
  205. */
  206. optionDescription(option) {
  207. const extraInfo = [];
  208. // Some of these do not make sense for negated boolean and suppress for backwards compatibility.
  209. if (option.argChoices && !option.negate) {
  210. extraInfo.push(
  211. // use stringify to match the display of the default value
  212. `choices: ${option.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`);
  213. }
  214. if (option.defaultValue !== undefined && !option.negate) {
  215. extraInfo.push(`default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`);
  216. }
  217. if (option.envVar !== undefined) {
  218. extraInfo.push(`env: ${option.envVar}`);
  219. }
  220. if (extraInfo.length > 0) {
  221. return `${option.description} (${extraInfo.join(', ')})`;
  222. }
  223. return option.description;
  224. };
  225. /**
  226. * Get the argument description to show in the list of arguments.
  227. *
  228. * @param {Argument} argument
  229. * @return {string}
  230. */
  231. argumentDescription(argument) {
  232. const extraInfo = [];
  233. if (argument.argChoices) {
  234. extraInfo.push(
  235. // use stringify to match the display of the default value
  236. `choices: ${argument.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`);
  237. }
  238. if (argument.defaultValue !== undefined) {
  239. extraInfo.push(`default: ${argument.defaultValueDescription || JSON.stringify(argument.defaultValue)}`);
  240. }
  241. if (extraInfo.length > 0) {
  242. const extraDescripton = `(${extraInfo.join(', ')})`;
  243. if (argument.description) {
  244. return `${argument.description} ${extraDescripton}`;
  245. }
  246. return extraDescripton;
  247. }
  248. return argument.description;
  249. }
  250. /**
  251. * Generate the built-in help text.
  252. *
  253. * @param {Command} cmd
  254. * @param {Help} helper
  255. * @returns {string}
  256. */
  257. formatHelp(cmd, helper) {
  258. const termWidth = helper.padWidth(cmd, helper);
  259. const helpWidth = helper.helpWidth || 80;
  260. const itemIndentWidth = 2;
  261. const itemSeparatorWidth = 2; // between term and description
  262. function formatItem(term, description) {
  263. if (description) {
  264. const fullText = `${term.padEnd(termWidth + itemSeparatorWidth)}${description}`;
  265. return helper.wrap(fullText, helpWidth - itemIndentWidth, termWidth + itemSeparatorWidth);
  266. }
  267. return term;
  268. };
  269. function formatList(textArray) {
  270. return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth));
  271. }
  272. // Usage
  273. let output = [`Usage: ${helper.commandUsage(cmd)}`, ''];
  274. // Description
  275. const commandDescription = helper.commandDescription(cmd);
  276. if (commandDescription.length > 0) {
  277. output = output.concat([commandDescription, '']);
  278. }
  279. // Arguments
  280. const argumentList = helper.visibleArguments(cmd).map((argument) => {
  281. return formatItem(helper.argumentTerm(argument), helper.argumentDescription(argument));
  282. });
  283. if (argumentList.length > 0) {
  284. output = output.concat(['Arguments:', formatList(argumentList), '']);
  285. }
  286. // Options
  287. const optionList = helper.visibleOptions(cmd).map((option) => {
  288. return formatItem(helper.optionTerm(option), helper.optionDescription(option));
  289. });
  290. if (optionList.length > 0) {
  291. output = output.concat(['Options:', formatList(optionList), '']);
  292. }
  293. // Commands
  294. const commandList = helper.visibleCommands(cmd).map((cmd) => {
  295. return formatItem(helper.subcommandTerm(cmd), helper.subcommandDescription(cmd));
  296. });
  297. if (commandList.length > 0) {
  298. output = output.concat(['Commands:', formatList(commandList), '']);
  299. }
  300. return output.join('\n');
  301. }
  302. /**
  303. * Calculate the pad width from the maximum term length.
  304. *
  305. * @param {Command} cmd
  306. * @param {Help} helper
  307. * @returns {number}
  308. */
  309. padWidth(cmd, helper) {
  310. return Math.max(
  311. helper.longestOptionTermLength(cmd, helper),
  312. helper.longestSubcommandTermLength(cmd, helper),
  313. helper.longestArgumentTermLength(cmd, helper)
  314. );
  315. };
  316. /**
  317. * Wrap the given string to width characters per line, with lines after the first indented.
  318. * Do not wrap if insufficient room for wrapping (minColumnWidth), or string is manually formatted.
  319. *
  320. * @param {string} str
  321. * @param {number} width
  322. * @param {number} indent
  323. * @param {number} [minColumnWidth=40]
  324. * @return {string}
  325. *
  326. */
  327. wrap(str, width, indent, minColumnWidth = 40) {
  328. // Detect manually wrapped and indented strings by searching for line breaks
  329. // followed by multiple spaces/tabs.
  330. if (str.match(/[\n]\s+/)) return str;
  331. // Do not wrap if not enough room for a wrapped column of text (as could end up with a word per line).
  332. const columnWidth = width - indent;
  333. if (columnWidth < minColumnWidth) return str;
  334. const leadingStr = str.substr(0, indent);
  335. const columnText = str.substr(indent);
  336. const indentString = ' '.repeat(indent);
  337. const regex = new RegExp('.{1,' + (columnWidth - 1) + '}([\\s\u200B]|$)|[^\\s\u200B]+?([\\s\u200B]|$)', 'g');
  338. const lines = columnText.match(regex) || [];
  339. return leadingStr + lines.map((line, i) => {
  340. if (line.slice(-1) === '\n') {
  341. line = line.slice(0, line.length - 1);
  342. }
  343. return ((i > 0) ? indentString : '') + line.trimRight();
  344. }).join('\n');
  345. }
  346. }
  347. exports.Help = Help;