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