aboutsummaryrefslogtreecommitdiff
path: root/node_modules/hullabaloo-config-manager/lib/collector.js
blob: 1a7414a87f265319545a9b17fd79be0e6f5a14fd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
'use strict'

const path = require('path')

const parseJson5 = require('json5').parse

const errors = require('./errors')
const readSafe = require('./readSafe')

function makeValid (source, options) {
  // Arrays are never valid options.
  if (Array.isArray(options)) throw new errors.InvalidFileError(source)

  // Force options to be an object. Babel itself ignores falsy values when
  // resolving config chains. Here such files still need to be included
  // for cache busting purposes.
  if (!options || typeof options !== 'object') return {}

  return options
}

function parseFile (source, buffer) {
  let options
  try {
    options = parseJson5(buffer.toString('utf8'))
  } catch (err) {
    throw new errors.ParseError(source, err)
  }

  return makeValid(source, options)
}

function parsePackage (source, buffer) {
  let options
  try {
    const pkg = JSON.parse(buffer.toString('utf8'))
    options = pkg && pkg.babel
  } catch (err) {
    throw new errors.ParseError(source, err)
  }

  return makeValid(source, options)
}

class Config {
  constructor (dir, env, hash, json5, options, source) {
    this.dir = dir
    this.env = env
    this.hash = hash
    this.json5 = json5
    this.options = options
    this.source = source

    this.babelrcPointer = null
    this.envPointers = new Map()
    this.extends = null
    this.extendsPointer = null
  }

  copyWithEnv (env, options) {
    return new this.constructor(this.dir, env, this.hash, this.json5, options, this.source)
  }

  extend (config) {
    const clause = this.takeExtends()
    if (clause) {
      throw new TypeError(`Cannot extend config: there is an extends clause in the current options: ${clause}`)
    }
    if (this.extends) {
      throw new Error('Cannot extend config: already extended')
    }

    this.extends = config
  }

  takeEnvs () {
    const env = this.options.env
    delete this.options.env

    return env
      ? new Map(
          Object.keys(env)
            .filter(Boolean)
            .map(name => [name, env[name]]))
      : new Map()
  }

  takeExtends () {
    const clause = this.options.extends
    delete this.options.extends
    return clause
  }
}
exports.Config = Config

function resolveDirectory (dir, cache) {
  const fileSource = path.join(dir, '.babelrc')
  const packageSource = path.join(dir, 'package.json')

  const fromFile = readSafe(fileSource, cache)
    .then(contents => contents && {
      json5: true,
      parse () { return parseFile(fileSource, contents) },
      source: fileSource
    })

  const fromPackage = readSafe(packageSource, cache)
    .then(contents => contents && {
      json5: false,
      parse () { return parsePackage(packageSource, contents) },
      source: packageSource
    })

  return fromFile
    .then(fileResult => fileResult || fromPackage)
    .then(result => {
      // .babelrc or package.json files may not exist, and that's OK.
      if (!result) return null

      return new Config(dir, null, null, result.json5, result.parse(), result.source)
    })
}

function resolveFile (source, cache) {
  return readSafe(source, cache)
    .then(contents => {
      // The file *must* exist. Causes a proper error to be propagated to
      // where "extends" directives are resolved.
      if (!contents) throw new errors.NoSourceFileError(source)

      return new Config(path.dirname(source), null, null, true, parseFile(source, contents), source)
    })
}

class Chains {
  constructor (babelrcDir, defaultChain, envChains) {
    this.babelrcDir = babelrcDir
    this.defaultChain = defaultChain
    this.envChains = envChains
  }

  * [Symbol.iterator] () {
    yield this.defaultChain
    for (const chain of this.envChains.values()) {
      yield chain
    }
  }
}

class Collector {
  constructor (cache) {
    this.cache = cache
    this.configs = []
    this.envNames = new Set()
    this.pointers = new Map()
  }

  get initialConfig () {
    return this.configs[0]
  }

  add (config) {
    // Avoid adding duplicate configs. Note that configs that came from an
    // "env" directive share their source with their parent config.
    if (!config.env && this.pointers.has(config.source)) {
      return Promise.resolve(this.pointers.get(config.source))
    }

    const pointer = this.configs.push(config) - 1
    // Make sure not to override the pointer to an environmental
    // config's parent.
    if (!config.env) this.pointers.set(config.source, pointer)

    const envs = config.takeEnvs()
    const extendsClause = config.takeExtends()
    const waitFor = []

    if (config.extends) {
      const promise = this.add(config.extends)
        .then(extendsPointer => (config.extendsPointer = extendsPointer))
      waitFor.push(promise)
    } else if (extendsClause) {
      const extendsSource = path.resolve(config.dir, extendsClause)

      if (this.pointers.has(extendsSource)) {
        // Point at existing config.
        config.extendsPointer = this.pointers.get(extendsSource)
      } else {
        // Different configs may concurrently resolve the same extends source.
        // While only one such resolution is added to the config list, this
        // does lead to extra file I/O and parsing. Optimizing this is not
        // currently considered worthwhile.
        const promise = resolveFile(extendsSource, this.cache)
          .then(parentConfig => this.add(parentConfig))
          .then(extendsPointer => (config.extendsPointer = extendsPointer))
          .catch(err => {
            if (err.name === 'NoSourceFileError') {
              throw new errors.ExtendsError(config.source, extendsClause, err)
            }

            throw err
          })

        waitFor.push(promise)
      }
    }

    for (const pair of envs) {
      const name = pair[0]
      const options = pair[1]

      this.envNames.add(name)
      const promise = this.add(config.copyWithEnv(name, options))
        .then(envPointer => config.envPointers.set(name, envPointer))
      waitFor.push(promise)
    }

    return Promise.all(waitFor)
      .then(() => pointer)
  }

  resolveChains (babelrcDir) {
    if (this.configs.length === 0) return null

    // Resolves a config chain, correctly ordering parent configs and recursing
    // through environmental configs, while avoiding cycles and repetitions.
    const resolveChain = (from, envName) => {
      const chain = new Set()
      const knownParents = new Set()

      /* eslint-disable no-use-before-define */
      const addWithEnv = config => {
        // Avoid unnecessary work in case the `from` list contains configs that
        // have already been added through an environmental config's parent.
        if (chain.has(config)) return
        chain.add(config)

        if (config.envPointers.has(envName)) {
          const pointer = config.envPointers.get(envName)
          const envConfig = this.configs[pointer]
          addAfterParents(envConfig)
        }
      }

      const addAfterParents = config => {
        // Avoid cycles by ignoring those parents that are already being added.
        if (knownParents.has(config)) return
        knownParents.add(config)

        if (config.babelrcPointer !== null) {
          const parent = this.configs[config.babelrcPointer]
          addAfterParents(parent)
        }
        if (config.extendsPointer !== null) {
          const parent = this.configs[config.extendsPointer]
          addAfterParents(parent)
        }

        if (envName) {
          addWithEnv(config)
        } else {
          chain.add(config)
        }
      }
      /* eslint-enable no-use-before-define */

      for (const config of from) {
        if (envName) {
          addWithEnv(config)
        } else {
          addAfterParents(config)
        }
      }

      return chain
    }

    // Start with the first config. This is either the base config provided
    // to fromConfig(), or the config derived from .babelrc / package.json
    // found in fromDirectory().
    const defaultChain = resolveChain([this.initialConfig])

    // For each environment, augment the default chain with environmental
    // configs.
    const envChains = new Map(Array.from(this.envNames, name => {
      return [name, resolveChain(defaultChain, name)]
    }))

    return new Chains(babelrcDir, defaultChain, envChains)
  }
}

function fromConfig (baseConfig, cache) {
  let babelrcConfig = null
  for (let config = baseConfig; config; config = config.extends) {
    if (config.options.babelrc === false) continue

    if (babelrcConfig) {
      throw new TypeError(`${config.source}: Cannot resolve babelrc option, already resolved by ${babelrcConfig.source}`)
    }

    babelrcConfig = config
  }

  const collector = new Collector(cache)
  return Promise.all([
    collector.add(baseConfig),
    // Resolve the directory concurrently. Assumes that in the common case,
    // the babelrcConfig doesn't extend from a .babelrc file while also leaving
    // the babelrc option enabled. Worst case the resolved config is discarded
    // as a duplicate.
    babelrcConfig && resolveDirectory(babelrcConfig.dir, cache)
      .then(parentConfig => {
        if (!parentConfig) return

        return collector.add(parentConfig)
          .then(babelrcPointer => (babelrcConfig.babelrcPointer = babelrcPointer))
      })
  ])
    .then(() => collector.resolveChains(babelrcConfig && babelrcConfig.dir))
}
exports.fromConfig = fromConfig

function fromDirectory (dir, cache) {
  dir = path.resolve(dir)

  const collector = new Collector(cache)
  return resolveDirectory(dir, cache)
    .then(config => config && collector.add(config))
    .then(() => collector.resolveChains(dir))
}
exports.fromDirectory = fromDirectory