diff --git a/packages/babel-core/src/config/build-config-chain.js b/packages/babel-core/src/config/build-config-chain.js index d52a75666c..56995ea6f2 100644 --- a/packages/babel-core/src/config/build-config-chain.js +++ b/packages/babel-core/src/config/build-config-chain.js @@ -7,7 +7,9 @@ import buildDebug from "debug"; const debug = buildDebug("babel:config:config-chain"); -import { findConfigs, loadConfig } from "./loading/files"; +import { findConfigs, loadConfig, type ConfigFile } from "./loading/files"; + +import { makeWeakCache, makeStrongCache } from "./caching"; type ConfigItem = { type: "options" | "arguments", @@ -17,25 +19,32 @@ type ConfigItem = { loc: string, }; +type ConfigRaw = { + type: "options" | "arguments", + options: {}, + alias: string, + dirname: string, +}; + export default function buildConfigChain(opts: {}): Array | null { if (typeof opts.filename !== "string" && opts.filename != null) { throw new Error(".filename must be a string, null, or undefined"); } const filename = opts.filename ? path.resolve(opts.filename) : null; - const builder = new ConfigChainBuilder(filename); + const builder = new ConfigChainBuilder( + filename ? new LoadedFile(filename) : null, + ); + const envKey = getEnv(); try { - builder.mergeConfig({ - type: "arguments", - options: opts, - alias: "base", - dirname: process.cwd(), - }); + builder.mergeConfigArguments(opts, process.cwd(), envKey); // resolve all .babelrc files if (opts.babelrc !== false && filename) { - builder.findConfigs(filename); + findConfigs(path.dirname(filename)).forEach(configFile => + builder.mergeConfigFile(configFile, envKey), + ); } } catch (e) { if (e.code !== "BABEL_IGNORED_FILE") throw e; @@ -47,30 +56,346 @@ export default function buildConfigChain(opts: {}): Array | null { } class ConfigChainBuilder { - filename: string | null; - configs: Array; - possibleDirs: null | Array; + file: LoadedFile | null; + configs: Array = []; + + constructor(file: LoadedFile | null) { + this.file = file; + } + + mergeConfigArguments(opts: {}, dirname: string, envKey: string) { + flattenArgumentsOptionsParts(opts, dirname, envKey).forEach(part => + this._processConfigPart(part, envKey), + ); + } + + mergeConfigFile(file: ConfigFile, envKey: string) { + flattenFileOptionsParts(file)(envKey).forEach(part => + this._processConfigPart(part, envKey), + ); + } + + _processConfigPart(part: ConfigPart, envKey: string) { + if (part.part === "config") { + const { ignore, only } = part; + + // Bail out ASAP if this file is ignored so that we run as little logic as possible on ignored files. + if ( + this.file && + this.file.shouldIgnore(ignore, only, part.config.dirname) + ) { + // TODO(logan): This is a really gross way to bail out. Avoid this in rewrite. + throw Object.assign((new Error("This file has been ignored."): any), { + code: "BABEL_IGNORED_FILE", + }); + } + + this.configs.push(part.config); + } else { + const extendsConfig = loadConfig(part.path, part.dirname); + + const existingConfig = this.configs.some(config => { + return config.alias === extendsConfig.filepath; + }); + if (!existingConfig) { + this.mergeConfigFile(extendsConfig, envKey); + } + } + } +} + +/** + * Given the root config object passed to Babel, split it into the separate + * config parts. The resulting config objects in the 'ConfigPart' have their + * object identity preserved between calls so that they can be used for caching. + */ +function flattenArgumentsOptionsParts( + opts: {}, + dirname: string, + envKey: string, +): Array { + const raw = []; + + const env = typeof opts.env === "object" ? opts.env : null; + const plugins = Array.isArray(opts.plugins) ? opts.plugins : null; + const presets = Array.isArray(opts.presets) ? opts.presets : null; + const passPerPreset = + typeof opts.passPerPreset === "boolean" ? opts.passPerPreset : false; + + if (env) { + raw.push(...flattenArgumentsEnvOptionsParts(env)(dirname)(envKey)); + } + + const innerOpts = Object.assign({}, opts); + // If the env, plugins, and presets values on the object aren't arrays or + // objects, leave them in the base opts so that normal options validation + // will throw errors on them later. + if (env) delete innerOpts.env; + if (plugins) delete innerOpts.plugins; + if (presets) { + delete innerOpts.presets; + delete innerOpts.passPerPreset; + } + delete innerOpts.extends; + + if (Object.keys(innerOpts).length > 0) { + raw.push( + ...flattenOptionsParts({ + type: "arguments", + options: innerOpts, + alias: "base", + dirname, + }), + ); + } + + if (plugins) { + raw.push(...flattenArgumentsPluginsOptionsParts(plugins)(dirname)); + } + if (presets) { + raw.push( + ...flattenArgumentsPresetsOptionsParts(presets)(passPerPreset)(dirname), + ); + } + + if (opts.extends != null) { + raw.push( + ...flattenOptionsParts( + buildArgumentsRawConfig({ extends: opts.extends }, dirname), + ), + ); + } + + return raw; +} + +/** + * For the top-level 'options' object, we cache the env list based on + * the object identity of the 'env' object. + */ +const flattenArgumentsEnvOptionsParts = makeWeakCache((env: {}) => { + const options = { env }; + + return makeStrongCache((dirname: string) => + flattenOptionsPartsLookup(buildArgumentsRawConfig(options, dirname)), + ); +}); + +/** + * For the top-level 'options' object, we cache the plugin list based on + * the object identity of the 'plugins' object. + */ +const flattenArgumentsPluginsOptionsParts = makeWeakCache( + (plugins: Array) => { + const options = { plugins }; + + return makeStrongCache((dirname: string) => + flattenOptionsParts(buildArgumentsRawConfig(options, dirname)), + ); + }, +); + +/** + * For the top-level 'options' object, we cache the preset list based on + * the object identity of the 'presets' object. + */ +const flattenArgumentsPresetsOptionsParts = makeWeakCache( + (presets: Array) => + makeStrongCache((passPerPreset: ?boolean) => { + // The concept of passPerPreset is integrally tied to the preset list + // so unfortunately we need to copy both values here, adding an extra + // layer of caching functions. + const options = { presets, passPerPreset }; + + return makeStrongCache((dirname: string) => + flattenOptionsParts(buildArgumentsRawConfig(options, dirname)), + ); + }), +); + +function buildArgumentsRawConfig(options: {}, dirname: string): ConfigRaw { + return { + type: "arguments", + options, + alias: "base", + dirname, + }; +} + +/** + * Given a config from a specific file, return a list of ConfigPart objects + * with object identity preserved for all 'config' part objects for use + * with caching later in config processing. + */ +const flattenFileOptionsParts = makeWeakCache((file: ConfigFile) => { + return flattenOptionsPartsLookup({ + type: "options", + options: file.options, + alias: file.filepath, + dirname: file.dirname, + }); +}); + +/** + * Given a config, create a function that will return the config parts for + * the environment passed as the first argument. + */ +function flattenOptionsPartsLookup( + config: ConfigRaw, +): (string | null) => Array { + const parts = flattenOptionsParts(config); + + const def = parts.filter(part => part.activeEnv === null); + const lookup = new Map(); + + parts.forEach(part => { + if (part.activeEnv !== null) lookup.set(part.activeEnv, []); + }); + + for (const [activeEnv, values] of lookup) { + parts.forEach(part => { + if (part.activeEnv === null || part.activeEnv === activeEnv) { + values.push(part); + } + }); + } + + return envKey => lookup.get(envKey) || def; +} + +type ConfigPart = + | { + part: "config", + config: ConfigItem, + ignore: ?Array, + only: ?Array, + activeEnv: string | null, + } + | { + part: "extends", + path: string, + dirname: string, + activeEnv: string | null, + }; + +/** + * Given a generic config object, flatten it into its various parts so that + * then can be cached and processed later. + */ +function flattenOptionsParts( + rawConfig: ConfigRaw, + activeEnv: string | null = null, +): Array { + const { type, options: rawOpts, alias, dirname } = rawConfig; + + if (rawOpts.ignore != null && !Array.isArray(rawOpts.ignore)) { + throw new Error( + `.ignore should be an array, ${JSON.stringify(rawOpts.ignore)} given`, + ); + } + if (rawOpts.only != null && !Array.isArray(rawOpts.only)) { + throw new Error( + `.only should be an array, ${JSON.stringify(rawOpts.only)} given`, + ); + } + const ignore = rawOpts.ignore || null; + const only = rawOpts.only || null; + + const parts = []; + + if ( + rawOpts.env != null && + (typeof rawOpts.env !== "object" || Array.isArray(rawOpts.env)) + ) { + throw new Error(".env block must be an object, null, or undefined"); + } + + const rawEnv = rawOpts.env || {}; + + Object.keys(rawEnv).forEach(envKey => { + const envOpts = rawEnv[envKey]; + + if (envOpts !== undefined && activeEnv !== null && activeEnv !== envKey) { + throw new Error(`Unreachable .env[${envKey}] block detected`); + } + + if ( + envOpts != null && + (typeof envOpts !== "object" || Array.isArray(envOpts)) + ) { + throw new Error(".env[...] block must be an object, null, or undefined"); + } + + if (envOpts) { + parts.push( + ...flattenOptionsParts( + { + type, + options: envOpts, + alias: alias + `.env.${envKey}`, + dirname, + }, + envKey, + ), + ); + } + }); + + const options = Object.assign({}, rawOpts); + delete options.env; + delete options.extends; + + parts.push({ + part: "config", + config: { + type, + options, + alias, + loc: alias, + dirname, + }, + ignore, + only, + activeEnv, + }); + + if (rawOpts.extends != null) { + if (typeof rawOpts.extends !== "string") { + throw new Error(".extends must be a string"); + } + + parts.push({ + part: "extends", + path: rawOpts.extends, + dirname, + activeEnv, + }); + } + + return parts; +} + +/** + * Track a given file and expose function to check if it should be ignored. + */ +class LoadedFile { + filename: string; + possibleDirs: null | Array = null; constructor(filename) { - this.configs = []; this.filename = filename; - this.possibleDirs = null; } /** * Tests if a filename should be ignored based on "ignore" and "only" options. */ - shouldIgnore(ignore: mixed, only: mixed, dirname: string): boolean { - if (!this.filename) return false; - + shouldIgnore( + ignore: ?Array, + only: ?Array, + dirname: string, + ): boolean { if (ignore) { - if (!Array.isArray(ignore)) { - throw new Error( - `.ignore should be an array, ${JSON.stringify(ignore)} given`, - ); - } - - if (this.matchesPatterns(ignore, dirname)) { + if (this._matchesPatterns(ignore, dirname)) { debug( "Ignored %o because it matched one of %O from %o", this.filename, @@ -82,13 +407,7 @@ class ConfigChainBuilder { } if (only) { - if (!Array.isArray(only)) { - throw new Error( - `.only should be an array, ${JSON.stringify(only)} given`, - ); - } - - if (!this.matchesPatterns(only, dirname)) { + if (!this._matchesPatterns(only, dirname)) { debug( "Ignored %o because it failed to match one of %O from %o", this.filename, @@ -106,12 +425,7 @@ class ConfigChainBuilder { * Returns result of calling function with filename if pattern is a function. * Otherwise returns result of matching pattern Regex with filename. */ - matchesPatterns(patterns: Array, dirname: string) { - const filename = this.filename; - if (!filename) { - throw new Error("Assertion failure: .filename should always exist here"); - } - + _matchesPatterns(patterns: Array, dirname: string): boolean { const res = []; const strings = []; const fns = []; @@ -127,6 +441,7 @@ class ConfigChainBuilder { } }); + const filename = this.filename; if (res.some(re => re.test(filename))) return true; if (fns.some(fn => fn(filename))) return true; @@ -165,87 +480,4 @@ class ConfigChainBuilder { return false; } - - findConfigs(loc: string) { - findConfigs(path.dirname(loc)).forEach(({ filepath, dirname, options }) => { - this.mergeConfig({ - type: "options", - options, - alias: filepath, - dirname, - }); - }); - } - - mergeConfig({ type, options: rawOpts, alias, dirname }) { - // Bail out ASAP if this file is ignored so that we run as little logic as possible on ignored files. - if ( - this.filename && - this.shouldIgnore(rawOpts.ignore || null, rawOpts.only || null, dirname) - ) { - // TODO(logan): This is a really cross way to bail out. Avoid this in rewrite. - throw Object.assign((new Error("This file has been ignored."): any), { - code: "BABEL_IGNORED_FILE", - }); - } - - const options = Object.assign({}, rawOpts); - delete options.env; - delete options.extends; - - const envKey = getEnv(); - - if ( - rawOpts.env != null && - (typeof rawOpts.env !== "object" || Array.isArray(rawOpts.env)) - ) { - throw new Error(".env block must be an object, null, or undefined"); - } - - const envOpts = rawOpts.env && rawOpts.env[envKey]; - - if ( - envOpts != null && - (typeof envOpts !== "object" || Array.isArray(envOpts)) - ) { - throw new Error(".env[...] block must be an object, null, or undefined"); - } - - if (envOpts) { - this.mergeConfig({ - type, - options: envOpts, - alias: `${alias}.env.${envKey}`, - dirname: dirname, - }); - } - - this.configs.push({ - type, - options, - alias, - loc: alias, - dirname, - }); - - if (rawOpts.extends) { - if (typeof rawOpts.extends !== "string") { - throw new Error(".extends must be a string"); - } - - const extendsConfig = loadConfig(rawOpts.extends, dirname); - - const existingConfig = this.configs.some(config => { - return config.alias === extendsConfig.filepath; - }); - if (!existingConfig) { - this.mergeConfig({ - type: "options", - alias: extendsConfig.filepath, - options: extendsConfig.options, - dirname: extendsConfig.dirname, - }); - } - } - } } diff --git a/packages/babel-core/src/config/caching.js b/packages/babel-core/src/config/caching.js index 24d3e748bb..052e2803c3 100644 --- a/packages/babel-core/src/config/caching.js +++ b/packages/babel-core/src/config/caching.js @@ -31,7 +31,7 @@ export function makeStrongCache( * configures its caching behavior. Cached values are stored weakly and the function argument must be * an object type. */ -export function makeWeakCache( +export function makeWeakCache, ResultT>( handler: (ArgT, CacheConfigurator) => ResultT, autoPermacache?: boolean, ): ArgT => ResultT { diff --git a/packages/babel-core/src/config/loading/files/configuration.js b/packages/babel-core/src/config/loading/files/configuration.js index 861ab73039..09bd840230 100644 --- a/packages/babel-core/src/config/loading/files/configuration.js +++ b/packages/babel-core/src/config/loading/files/configuration.js @@ -10,7 +10,7 @@ import { makeStrongCache } from "../../caching"; const debug = buildDebug("babel:config:loading:files:configuration"); -type ConfigFile = { +export type ConfigFile = { filepath: string, dirname: string, options: {}, diff --git a/packages/babel-core/src/config/loading/files/index-browser.js b/packages/babel-core/src/config/loading/files/index-browser.js index 79d5325291..2c8f80d79f 100644 --- a/packages/babel-core/src/config/loading/files/index-browser.js +++ b/packages/babel-core/src/config/loading/files/index-browser.js @@ -1,6 +1,6 @@ // @flow -type ConfigFile = { +export type ConfigFile = { filepath: string, dirname: string, options: {}, diff --git a/packages/babel-core/test/config-chain.js b/packages/babel-core/test/config-chain.js index 3f93a7d18c..aeee3c7cad 100644 --- a/packages/babel-core/test/config-chain.js +++ b/packages/babel-core/test/config-chain.js @@ -1,4 +1,5 @@ import assert from "assert"; +import fs from "fs"; import path from "path"; import buildConfigChain from "../lib/config/build-config-chain"; @@ -52,6 +53,234 @@ describe("buildConfigChain", function() { }); }); + describe("caching", function() { + describe("programmatic options", function() { + it("should not cache the input options by identity", () => { + const comments = false; + + const chain1 = buildConfigChain({ comments }); + const chain2 = buildConfigChain({ comments }); + + assert.equal(chain1.length, 1); + assert.equal(chain2.length, 1); + assert.notStrictEqual(chain1[0], chain2[0]); + }); + + it("should cache the env options by identity", () => { + process.env.NODE_ENV = "foo"; + const env = { + foo: { + comments: false, + }, + }; + + const chain1 = buildConfigChain({ env }); + const chain2 = buildConfigChain({ env }); + + assert.equal(chain1.length, 2); + assert.equal(chain2.length, 2); + assert.strictEqual(chain1[0], chain2[0]); + assert.strictEqual(chain1[1], chain2[1]); + }); + + it("should cache the plugin options by identity", () => { + const plugins = []; + + const chain1 = buildConfigChain({ plugins }); + const chain2 = buildConfigChain({ plugins }); + + assert.equal(chain1.length, 1); + assert.equal(chain2.length, 1); + assert.strictEqual(chain1[0], chain2[0]); + }); + + it("should cache the presets options by identity", () => { + const presets = []; + + const chain1 = buildConfigChain({ presets }); + const chain2 = buildConfigChain({ presets }); + + assert.equal(chain1.length, 1); + assert.equal(chain2.length, 1); + assert.strictEqual(chain1[0], chain2[0]); + }); + + it("should not cache the presets options with passPerPreset", () => { + const presets = []; + + const chain1 = buildConfigChain({ presets }); + const chain2 = buildConfigChain({ presets, passPerPreset: true }); + const chain3 = buildConfigChain({ presets, passPerPreset: false }); + + assert.equal(chain1.length, 1); + assert.equal(chain2.length, 1); + assert.equal(chain3.length, 1); + assert.notStrictEqual(chain1[0], chain2[0]); + assert.strictEqual(chain1[0], chain3[0]); + assert.notStrictEqual(chain2[0], chain3[0]); + }); + }); + + describe("config file options", function() { + function touch(filepath) { + const s = fs.statSync(filepath); + fs.utimesSync( + filepath, + s.atime, + s.mtime + Math.random() > 0.5 ? 1 : -1, + ); + } + + it("should cache package.json files by mtime", () => { + const filename = fixture( + "complex-plugin-config", + "config-identity", + "pkg", + "src.js", + ); + const pkgJSON = fixture( + "complex-plugin-config", + "config-identity", + "pkg", + "package.json", + ); + + const chain1 = buildConfigChain({ filename }); + const chain2 = buildConfigChain({ filename }); + + touch(pkgJSON); + + const chain3 = buildConfigChain({ filename }); + const chain4 = buildConfigChain({ filename }); + + assert.equal(chain1.length, 3); + assert.equal(chain2.length, 3); + assert.equal(chain3.length, 3); + assert.equal(chain4.length, 3); + assert.equal(chain1[1].alias, pkgJSON); + assert.equal(chain2[1].alias, pkgJSON); + assert.equal(chain3[1].alias, pkgJSON); + assert.equal(chain4[1].alias, pkgJSON); + assert.strictEqual(chain1[1], chain2[1]); + + // Identity changed after touch(). + assert.notStrictEqual(chain3[1], chain1[1]); + assert.strictEqual(chain3[1], chain4[1]); + }); + + it("should cache .babelrc files by mtime", () => { + const filename = fixture( + "complex-plugin-config", + "config-identity", + "babelrc", + "src.js", + ); + const babelrcFile = fixture( + "complex-plugin-config", + "config-identity", + "babelrc", + ".babelrc", + ); + + const chain1 = buildConfigChain({ filename }); + const chain2 = buildConfigChain({ filename }); + + touch(babelrcFile); + + const chain3 = buildConfigChain({ filename }); + const chain4 = buildConfigChain({ filename }); + + assert.equal(chain1.length, 3); + assert.equal(chain2.length, 3); + assert.equal(chain3.length, 3); + assert.equal(chain4.length, 3); + assert.equal(chain1[1].alias, babelrcFile); + assert.equal(chain2[1].alias, babelrcFile); + assert.equal(chain3[1].alias, babelrcFile); + assert.equal(chain4[1].alias, babelrcFile); + assert.strictEqual(chain1[1], chain2[1]); + + // Identity changed after touch(). + assert.notStrictEqual(chain3[1], chain1[1]); + assert.strictEqual(chain3[1], chain4[1]); + }); + + it("should cache .babelignore files by mtime", () => { + const filename = fixture( + "complex-plugin-config", + "config-identity", + "babelignore", + "src.js", + ); + const babelignoreFile = fixture( + "complex-plugin-config", + "config-identity", + "babelignore", + ".babelignore", + ); + + const chain1 = buildConfigChain({ filename }); + const chain2 = buildConfigChain({ filename }); + + touch(babelignoreFile); + + const chain3 = buildConfigChain({ filename }); + const chain4 = buildConfigChain({ filename }); + + assert.equal(chain1.length, 6); + assert.equal(chain2.length, 6); + assert.equal(chain3.length, 6); + assert.equal(chain4.length, 6); + assert.equal(chain1[4].alias, babelignoreFile); + assert.equal(chain2[4].alias, babelignoreFile); + assert.equal(chain3[4].alias, babelignoreFile); + assert.equal(chain4[4].alias, babelignoreFile); + assert.strictEqual(chain1[4], chain2[4]); + + // Identity changed after touch(). + assert.notStrictEqual(chain3[4], chain1[4]); + assert.strictEqual(chain3[4], chain4[4]); + }); + + it("should cache .babelrc.js files programmable behavior", () => { + const filename = fixture( + "complex-plugin-config", + "config-identity", + "babelrc-js", + "src.js", + ); + const babelrcFile = fixture( + "complex-plugin-config", + "config-identity", + "babelrc-js", + ".babelrc.js", + ); + + const chain1 = buildConfigChain({ filename }); + const chain2 = buildConfigChain({ filename }); + + process.env.NODE_ENV = "new-env"; + + const chain3 = buildConfigChain({ filename }); + const chain4 = buildConfigChain({ filename }); + + assert.equal(chain1.length, 3); + assert.equal(chain2.length, 3); + assert.equal(chain3.length, 3); + assert.equal(chain4.length, 3); + assert.equal(chain1[1].alias, babelrcFile); + assert.equal(chain2[1].alias, babelrcFile); + assert.equal(chain3[1].alias, babelrcFile); + assert.equal(chain4[1].alias, babelrcFile); + assert.strictEqual(chain1[1], chain2[1]); + + // Identity changed after changing the NODE_ENV. + assert.notStrictEqual(chain3[1], chain1[1]); + assert.strictEqual(chain3[1], chain4[1]); + }); + }); + }); + it("dir1", function() { const chain = buildConfigChain({ filename: fixture("dir1", "src.js"), diff --git a/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelignore/.babelignore b/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelignore/.babelignore new file mode 100644 index 0000000000..3f6555f66d --- /dev/null +++ b/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelignore/.babelignore @@ -0,0 +1 @@ +fake-file.js diff --git a/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelrc-js/.babelrc.js b/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelrc-js/.babelrc.js new file mode 100644 index 0000000000..968dcc5d7d --- /dev/null +++ b/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelrc-js/.babelrc.js @@ -0,0 +1,7 @@ +module.exports = function(api) { + api.env(); + + return { + comments: false, + }; +} diff --git a/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelrc/.babelrc b/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelrc/.babelrc new file mode 100644 index 0000000000..68628f8709 --- /dev/null +++ b/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelrc/.babelrc @@ -0,0 +1,3 @@ +{ + comments: false, +} diff --git a/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/pkg/package.json b/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/pkg/package.json new file mode 100644 index 0000000000..612c17bb1f --- /dev/null +++ b/packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/pkg/package.json @@ -0,0 +1,5 @@ +{ + "babel": { + "comments": false + } +}