UNPKG

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