UNPKG

16.7 kBJavaScriptView Raw
1'use strict'
2// this file handles outputting usage instructions,
3// failures, etc. keeps logging in one place.
4const decamelize = require('decamelize')
5const stringWidth = require('string-width')
6const objFilter = require('./obj-filter')
7const path = require('path')
8const setBlocking = require('set-blocking')
9const YError = require('./yerror')
10
11module.exports = function usage (yargs, y18n) {
12 const __ = y18n.__
13 const self = {}
14
15 // methods for ouputting/building failure message.
16 const fails = []
17 self.failFn = function failFn (f) {
18 fails.push(f)
19 }
20
21 let failMessage = null
22 let showHelpOnFail = true
23 self.showHelpOnFail = function showHelpOnFailFn (enabled, message) {
24 if (typeof enabled === 'string') {
25 message = enabled
26 enabled = true
27 } else if (typeof enabled === 'undefined') {
28 enabled = true
29 }
30 failMessage = message
31 showHelpOnFail = enabled
32 return self
33 }
34
35 let failureOutput = false
36 self.fail = function fail (msg, err) {
37 const logger = yargs._getLoggerInstance()
38
39 if (fails.length) {
40 for (let i = fails.length - 1; i >= 0; --i) {
41 fails[i](msg, err, self)
42 }
43 } else {
44 if (yargs.getExitProcess()) setBlocking(true)
45
46 // don't output failure message more than once
47 if (!failureOutput) {
48 failureOutput = true
49 if (showHelpOnFail) {
50 yargs.showHelp('error')
51 logger.error()
52 }
53 if (msg || err) logger.error(msg || err)
54 if (failMessage) {
55 if (msg || err) logger.error('')
56 logger.error(failMessage)
57 }
58 }
59
60 err = err || new YError(msg)
61 if (yargs.getExitProcess()) {
62 return yargs.exit(1)
63 } else if (yargs._hasParseCallback()) {
64 return yargs.exit(1, err)
65 } else {
66 throw err
67 }
68 }
69 }
70
71 // methods for ouputting/building help (usage) message.
72 let usages = []
73 let usageDisabled = false
74 self.usage = (msg, description) => {
75 if (msg === null) {
76 usageDisabled = true
77 usages = []
78 return
79 }
80 usageDisabled = false
81 usages.push([msg, description || ''])
82 return self
83 }
84 self.getUsage = () => {
85 return usages
86 }
87 self.getUsageDisabled = () => {
88 return usageDisabled
89 }
90
91 self.getPositionalGroupName = () => {
92 return __('Positionals:')
93 }
94
95 let examples = []
96 self.example = (cmd, description) => {
97 examples.push([cmd, description || ''])
98 }
99
100 let commands = []
101 self.command = function command (cmd, description, isDefault, aliases) {
102 // the last default wins, so cancel out any previously set default
103 if (isDefault) {
104 commands = commands.map((cmdArray) => {
105 cmdArray[2] = false
106 return cmdArray
107 })
108 }
109 commands.push([cmd, description || '', isDefault, aliases])
110 }
111 self.getCommands = () => commands
112
113 let descriptions = {}
114 self.describe = function describe (key, desc) {
115 if (typeof key === 'object') {
116 Object.keys(key).forEach((k) => {
117 self.describe(k, key[k])
118 })
119 } else {
120 descriptions[key] = desc
121 }
122 }
123 self.getDescriptions = () => descriptions
124
125 let epilogs = []
126 self.epilog = (msg) => {
127 epilogs.push(msg)
128 }
129
130 let wrapSet = false
131 let wrap
132 self.wrap = (cols) => {
133 wrapSet = true
134 wrap = cols
135 }
136
137 function getWrap () {
138 if (!wrapSet) {
139 wrap = windowWidth()
140 wrapSet = true
141 }
142
143 return wrap
144 }
145
146 const deferY18nLookupPrefix = '__yargsString__:'
147 self.deferY18nLookup = str => deferY18nLookupPrefix + str
148
149 const defaultGroup = __('Options:')
150 self.help = function help () {
151 if (cachedHelpMessage) return cachedHelpMessage
152 normalizeAliases()
153
154 // handle old demanded API
155 const base$0 = yargs.customScriptName ? yargs.$0 : path.basename(yargs.$0)
156 const demandedOptions = yargs.getDemandedOptions()
157 const demandedCommands = yargs.getDemandedCommands()
158 const deprecatedOptions = yargs.getDeprecatedOptions()
159 const groups = yargs.getGroups()
160 const options = yargs.getOptions()
161
162 let keys = []
163 keys = keys.concat(Object.keys(descriptions))
164 keys = keys.concat(Object.keys(demandedOptions))
165 keys = keys.concat(Object.keys(demandedCommands))
166 keys = keys.concat(Object.keys(options.default))
167 keys = keys.filter(filterHiddenOptions)
168 keys = Object.keys(keys.reduce((acc, key) => {
169 if (key !== '_') acc[key] = true
170 return acc
171 }, {}))
172
173 const theWrap = getWrap()
174 const ui = require('cliui')({
175 width: theWrap,
176 wrap: !!theWrap
177 })
178
179 // the usage string.
180 if (!usageDisabled) {
181 if (usages.length) {
182 // user-defined usage.
183 usages.forEach((usage) => {
184 ui.div(`${usage[0].replace(/\$0/g, base$0)}`)
185 if (usage[1]) {
186 ui.div({ text: `${usage[1]}`, padding: [1, 0, 0, 0] })
187 }
188 })
189 ui.div()
190 } else if (commands.length) {
191 let u = null
192 // demonstrate how commands are used.
193 if (demandedCommands._) {
194 u = `${base$0} <${__('command')}>\n`
195 } else {
196 u = `${base$0} [${__('command')}]\n`
197 }
198 ui.div(`${u}`)
199 }
200 }
201
202 // your application's commands, i.e., non-option
203 // arguments populated in '_'.
204 if (commands.length) {
205 ui.div(__('Commands:'))
206
207 const context = yargs.getContext()
208 const parentCommands = context.commands.length ? `${context.commands.join(' ')} ` : ''
209
210 if (yargs.getParserConfiguration()['sort-commands'] === true) {
211 commands = commands.sort((a, b) => a[0].localeCompare(b[0]))
212 }
213
214 commands.forEach((command) => {
215 const commandString = `${base$0} ${parentCommands}${command[0].replace(/^\$0 ?/, '')}` // drop $0 from default commands.
216 ui.span(
217 {
218 text: commandString,
219 padding: [0, 2, 0, 2],
220 width: maxWidth(commands, theWrap, `${base$0}${parentCommands}`) + 4
221 },
222 { text: command[1] }
223 )
224 const hints = []
225 if (command[2]) hints.push(`[${__('default')}]`)
226 if (command[3] && command[3].length) {
227 hints.push(`[${__('aliases:')} ${command[3].join(', ')}]`)
228 }
229 if (hints.length) {
230 ui.div({ text: hints.join(' '), padding: [0, 0, 0, 2], align: 'right' })
231 } else {
232 ui.div()
233 }
234 })
235
236 ui.div()
237 }
238
239 // perform some cleanup on the keys array, making it
240 // only include top-level keys not their aliases.
241 const aliasKeys = (Object.keys(options.alias) || [])
242 .concat(Object.keys(yargs.parsed.newAliases) || [])
243
244 keys = keys.filter(key => !yargs.parsed.newAliases[key] && aliasKeys.every(alias => (options.alias[alias] || []).indexOf(key) === -1))
245
246 // populate 'Options:' group with any keys that have not
247 // explicitly had a group set.
248 if (!groups[defaultGroup]) groups[defaultGroup] = []
249 addUngroupedKeys(keys, options.alias, groups)
250
251 // display 'Options:' table along with any custom tables:
252 Object.keys(groups).forEach((groupName) => {
253 if (!groups[groupName].length) return
254
255 // if we've grouped the key 'f', but 'f' aliases 'foobar',
256 // normalizedKeys should contain only 'foobar'.
257 const normalizedKeys = groups[groupName].filter(filterHiddenOptions).map((key) => {
258 if (~aliasKeys.indexOf(key)) return key
259 for (let i = 0, aliasKey; (aliasKey = aliasKeys[i]) !== undefined; i++) {
260 if (~(options.alias[aliasKey] || []).indexOf(key)) return aliasKey
261 }
262 return key
263 })
264
265 if (normalizedKeys.length < 1) return
266
267 ui.div(groupName)
268
269 // actually generate the switches string --foo, -f, --bar.
270 const switches = normalizedKeys.reduce((acc, key) => {
271 acc[key] = [key].concat(options.alias[key] || [])
272 .map(sw => {
273 // for the special positional group don't
274 // add '--' or '-' prefix.
275 if (groupName === self.getPositionalGroupName()) return sw
276 else {
277 return (
278 // matches yargs-parser logic in which single-digits
279 // aliases declared with a boolean type are now valid
280 /^[0-9]$/.test(sw)
281 ? ~options.boolean.indexOf(key) ? '-' : '--'
282 : sw.length > 1 ? '--' : '-'
283 ) + sw
284 }
285 })
286 .join(', ')
287
288 return acc
289 }, {})
290
291 normalizedKeys.forEach((key) => {
292 const kswitch = switches[key]
293 let desc = descriptions[key] || ''
294 let type = null
295
296 if (~desc.lastIndexOf(deferY18nLookupPrefix)) desc = __(desc.substring(deferY18nLookupPrefix.length))
297
298 if (~options.boolean.indexOf(key)) type = `[${__('boolean')}]`
299 if (~options.count.indexOf(key)) type = `[${__('count')}]`
300 if (~options.string.indexOf(key)) type = `[${__('string')}]`
301 if (~options.normalize.indexOf(key)) type = `[${__('string')}]`
302 if (~options.array.indexOf(key)) type = `[${__('array')}]`
303 if (~options.number.indexOf(key)) type = `[${__('number')}]`
304
305 const extra = [
306 (key in deprecatedOptions) ? (
307 typeof deprecatedOptions[key] === 'string'
308 ? `[${__('deprecated: %s', deprecatedOptions[key])}]`
309 : `[${__('deprecated')}]`
310 ) : null,
311 type,
312 (key in demandedOptions) ? `[${__('required')}]` : null,
313 options.choices && options.choices[key] ? `[${__('choices:')} ${
314 self.stringifiedValues(options.choices[key])}]` : null,
315 defaultString(options.default[key], options.defaultDescription[key])
316 ].filter(Boolean).join(' ')
317
318 ui.span(
319 { text: kswitch, padding: [0, 2, 0, 2], width: maxWidth(switches, theWrap) + 4 },
320 desc
321 )
322
323 if (extra) ui.div({ text: extra, padding: [0, 0, 0, 2], align: 'right' })
324 else ui.div()
325 })
326
327 ui.div()
328 })
329
330 // describe some common use-cases for your application.
331 if (examples.length) {
332 ui.div(__('Examples:'))
333
334 examples.forEach((example) => {
335 example[0] = example[0].replace(/\$0/g, base$0)
336 })
337
338 examples.forEach((example) => {
339 if (example[1] === '') {
340 ui.div(
341 {
342 text: example[0],
343 padding: [0, 2, 0, 2]
344 }
345 )
346 } else {
347 ui.div(
348 {
349 text: example[0],
350 padding: [0, 2, 0, 2],
351 width: maxWidth(examples, theWrap) + 4
352 }, {
353 text: example[1]
354 }
355 )
356 }
357 })
358
359 ui.div()
360 }
361
362 // the usage string.
363 if (epilogs.length > 0) {
364 const e = epilogs.map(epilog => epilog.replace(/\$0/g, base$0)).join('\n')
365 ui.div(`${e}\n`)
366 }
367
368 // Remove the trailing white spaces
369 return ui.toString().replace(/\s*$/, '')
370 }
371
372 // return the maximum width of a string
373 // in the left-hand column of a table.
374 function maxWidth (table, theWrap, modifier) {
375 let width = 0
376
377 // table might be of the form [leftColumn],
378 // or {key: leftColumn}
379 if (!Array.isArray(table)) {
380 table = Object.keys(table).map(key => [table[key]])
381 }
382
383 table.forEach((v) => {
384 width = Math.max(
385 stringWidth(modifier ? `${modifier} ${v[0]}` : v[0]),
386 width
387 )
388 })
389
390 // if we've enabled 'wrap' we should limit
391 // the max-width of the left-column.
392 if (theWrap) width = Math.min(width, parseInt(theWrap * 0.5, 10))
393
394 return width
395 }
396
397 // make sure any options set for aliases,
398 // are copied to the keys being aliased.
399 function normalizeAliases () {
400 // handle old demanded API
401 const demandedOptions = yargs.getDemandedOptions()
402 const options = yargs.getOptions()
403
404 ;(Object.keys(options.alias) || []).forEach((key) => {
405 options.alias[key].forEach((alias) => {
406 // copy descriptions.
407 if (descriptions[alias]) self.describe(key, descriptions[alias])
408 // copy demanded.
409 if (alias in demandedOptions) yargs.demandOption(key, demandedOptions[alias])
410 // type messages.
411 if (~options.boolean.indexOf(alias)) yargs.boolean(key)
412 if (~options.count.indexOf(alias)) yargs.count(key)
413 if (~options.string.indexOf(alias)) yargs.string(key)
414 if (~options.normalize.indexOf(alias)) yargs.normalize(key)
415 if (~options.array.indexOf(alias)) yargs.array(key)
416 if (~options.number.indexOf(alias)) yargs.number(key)
417 })
418 })
419 }
420
421 // if yargs is executing an async handler, we take a snapshot of the
422 // help message to display on failure:
423 let cachedHelpMessage
424 self.cacheHelpMessage = function () {
425 cachedHelpMessage = this.help()
426 }
427
428 // however this snapshot must be cleared afterwards
429 // not to be be used by next calls to parse
430 self.clearCachedHelpMessage = function () {
431 cachedHelpMessage = undefined
432 }
433
434 // given a set of keys, place any keys that are
435 // ungrouped under the 'Options:' grouping.
436 function addUngroupedKeys (keys, aliases, groups) {
437 let groupedKeys = []
438 let toCheck = null
439 Object.keys(groups).forEach((group) => {
440 groupedKeys = groupedKeys.concat(groups[group])
441 })
442
443 keys.forEach((key) => {
444 toCheck = [key].concat(aliases[key])
445 if (!toCheck.some(k => groupedKeys.indexOf(k) !== -1)) {
446 groups[defaultGroup].push(key)
447 }
448 })
449 return groupedKeys
450 }
451
452 function filterHiddenOptions (key) {
453 return yargs.getOptions().hiddenOptions.indexOf(key) < 0 || yargs.parsed.argv[yargs.getOptions().showHiddenOpt]
454 }
455
456 self.showHelp = (level) => {
457 const logger = yargs._getLoggerInstance()
458 if (!level) level = 'error'
459 const emit = typeof level === 'function' ? level : logger[level]
460 emit(self.help())
461 }
462
463 self.functionDescription = (fn) => {
464 const description = fn.name ? decamelize(fn.name, '-') : __('generated-value')
465 return ['(', description, ')'].join('')
466 }
467
468 self.stringifiedValues = function stringifiedValues (values, separator) {
469 let string = ''
470 const sep = separator || ', '
471 const array = [].concat(values)
472
473 if (!values || !array.length) return string
474
475 array.forEach((value) => {
476 if (string.length) string += sep
477 string += JSON.stringify(value)
478 })
479
480 return string
481 }
482
483 // format the default-value-string displayed in
484 // the right-hand column.
485 function defaultString (value, defaultDescription) {
486 let string = `[${__('default:')} `
487
488 if (value === undefined && !defaultDescription) return null
489
490 if (defaultDescription) {
491 string += defaultDescription
492 } else {
493 switch (typeof value) {
494 case 'string':
495 string += `"${value}"`
496 break
497 case 'object':
498 string += JSON.stringify(value)
499 break
500 default:
501 string += value
502 }
503 }
504
505 return `${string}]`
506 }
507
508 // guess the width of the console window, max-width 80.
509 function windowWidth () {
510 const maxWidth = 80
511 // CI is not a TTY
512 /* c8 ignore next 2 */
513 if (typeof process === 'object' && process.stdout && process.stdout.columns) {
514 return Math.min(maxWidth, process.stdout.columns)
515 } else {
516 return maxWidth
517 }
518 }
519
520 // logic for displaying application version.
521 let version = null
522 self.version = (ver) => {
523 version = ver
524 }
525
526 self.showVersion = () => {
527 const logger = yargs._getLoggerInstance()
528 logger.log(version)
529 }
530
531 self.reset = function reset (localLookup) {
532 // do not reset wrap here
533 // do not reset fails here
534 failMessage = null
535 failureOutput = false
536 usages = []
537 usageDisabled = false
538 epilogs = []
539 examples = []
540 commands = []
541 descriptions = objFilter(descriptions, (k, v) => !localLookup[k])
542 return self
543 }
544
545 const frozens = []
546 self.freeze = function freeze () {
547 const frozen = {}
548 frozens.push(frozen)
549 frozen.failMessage = failMessage
550 frozen.failureOutput = failureOutput
551 frozen.usages = usages
552 frozen.usageDisabled = usageDisabled
553 frozen.epilogs = epilogs
554 frozen.examples = examples
555 frozen.commands = commands
556 frozen.descriptions = descriptions
557 }
558 self.unfreeze = function unfreeze () {
559 const frozen = frozens.pop()
560 failMessage = frozen.failMessage
561 failureOutput = frozen.failureOutput
562 usages = frozen.usages
563 usageDisabled = frozen.usageDisabled
564 epilogs = frozen.epilogs
565 examples = frozen.examples
566 commands = frozen.commands
567 descriptions = frozen.descriptions
568 }
569
570 return self
571}