You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

535 lines
15 KiB

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