From 073a0dc823b83cf708087f181a1e94aeb5c0feb3 Mon Sep 17 00:00:00 2001 From: Logan Smyth Date: Tue, 26 Sep 2017 17:53:56 -0700 Subject: [PATCH 1/6] Split the ignore logic into its own helper class. --- .../src/config/build-config-chain.js | 246 +++++++++--------- 1 file changed, 127 insertions(+), 119 deletions(-) diff --git a/packages/babel-core/src/config/build-config-chain.js b/packages/babel-core/src/config/build-config-chain.js index d52a75666c..6f7364d0e2 100644 --- a/packages/babel-core/src/config/build-config-chain.js +++ b/packages/babel-core/src/config/build-config-chain.js @@ -23,7 +23,9 @@ export default function buildConfigChain(opts: {}): Array | null { } const filename = opts.filename ? path.resolve(opts.filename) : null; - const builder = new ConfigChainBuilder(filename); + const builder = new ConfigChainBuilder( + filename ? new LoadedFile(filename) : null, + ); try { builder.mergeConfig({ @@ -47,123 +49,11 @@ export default function buildConfigChain(opts: {}): Array | null { } class ConfigChainBuilder { - filename: string | null; - configs: Array; - possibleDirs: null | Array; + file: LoadedFile | null; + configs: Array = []; - 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; - - if (ignore) { - if (!Array.isArray(ignore)) { - throw new Error( - `.ignore should be an array, ${JSON.stringify(ignore)} given`, - ); - } - - if (this.matchesPatterns(ignore, dirname)) { - debug( - "Ignored %o because it matched one of %O from %o", - this.filename, - ignore, - dirname, - ); - return true; - } - } - - if (only) { - if (!Array.isArray(only)) { - throw new Error( - `.only should be an array, ${JSON.stringify(only)} given`, - ); - } - - if (!this.matchesPatterns(only, dirname)) { - debug( - "Ignored %o because it failed to match one of %O from %o", - this.filename, - only, - dirname, - ); - return true; - } - } - - return false; - } - - /** - * 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"); - } - - const res = []; - const strings = []; - const fns = []; - - patterns.forEach(pattern => { - if (typeof pattern === "string") strings.push(pattern); - else if (typeof pattern === "function") fns.push(pattern); - else if (pattern instanceof RegExp) res.push(pattern); - else { - throw new Error( - "Patterns must be a string, function, or regular expression", - ); - } - }); - - if (res.some(re => re.test(filename))) return true; - if (fns.some(fn => fn(filename))) return true; - - if (strings.length > 0) { - let possibleDirs = this.possibleDirs; - // Lazy-init so we don't initialize this for files that have no glob patterns. - if (!possibleDirs) { - possibleDirs = this.possibleDirs = []; - - possibleDirs.push(filename); - - let current = filename; - while (true) { - const previous = current; - current = path.dirname(current); - if (previous === current) break; - - possibleDirs.push(current); - } - } - - const absolutePatterns = strings.map(pattern => { - // Preserve the "!" prefix so that micromatch can use it for negation. - const negate = pattern[0] === "!"; - if (negate) pattern = pattern.slice(1); - - return (negate ? "!" : "") + path.resolve(dirname, pattern); - }); - - if ( - micromatch(possibleDirs, absolutePatterns, { nocase: true }).length > 0 - ) { - return true; - } - } - - return false; + constructor(file: LoadedFile | null) { + this.file = file; } findConfigs(loc: string) { @@ -178,10 +68,21 @@ class ConfigChainBuilder { } mergeConfig({ type, options: rawOpts, alias, dirname }) { + 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`, + ); + } + // 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) + this.file && + this.file.shouldIgnore(rawOpts.ignore, rawOpts.only, 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), { @@ -249,3 +150,110 @@ class ConfigChainBuilder { } } } + +/** + * 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.filename = filename; + } + + /** + * Tests if a filename should be ignored based on "ignore" and "only" options. + */ + shouldIgnore( + ignore: ?Array, + only: ?Array, + dirname: string, + ): boolean { + if (ignore) { + if (this._matchesPatterns(ignore, dirname)) { + debug( + "Ignored %o because it matched one of %O from %o", + this.filename, + ignore, + dirname, + ); + return true; + } + } + + if (only) { + if (!this._matchesPatterns(only, dirname)) { + debug( + "Ignored %o because it failed to match one of %O from %o", + this.filename, + only, + dirname, + ); + return true; + } + } + + return false; + } + + /** + * 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): boolean { + const res = []; + const strings = []; + const fns = []; + + patterns.forEach(pattern => { + if (typeof pattern === "string") strings.push(pattern); + else if (typeof pattern === "function") fns.push(pattern); + else if (pattern instanceof RegExp) res.push(pattern); + else { + throw new Error( + "Patterns must be a string, function, or regular expression", + ); + } + }); + + const filename = this.filename; + if (res.some(re => re.test(filename))) return true; + if (fns.some(fn => fn(filename))) return true; + + if (strings.length > 0) { + let possibleDirs = this.possibleDirs; + // Lazy-init so we don't initialize this for files that have no glob patterns. + if (!possibleDirs) { + possibleDirs = this.possibleDirs = []; + + possibleDirs.push(filename); + + let current = filename; + while (true) { + const previous = current; + current = path.dirname(current); + if (previous === current) break; + + possibleDirs.push(current); + } + } + + const absolutePatterns = strings.map(pattern => { + // Preserve the "!" prefix so that micromatch can use it for negation. + const negate = pattern[0] === "!"; + if (negate) pattern = pattern.slice(1); + + return (negate ? "!" : "") + path.resolve(dirname, pattern); + }); + + if ( + micromatch(possibleDirs, absolutePatterns, { nocase: true }).length > 0 + ) { + return true; + } + } + + return false; + } +} From 2d7cda4d285264be46c216c465f41952f8e209ab Mon Sep 17 00:00:00 2001 From: Logan Smyth Date: Tue, 26 Sep 2017 17:57:07 -0700 Subject: [PATCH 2/6] Remove unnecessary function. --- .../src/config/build-config-chain.js | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/babel-core/src/config/build-config-chain.js b/packages/babel-core/src/config/build-config-chain.js index 6f7364d0e2..fe213f439d 100644 --- a/packages/babel-core/src/config/build-config-chain.js +++ b/packages/babel-core/src/config/build-config-chain.js @@ -37,7 +37,16 @@ export default function buildConfigChain(opts: {}): Array | null { // resolve all .babelrc files if (opts.babelrc !== false && filename) { - builder.findConfigs(filename); + findConfigs( + path.dirname(filename), + ).forEach(({ filepath, dirname, options }) => { + builder.mergeConfig({ + type: "options", + options, + alias: filepath, + dirname, + }); + }); } } catch (e) { if (e.code !== "BABEL_IGNORED_FILE") throw e; @@ -56,17 +65,6 @@ class ConfigChainBuilder { this.file = file; } - 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 }) { if (rawOpts.ignore != null && !Array.isArray(rawOpts.ignore)) { throw new Error( From 9a4b764bdea75c7f52bc8fc779610bd7cc69d8f9 Mon Sep 17 00:00:00 2001 From: Logan Smyth Date: Tue, 26 Sep 2017 18:29:28 -0700 Subject: [PATCH 3/6] Centralize config processing in class. --- .../src/config/build-config-chain.js | 42 +++++++++++-------- .../src/config/loading/files/configuration.js | 2 +- .../src/config/loading/files/index-browser.js | 2 +- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/babel-core/src/config/build-config-chain.js b/packages/babel-core/src/config/build-config-chain.js index fe213f439d..3edf48d623 100644 --- a/packages/babel-core/src/config/build-config-chain.js +++ b/packages/babel-core/src/config/build-config-chain.js @@ -7,7 +7,7 @@ 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"; type ConfigItem = { type: "options" | "arguments", @@ -28,25 +28,13 @@ export default function buildConfigChain(opts: {}): Array | null { ); try { - builder.mergeConfig({ - type: "arguments", - options: opts, - alias: "base", - dirname: process.cwd(), - }); + builder.mergeConfigArguments(opts, process.cwd()); // resolve all .babelrc files if (opts.babelrc !== false && filename) { - findConfigs( - path.dirname(filename), - ).forEach(({ filepath, dirname, options }) => { - builder.mergeConfig({ - type: "options", - options, - alias: filepath, - dirname, - }); - }); + findConfigs(path.dirname(filename)).forEach(configFile => + builder.mergeConfigFile(configFile), + ); } } catch (e) { if (e.code !== "BABEL_IGNORED_FILE") throw e; @@ -65,6 +53,26 @@ class ConfigChainBuilder { this.file = file; } + mergeConfigArguments(opts, dirname) { + this.mergeConfig({ + type: "arguments", + options: opts, + alias: "base", + dirname, + }); + } + + mergeConfigFile(file: ConfigFile) { + const { filepath, dirname, options } = file; + + this.mergeConfig({ + type: "options", + options, + alias: filepath, + dirname, + }); + } + mergeConfig({ type, options: rawOpts, alias, dirname }) { if (rawOpts.ignore != null && !Array.isArray(rawOpts.ignore)) { throw new Error( 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: {}, From 7455e58270cf95e4fcdee7115df1fcc76f733717 Mon Sep 17 00:00:00 2001 From: Logan Smyth Date: Tue, 26 Sep 2017 18:31:32 -0700 Subject: [PATCH 4/6] Reuse config file merge. --- packages/babel-core/src/config/build-config-chain.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/babel-core/src/config/build-config-chain.js b/packages/babel-core/src/config/build-config-chain.js index 3edf48d623..4e84132fb7 100644 --- a/packages/babel-core/src/config/build-config-chain.js +++ b/packages/babel-core/src/config/build-config-chain.js @@ -146,12 +146,7 @@ class ConfigChainBuilder { return config.alias === extendsConfig.filepath; }); if (!existingConfig) { - this.mergeConfig({ - type: "options", - alias: extendsConfig.filepath, - options: extendsConfig.options, - dirname: extendsConfig.dirname, - }); + this.mergeConfigFile(extendsConfig); } } } From a599c49436e15c6fd8cf10bec1ad157f63b62889 Mon Sep 17 00:00:00 2001 From: Logan Smyth Date: Tue, 26 Sep 2017 18:58:30 -0700 Subject: [PATCH 5/6] Centralize call to getEnv(). --- .../src/config/build-config-chain.js | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/babel-core/src/config/build-config-chain.js b/packages/babel-core/src/config/build-config-chain.js index 4e84132fb7..e0ffc746c4 100644 --- a/packages/babel-core/src/config/build-config-chain.js +++ b/packages/babel-core/src/config/build-config-chain.js @@ -27,13 +27,14 @@ export default function buildConfigChain(opts: {}): Array | null { filename ? new LoadedFile(filename) : null, ); + const envKey = getEnv(); try { - builder.mergeConfigArguments(opts, process.cwd()); + builder.mergeConfigArguments(opts, process.cwd(), envKey); // resolve all .babelrc files if (opts.babelrc !== false && filename) { findConfigs(path.dirname(filename)).forEach(configFile => - builder.mergeConfigFile(configFile), + builder.mergeConfigFile(configFile, envKey), ); } } catch (e) { @@ -53,27 +54,33 @@ class ConfigChainBuilder { this.file = file; } - mergeConfigArguments(opts, dirname) { - this.mergeConfig({ - type: "arguments", - options: opts, - alias: "base", - dirname, - }); + mergeConfigArguments(opts, dirname, envKey: string) { + this.mergeConfig( + { + type: "arguments", + options: opts, + alias: "base", + dirname, + }, + envKey, + ); } - mergeConfigFile(file: ConfigFile) { + mergeConfigFile(file: ConfigFile, envKey: string) { const { filepath, dirname, options } = file; - this.mergeConfig({ - type: "options", - options, - alias: filepath, - dirname, - }); + this.mergeConfig( + { + type: "options", + options, + alias: filepath, + dirname, + }, + envKey, + ); } - mergeConfig({ type, options: rawOpts, alias, dirname }) { + mergeConfig({ type, options: rawOpts, alias, dirname }, envKey: string) { if (rawOpts.ignore != null && !Array.isArray(rawOpts.ignore)) { throw new Error( `.ignore should be an array, ${JSON.stringify(rawOpts.ignore)} given`, @@ -100,8 +107,6 @@ class ConfigChainBuilder { delete options.env; delete options.extends; - const envKey = getEnv(); - if ( rawOpts.env != null && (typeof rawOpts.env !== "object" || Array.isArray(rawOpts.env)) @@ -119,12 +124,15 @@ class ConfigChainBuilder { } if (envOpts) { - this.mergeConfig({ - type, - options: envOpts, - alias: `${alias}.env.${envKey}`, - dirname: dirname, - }); + this.mergeConfig( + { + type, + options: envOpts, + alias: `${alias}.env.${envKey}`, + dirname: dirname, + }, + envKey, + ); } this.configs.push({ @@ -146,7 +154,7 @@ class ConfigChainBuilder { return config.alias === extendsConfig.filepath; }); if (!existingConfig) { - this.mergeConfigFile(extendsConfig); + this.mergeConfigFile(extendsConfig, envKey); } } } From fc448ca8f2893a6b6b3ce7c5936834fa7813d766 Mon Sep 17 00:00:00 2001 From: Logan Smyth Date: Tue, 26 Sep 2017 20:31:21 -0700 Subject: [PATCH 6/6] Flatten, process, and cache incoming options by key. --- .../src/config/build-config-chain.js | 385 ++++++++++++++---- packages/babel-core/src/config/caching.js | 2 +- packages/babel-core/test/config-chain.js | 229 +++++++++++ .../config-identity/babelignore/.babelignore | 1 + .../config-identity/babelrc-js/.babelrc.js | 7 + .../config-identity/babelrc/.babelrc | 3 + .../config-identity/pkg/package.json | 5 + 7 files changed, 546 insertions(+), 86 deletions(-) create mode 100644 packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelignore/.babelignore create mode 100644 packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelrc-js/.babelrc.js create mode 100644 packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/babelrc/.babelrc create mode 100644 packages/babel-core/test/fixtures/config/complex-plugin-config/config-identity/pkg/package.json diff --git a/packages/babel-core/src/config/build-config-chain.js b/packages/babel-core/src/config/build-config-chain.js index e0ffc746c4..56995ea6f2 100644 --- a/packages/babel-core/src/config/build-config-chain.js +++ b/packages/babel-core/src/config/build-config-chain.js @@ -9,6 +9,8 @@ const debug = buildDebug("babel:config:config-chain"); import { findConfigs, loadConfig, type ConfigFile } from "./loading/files"; +import { makeWeakCache, makeStrongCache } from "./caching"; + type ConfigItem = { type: "options" | "arguments", options: {}, @@ -17,6 +19,13 @@ 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"); @@ -54,101 +63,36 @@ class ConfigChainBuilder { this.file = file; } - mergeConfigArguments(opts, dirname, envKey: string) { - this.mergeConfig( - { - type: "arguments", - options: opts, - alias: "base", - dirname, - }, - envKey, + mergeConfigArguments(opts: {}, dirname: string, envKey: string) { + flattenArgumentsOptionsParts(opts, dirname, envKey).forEach(part => + this._processConfigPart(part, envKey), ); } mergeConfigFile(file: ConfigFile, envKey: string) { - const { filepath, dirname, options } = file; - - this.mergeConfig( - { - type: "options", - options, - alias: filepath, - dirname, - }, - envKey, + flattenFileOptionsParts(file)(envKey).forEach(part => + this._processConfigPart(part, envKey), ); } - mergeConfig({ type, options: rawOpts, alias, dirname }, envKey: string) { - 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`, - ); - } + _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(rawOpts.ignore, rawOpts.only, 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; - - 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, - }, - envKey, - ); - } - - 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"); + // 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", + }); } - const extendsConfig = loadConfig(rawOpts.extends, dirname); + this.configs.push(part.config); + } else { + const extendsConfig = loadConfig(part.path, part.dirname); const existingConfig = this.configs.some(config => { return config.alias === extendsConfig.filepath; @@ -160,6 +104,277 @@ class ConfigChainBuilder { } } +/** + * 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. */ 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/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 + } +}