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