1// class that describes a config key we know about
2// this keeps us from defining a config key and not
3// providing a default, description, etc.
4//
5// TODO: some kind of categorization system, so we can
6// say "these are for registry access", "these are for
7// version resolution" etc.
8
9const required = ['type', 'description', 'default', 'key']
10
11const allowed = [
12  'default',
13  'defaultDescription',
14  'deprecated',
15  'description',
16  'exclusive',
17  'flatten',
18  'hint',
19  'key',
20  'short',
21  'type',
22  'typeDescription',
23  'usage',
24  'envExport',
25]
26
27const {
28  semver: { type: semver },
29  Umask: { type: Umask },
30  url: { type: url },
31  path: { type: path },
32} = require('../type-defs.js')
33
34class Definition {
35  constructor (key, def) {
36    this.key = key
37    // if it's set falsey, don't export it, otherwise we do by default
38    this.envExport = true
39    Object.assign(this, def)
40    this.validate()
41    if (!this.defaultDescription) {
42      this.defaultDescription = describeValue(this.default)
43    }
44    if (!this.typeDescription) {
45      this.typeDescription = describeType(this.type)
46    }
47    // hint is only used for non-boolean values
48    if (!this.hint) {
49      if (this.type === Number) {
50        this.hint = '<number>'
51      } else {
52        this.hint = `<${this.key}>`
53      }
54    }
55    if (!this.usage) {
56      this.usage = describeUsage(this)
57    }
58  }
59
60  validate () {
61    for (const req of required) {
62      if (!Object.prototype.hasOwnProperty.call(this, req)) {
63        throw new Error(`config lacks ${req}: ${this.key}`)
64      }
65    }
66    if (!this.key) {
67      throw new Error(`config lacks key: ${this.key}`)
68    }
69    for (const field of Object.keys(this)) {
70      if (!allowed.includes(field)) {
71        throw new Error(`config defines unknown field ${field}: ${this.key}`)
72      }
73    }
74  }
75
76  // a textual description of this config, suitable for help output
77  describe () {
78    const description = unindent(this.description)
79    const noEnvExport = this.envExport
80      ? ''
81      : `
82This value is not exported to the environment for child processes.
83`
84    const deprecated = !this.deprecated ? '' : `* DEPRECATED: ${unindent(this.deprecated)}\n`
85    /* eslint-disable-next-line max-len */
86    const exclusive = !this.exclusive ? '' : `\nThis config can not be used with: \`${this.exclusive.join('`, `')}\``
87    return wrapAll(`#### \`${this.key}\`
88
89* Default: ${unindent(this.defaultDescription)}
90* Type: ${unindent(this.typeDescription)}
91${deprecated}
92${description}
93${exclusive}
94${noEnvExport}`)
95  }
96}
97
98const describeUsage = def => {
99  let key = ''
100
101  // Single type
102  if (!Array.isArray(def.type)) {
103    if (def.short) {
104      key = `-${def.short}|`
105    }
106
107    if (def.type === Boolean && def.default !== false) {
108      key = `${key}--no-${def.key}`
109    } else {
110      key = `${key}--${def.key}`
111    }
112
113    if (def.type !== Boolean) {
114      key = `${key} ${def.hint}`
115    }
116
117    return key
118  }
119
120  key = `--${def.key}`
121  if (def.short) {
122    key = `-${def.short}|--${def.key}`
123  }
124
125  // Multiple types
126  let types = def.type
127  const multiple = types.includes(Array)
128  const bool = types.includes(Boolean)
129
130  // null type means optional and doesn't currently affect usage output since
131  // all non-optional params have defaults so we render everything as optional
132  types = types.filter(t => t !== null && t !== Array && t !== Boolean)
133
134  if (!types.length) {
135    return key
136  }
137
138  let description
139  if (!types.some(t => typeof t !== 'string')) {
140    // Specific values, use specifics given
141    description = `<${types.filter(d => d).join('|')}>`
142  } else {
143    // Generic values, use hint
144    description = def.hint
145  }
146
147  if (bool) {
148    // Currently none of our multi-type configs with boolean values default to
149    // false so all their hints should show `--no-`, if we ever add ones that
150    // default to false we can branch the logic here
151    key = `--no-${def.key}|${key}`
152  }
153
154  const usage = `${key} ${description}`
155  if (multiple) {
156    return `${usage} [${usage} ...]`
157  } else {
158    return usage
159  }
160}
161
162const describeType = type => {
163  if (Array.isArray(type)) {
164    const descriptions = type.filter(t => t !== Array).map(t => describeType(t))
165
166    // [a] => "a"
167    // [a, b] => "a or b"
168    // [a, b, c] => "a, b, or c"
169    // [a, Array] => "a (can be set multiple times)"
170    // [a, Array, b] => "a or b (can be set multiple times)"
171    const last = descriptions.length > 1 ? [descriptions.pop()] : []
172    const oxford = descriptions.length > 1 ? ', or ' : ' or '
173    const words = [descriptions.join(', ')].concat(last).join(oxford)
174    const multiple = type.includes(Array) ? ' (can be set multiple times)' : ''
175    return `${words}${multiple}`
176  }
177
178  // Note: these are not quite the same as the description printed
179  // when validation fails.  In that case, we want to give the user
180  // a bit more information to help them figure out what's wrong.
181  switch (type) {
182    case String:
183      return 'String'
184    case Number:
185      return 'Number'
186    case Umask:
187      return 'Octal numeric string in range 0000..0777 (0..511)'
188    case Boolean:
189      return 'Boolean'
190    case Date:
191      return 'Date'
192    case path:
193      return 'Path'
194    case semver:
195      return 'SemVer string'
196    case url:
197      return 'URL'
198    default:
199      return describeValue(type)
200  }
201}
202
203// if it's a string, quote it.  otherwise, just cast to string.
204const describeValue = val => (typeof val === 'string' ? JSON.stringify(val) : String(val))
205
206const unindent = s => {
207  // get the first \n followed by a bunch of spaces, and pluck off
208  // that many spaces from the start of every line.
209  const match = s.match(/\n +/)
210  return !match ? s.trim() : s.split(match[0]).join('\n').trim()
211}
212
213const wrap = s => {
214  const cols = Math.min(Math.max(20, process.stdout.columns) || 80, 80) - 5
215  return unindent(s)
216    .split(/[ \n]+/)
217    .reduce((left, right) => {
218      const last = left.split('\n').pop()
219      const join = last.length && last.length + right.length > cols ? '\n' : ' '
220      return left + join + right
221    })
222}
223
224const wrapAll = s => {
225  let inCodeBlock = false
226  return s
227    .split('\n\n')
228    .map(block => {
229      if (inCodeBlock || block.startsWith('```')) {
230        inCodeBlock = !block.endsWith('```')
231        return block
232      }
233
234      if (block.charAt(0) === '*') {
235        return (
236          '* ' +
237          block
238            .slice(1)
239            .trim()
240            .split('\n* ')
241            .map(li => {
242              return wrap(li).replace(/\n/g, '\n  ')
243            })
244            .join('\n* ')
245        )
246      } else {
247        return wrap(block)
248      }
249    })
250    .join('\n\n')
251}
252
253module.exports = Definition
254