UNPKG

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