help.js 14 KB

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