Merge pull request #6326 from loganfsmyth/preserve-config-identity

Preserve object identity when loading config, for improved future caching.
This commit is contained in:
Logan Smyth 2017-09-29 15:36:03 -07:00 committed by GitHub
commit 828aec757a
9 changed files with 600 additions and 123 deletions

View File

@ -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<ConfigItem> | 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<ConfigItem> | null {
}
class ConfigChainBuilder {
filename: string | null;
configs: Array<ConfigItem>;
possibleDirs: null | Array<string>;
file: LoadedFile | null;
configs: Array<ConfigItem> = [];
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<ConfigPart> {
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<mixed>) => {
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<mixed>) =>
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<ConfigPart> {
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<mixed>,
only: ?Array<mixed>,
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<ConfigPart> {
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<string> = 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<mixed>,
only: ?Array<mixed>,
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<mixed>, dirname: string) {
const filename = this.filename;
if (!filename) {
throw new Error("Assertion failure: .filename should always exist here");
}
_matchesPatterns(patterns: Array<mixed>, 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,
});
}
}
}
}

View File

@ -31,7 +31,7 @@ export function makeStrongCache<ArgT, ResultT>(
* configures its caching behavior. Cached values are stored weakly and the function argument must be
* an object type.
*/
export function makeWeakCache<ArgT: {}, ResultT>(
export function makeWeakCache<ArgT: {} | Array<*>, ResultT>(
handler: (ArgT, CacheConfigurator) => ResultT,
autoPermacache?: boolean,
): ArgT => ResultT {

View File

@ -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: {},

View File

@ -1,6 +1,6 @@
// @flow
type ConfigFile = {
export type ConfigFile = {
filepath: string,
dirname: string,
options: {},

View File

@ -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"),

View File

@ -0,0 +1,7 @@
module.exports = function(api) {
api.env();
return {
comments: false,
};
}

View File

@ -0,0 +1,3 @@
{
comments: false,
}

View File

@ -0,0 +1,5 @@
{
"babel": {
"comments": false
}
}