From 758fd0369cd568f9344db465a1a0f76dfd90e2c1 Mon Sep 17 00:00:00 2001 From: Logan Smyth Date: Tue, 19 Dec 2017 18:10:51 -0800 Subject: [PATCH] Rewrite config chain loading to build chain recursively to keep caching readable. --- .../src/config/build-config-chain.js | 706 ------------------ .../babel-core/src/config/config-chain.js | 452 +++++++++++ .../src/config/config-descriptors.js | 272 +++++++ packages/babel-core/src/config/index.js | 282 ++++++- .../babel-core/src/config/option-manager.js | 278 ------- packages/babel-core/test/config-chain.js | 49 +- 6 files changed, 1039 insertions(+), 1000 deletions(-) delete mode 100644 packages/babel-core/src/config/build-config-chain.js create mode 100644 packages/babel-core/src/config/config-chain.js create mode 100644 packages/babel-core/src/config/config-descriptors.js delete mode 100644 packages/babel-core/src/config/option-manager.js diff --git a/packages/babel-core/src/config/build-config-chain.js b/packages/babel-core/src/config/build-config-chain.js deleted file mode 100644 index 53d4e88fa3..0000000000 --- a/packages/babel-core/src/config/build-config-chain.js +++ /dev/null @@ -1,706 +0,0 @@ -// @flow - -import path from "path"; -import micromatch from "micromatch"; -import buildDebug from "debug"; -import { - validate, - type ValidatedOptions, - type PluginItem, - type PluginList, - type IgnoreList, -} from "./validation/options"; - -const debug = buildDebug("babel:config:config-chain"); - -import { - loadPlugin, - loadPreset, - findBabelrc, - findBabelignore, - loadConfig, - type ConfigFile, -} from "./files"; - -import { makeWeakCache, makeStrongCache } from "./caching"; - -type ConfigItem = { - type: "arguments" | "env" | "file", - options: ValidatedOptions, - alias: string, - dirname: string, -}; - -export type ConfigChain = { - plugins: Array, - presets: Array, - options: Array, -}; - -export type BasicDescriptor = { - name: string | void, - value: {} | Function, - options: {} | void | false, - dirname: string, - alias: string, - ownPass?: boolean, -}; - -export type PresetInstance = SimpleConfig; - -type LoadedConfig = { - options: ValidatedOptions, - plugins: Array, - presets: Array, -}; - -type ConfigPart = - | { - part: "config", - config: ConfigItem, - ignore: ?IgnoreList, - only: ?IgnoreList, - activeEnv: string | null, - } - | { - part: "extends", - path: string, - dirname: string, - activeEnv: string | null, - }; - -type SimpleConfig = { - options: ValidatedOptions, - alias: string, - dirname: string, -}; - -type ConfigContext = { - filename: string | null, - cwd: string, - envName: string, -}; - -type ConfigContextNamed = { - ...ConfigContext, - filename: string, -}; - -export const buildPresetChain = makeWeakCache( - (preset: PresetInstance): ConfigChain => { - const loaded = processConfig(preset); - - return { - plugins: loaded.plugins, - presets: loaded.presets, - options: [loaded.options], - }; - }, -); - -export function buildRootChain( - cwd: string, - opts: ValidatedOptions, - envName: string, -): ConfigChain | null { - const context = { - filename: opts.filename ? path.resolve(cwd, opts.filename) : null, - cwd, - envName, - }; - const builder = new ConfigChainBuilder(context); - - try { - builder.mergeConfigArguments(opts); - - // resolve all .babelrc files - if (opts.babelrc !== false && context.filename !== null) { - const filename = context.filename; - const babelrcFile = findBabelrc(filename, context.envName); - if (babelrcFile) builder.mergeConfigFile(babelrcFile); - - const babelignoreFile = findBabelignore(filename); - if ( - babelignoreFile && - shouldIgnore( - context, - babelignoreFile.ignore, - null, - babelignoreFile.dirname, - ) - ) { - return null; - } - } - } catch (e) { - if (e.code !== "BABEL_IGNORED_FILE") throw e; - - return null; - } - - return dedupLoadedConfigs( - builder.configs.reverse().map(config => processConfig(config)), - ); -} - -class ConfigChainBuilder { - context: ConfigContext; - configs: Array = []; - seenFiles: Set = new Set(); - - constructor(context: ConfigContext) { - this.context = context; - } - - mergeConfigArguments(opts: ValidatedOptions) { - flattenArgumentsOptionsParts( - opts, - this.context.cwd, - this.context.envName, - ).forEach(part => this._processConfigPart(part)); - } - - mergeConfigFile(file: ConfigFile) { - if (this.seenFiles.has(file)) { - throw new Error( - `Cycle detected in Babel configuration file through "${ - file.filepath - }".`, - ); - } - - const parts = flattenFileOptionsParts(file)(this.context.envName); - - this.seenFiles.add(file); - parts.forEach(part => this._processConfigPart(part)); - this.seenFiles.delete(file); - } - - _processConfigPart(part: ConfigPart) { - 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 (shouldIgnore(this.context, 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 { - this.mergeConfigFile( - loadConfig(part.path, part.dirname, this.context.envName), - ); - } - } -} - -/** - * Given a plugin/preset item, resolve it into a standard format. - */ -function createDescriptor( - pair: PluginItem, - resolver, - dirname, - { - index, - alias, - ownPass, - }: { - index: number, - alias: string, - ownPass?: boolean, - }, -): BasicDescriptor { - let name; - let options; - let value = pair; - if (Array.isArray(value)) { - if (value.length === 3) { - // $FlowIgnore - Flow doesn't like the multiple tuple types. - [value, options, name] = value; - } else { - [value, options] = value; - } - } - - let filepath = null; - if (typeof value === "string") { - ({ filepath, value } = resolver(value, dirname)); - } - - if (!value) { - throw new Error(`Unexpected falsy value: ${String(value)}`); - } - - if (typeof value === "object" && value.__esModule) { - if (value.default) { - value = value.default; - } else { - throw new Error("Must export a default export when using ES6 modules."); - } - } - - if (typeof value !== "object" && typeof value !== "function") { - throw new Error( - `Unsupported format: ${typeof value}. Expected an object or a function.`, - ); - } - - if (filepath !== null && typeof value === "object" && value) { - // We allow object values for plugins/presets nested directly within a - // config object, because it can be useful to define them in nested - // configuration contexts. - throw new Error( - "Plugin/Preset files are not allowed to export objects, only functions.", - ); - } - - return { - name, - alias: filepath || `${alias}$${index}`, - value, - options, - dirname, - ownPass, - }; -} - -function normalizeOptions(opts: ValidatedOptions): ValidatedOptions { - const options = Object.assign({}, opts); - delete options.extends; - delete options.env; - delete options.plugins; - delete options.presets; - delete options.passPerPreset; - delete options.ignore; - delete options.only; - - // "sourceMap" is just aliased to sourceMap, so copy it over as - // we merge the options together. - if (options.sourceMap) { - options.sourceMaps = options.sourceMap; - delete options.sourceMap; - } - return options; -} - -/** - * Load and validate the given config into a set of options, plugins, and presets. - */ -const processConfig = makeWeakCache((config: SimpleConfig): LoadedConfig => { - const plugins = (config.options.plugins || []).map((plugin, index) => - createDescriptor(plugin, loadPlugin, config.dirname, { - index, - alias: config.alias, - }), - ); - - assertNoDuplicates(plugins); - - const presets = (config.options.presets || []).map((preset, index) => - createDescriptor(preset, loadPreset, config.dirname, { - index, - alias: config.alias, - ownPass: config.options.passPerPreset, - }), - ); - - assertNoDuplicates(presets); - - return { - options: normalizeOptions(config.options), - plugins, - presets, - }; -}); - -function assertNoDuplicates(items: Array): void { - const map = new Map(); - - for (const item of items) { - if (typeof item.value !== "function") continue; - - let nameMap = map.get(item.value); - if (!nameMap) { - nameMap = new Set(); - map.set(item.value, nameMap); - } - - if (nameMap.has(item.name)) { - throw new Error( - [ - `Duplicate plugin/preset detected.`, - `If you'd like to use two separate instances of a plugin,`, - `they neen separate names, e.g.`, - ``, - ` plugins: [`, - ` ['some-plugin', {}],`, - ` ['some-plugin', {}, 'some unique name'],`, - ` ]`, - ].join("\n"), - ); - } - - nameMap.add(item.name); - } -} - -function dedupLoadedConfigs(items: Array): ConfigChain { - const options = []; - const plugins = []; - const presets = []; - - for (const item of items) { - plugins.push(...item.plugins); - presets.push(...item.presets); - options.push(item.options); - } - - return { - options, - plugins: dedupDescriptors(plugins), - presets: dedupDescriptors(presets), - }; -} - -function dedupDescriptors( - items: Array, -): Array { - const map: Map< - Function, - Map, - > = new Map(); - - const descriptors = []; - - for (const item of items) { - if (typeof item.value === "function") { - const fnKey = item.value; - let nameMap = map.get(fnKey); - if (!nameMap) { - nameMap = new Map(); - map.set(fnKey, nameMap); - } - let desc = nameMap.get(item.name); - if (!desc) { - desc = { value: null }; - descriptors.push(desc); - - // Treat passPerPreset presets as unique, skipping them - // in the merge processing steps. - if (!item.ownPass) nameMap.set(item.name, desc); - } - - if (item.options === false) { - desc.value = null; - } else { - desc.value = item; - } - } else { - descriptors.push({ value: item }); - } - } - - return descriptors.reduce((acc, desc) => { - if (desc.value) acc.push(desc.value); - return acc; - }, []); -} - -/** - * 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: ValidatedOptions, - dirname: string, - envName: string, -): Array { - const { - env, - plugins, - presets, - passPerPreset, - extends: extendsPath, - ...options - } = opts; - - const raw = []; - if (env) { - raw.push(...flattenArgumentsEnvOptionsParts(env)(dirname)(envName)); - } - - if (Object.keys(options).length > 0) { - raw.push(...flattenOptionsParts(buildArgumentsItem(options, dirname))); - } - - if (plugins) { - raw.push(...flattenArgumentsPluginsOptionsParts(plugins)(dirname)); - } - if (presets) { - raw.push( - ...flattenArgumentsPresetsOptionsParts(presets)(!!passPerPreset)(dirname), - ); - } - - if (extendsPath != null) { - raw.push( - ...flattenOptionsParts( - buildArgumentsItem({ extends: extendsPath }, 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: ValidatedOptions = { env }; - - return makeStrongCache((dirname: string) => - flattenOptionsPartsLookup(buildArgumentsItem(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: PluginList) => { - const options: ValidatedOptions = { plugins }; - - return makeStrongCache((dirname: string) => - flattenOptionsParts(buildArgumentsItem(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: PluginList) => - 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(buildArgumentsItem(options, dirname)), - ); - }), -); - -function buildArgumentsItem( - options: ValidatedOptions, - dirname: string, -): ConfigItem { - 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: "file", - options: validate("file", 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: ConfigItem, -): (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 envName => lookup.get(envName) || def; -} - -/** - * Given a generic config object, flatten it into its various parts so that - * then can be cached and processed later. - */ -function flattenOptionsParts( - config: ConfigItem, - activeEnv: string | null = null, -): Array { - const { options: rawOpts, alias, dirname } = config; - - const parts = []; - - if (rawOpts.env) { - for (const envKey of Object.keys(rawOpts.env)) { - if (rawOpts.env[envKey]) { - parts.push( - ...flattenOptionsParts( - { - type: "env", - options: rawOpts.env[envKey], - alias: alias + `.env.${envKey}`, - dirname, - }, - envKey, - ), - ); - } - } - } - - parts.push({ - part: "config", - config, - ignore: rawOpts.ignore, - only: rawOpts.only, - activeEnv, - }); - - if (rawOpts.extends != null) { - parts.push({ - part: "extends", - path: rawOpts.extends, - dirname, - activeEnv, - }); - } - - return parts; -} - -/** - * Tests if a filename should be ignored based on "ignore" and "only" options. - */ -function shouldIgnore( - context: ConfigContext, - ignore: ?IgnoreList, - only: ?IgnoreList, - dirname: string, -): boolean { - if (context.filename === null) return false; - // $FlowIgnore - Flow refinements aren't quite smart enough for this :( - const ctx: ConfigContextNamed = context; - - if (ignore) { - if (matchesPatterns(ctx, ignore, dirname)) { - debug( - "Ignored %o because it matched one of %O from %o", - context.filename, - ignore, - dirname, - ); - return true; - } - } - - if (only) { - if (!matchesPatterns(ctx, only, dirname)) { - debug( - "Ignored %o because it failed to match one of %O from %o", - context.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. - */ -function matchesPatterns( - context: ConfigContextNamed, - patterns: IgnoreList, - 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 res.push(pattern); - }); - - const filename = context.filename; - if (res.some(re => re.test(context.filename))) return true; - if (fns.some(fn => fn(filename))) return true; - - if (strings.length > 0) { - const possibleDirs = getPossibleDirs(context); - - 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; -} - -const getPossibleDirs = makeWeakCache((context: ConfigContextNamed) => { - let current = context.filename; - if (current === null) return []; - - const possibleDirs = [current]; - while (true) { - const previous = current; - current = path.dirname(current); - if (previous === current) break; - - possibleDirs.push(current); - } - - return possibleDirs; -}); diff --git a/packages/babel-core/src/config/config-chain.js b/packages/babel-core/src/config/config-chain.js new file mode 100644 index 0000000000..5ecbde756f --- /dev/null +++ b/packages/babel-core/src/config/config-chain.js @@ -0,0 +1,452 @@ +// @flow + +import path from "path"; +import micromatch from "micromatch"; +import buildDebug from "debug"; +import { + validate, + type ValidatedOptions, + type IgnoreList, +} from "./validation/options"; + +const debug = buildDebug("babel:config:config-chain"); + +import { + findBabelrc, + findBabelignore, + loadConfig, + type ConfigFile, +} from "./files"; + +import { makeWeakCache, makeStrongCache } from "./caching"; + +import { + createCachedDescriptors, + createUncachedDescriptors, + type UnloadedDescriptor, + type OptionsAndDescriptors, + type ValidatedFile, +} from "./config-descriptors"; + +export type ConfigChain = { + plugins: Array, + presets: Array, + options: Array, +}; + +export type PresetInstance = { + options: ValidatedOptions, + alias: string, + dirname: string, +}; + +type ConfigContext = { + filename: string | null, + cwd: string, + envName: string, +}; + +type ConfigContextNamed = { + ...ConfigContext, + filename: string, +}; + +/** + * Build a config chain for a given preset. + */ +export const buildPresetChain = makeWeakCache( + ({ dirname, options, alias }: PresetInstance): ConfigChain => { + const result = createUncachedDescriptors(dirname, options, alias); + const { plugins, presets } = result; + return { + plugins: plugins(), + presets: presets(), + options: [normalizeOptions(result.options)], + }; + }, +); + +/** + * Build a config chain for Babel's full root configuration. + */ +export function buildRootChain( + cwd: string, + opts: ValidatedOptions, + envName: string, +): ConfigChain | null { + const context = { + filename: opts.filename ? path.resolve(cwd, opts.filename) : null, + cwd, + envName, + }; + + const programmaticChain = loadProgrammaticChain( + { + options: opts, + dirname: context.cwd, + }, + context, + ); + if (!programmaticChain) return null; + + const fileChain = emptyChain(); + // resolve all .babelrc files + if (opts.babelrc !== false && context.filename !== null) { + const filename = context.filename; + const babelrcFile = findBabelrc(filename, context.envName); + if (babelrcFile) { + const result = loadFileChain(babelrcFile, context); + if (!result) return null; + + mergeChain(fileChain, result); + } + + const babelignoreFile = findBabelignore(filename); + if ( + babelignoreFile && + shouldIgnore( + context, + babelignoreFile.ignore, + null, + babelignoreFile.dirname, + ) + ) { + return null; + } + } + + // Insert file chain in front so programmatic options have priority + // over configuration file chain items. + const chain = mergeChain( + mergeChain(emptyChain(), fileChain), + programmaticChain, + ); + + return { + plugins: dedupDescriptors(chain.plugins), + presets: dedupDescriptors(chain.presets), + options: chain.options.map(o => normalizeOptions(o)), + }; +} + +/** + * Build a config chain for just the programmatic options passed into Babel. + */ +const loadProgrammaticChain = makeChainWalker({ + init: arg => arg, + root: input => buildRootDescriptors(input, "base", createCachedDescriptors), + env: (input, envName) => + buildEnvDescriptors(input, "base", createCachedDescriptors, envName), +}); + +/** + * Build a config chain for a given file. + */ +const loadFileChain = makeChainWalker({ + init: input => validateFile(input), + root: file => loadFileDescriptors(file), + env: (file, envName) => loadFileEnvDescriptors(file)(envName), +}); +const validateFile = makeWeakCache((file: ConfigFile): ValidatedFile => ({ + filepath: file.filepath, + dirname: file.dirname, + options: validate("file", file.options), +})); +const loadFileDescriptors = makeWeakCache((file: ValidatedFile) => + buildRootDescriptors(file, file.filepath, createUncachedDescriptors), +); +const loadFileEnvDescriptors = makeWeakCache((file: ValidatedFile) => + makeStrongCache((envName: string) => + buildEnvDescriptors( + file, + file.filepath, + createUncachedDescriptors, + envName, + ), + ), +); + +function buildRootDescriptors({ dirname, options }, alias, descriptors) { + return descriptors(dirname, options, alias); +} + +function buildEnvDescriptors( + { dirname, options }, + alias, + descriptors, + envName, +) { + const opts = options.env && options.env[envName]; + return opts ? descriptors(dirname, opts, `${alias}.env["${envName}"]`) : null; +} + +function makeChainWalker< + ArgT, + InnerT: { options: ValidatedOptions, dirname: string }, +>({ + init, + root, + env, +}: { + init: ArgT => InnerT, + root: InnerT => OptionsAndDescriptors, + env: (InnerT, string) => OptionsAndDescriptors | null, +}): (ArgT, ConfigContext, Set | void) => ConfigChain | null { + return (arg, context, files = new Set()) => { + const input = init(arg); + + const { dirname } = input; + + const flattenedConfigs = []; + + const rootOpts = root(input); + flattenedConfigs.push(rootOpts); + + const envOpts = env(input, context.envName); + if (envOpts) { + flattenedConfigs.push(envOpts); + } + + // Process 'ignore' and 'only' before 'extends' items are processed so + // that we don't do extra work loading extended configs if a file is + // ignored. + if ( + flattenedConfigs.some(({ options: { ignore, only } }) => + shouldIgnore(context, ignore, only, dirname), + ) + ) { + return null; + } + + const chain = emptyChain(); + + for (const op of flattenedConfigs) { + if (!mergeExtendsChain(chain, op.options, dirname, context, files)) { + return null; + } + + mergeChainOpts(chain, op); + } + return chain; + }; +} + +function mergeExtendsChain( + chain: ConfigChain, + opts: ValidatedOptions, + dirname: string, + context: ConfigContext, + files: Set, +): boolean { + if (opts.extends === undefined) return true; + + const file = loadConfig(opts.extends, dirname, context.envName); + + if (files.has(file)) { + throw new Error( + `Configuration cycle detected loading ${file.filepath}.\n` + + `File already loaded following the config chain:\n` + + Array.from(files, file => ` - ${file.filepath}`).join("\n"), + ); + } + + files.add(file); + const fileChain = loadFileChain(file, context, files); + files.delete(file); + + if (!fileChain) return false; + + mergeChain(chain, fileChain); + + return true; +} + +function mergeChain(target: ConfigChain, source: ConfigChain): ConfigChain { + target.options.push(...source.options); + target.plugins.push(...source.plugins); + target.presets.push(...source.presets); + + return target; +} + +function mergeChainOpts( + target: ConfigChain, + { options, plugins, presets }: OptionsAndDescriptors, +): ConfigChain { + target.options.push(options); + target.plugins.push(...plugins()); + target.presets.push(...presets()); + + return target; +} + +function emptyChain(): ConfigChain { + return { + options: [], + presets: [], + plugins: [], + }; +} + +function normalizeOptions(opts: ValidatedOptions): ValidatedOptions { + const options = Object.assign({}, opts); + delete options.extends; + delete options.env; + delete options.plugins; + delete options.presets; + delete options.passPerPreset; + delete options.ignore; + delete options.only; + + // "sourceMap" is just aliased to sourceMap, so copy it over as + // we merge the options together. + if (options.sourceMap) { + options.sourceMaps = options.sourceMap; + delete options.sourceMap; + } + return options; +} + +function dedupDescriptors( + items: Array, +): Array { + const map: Map< + Function, + Map, + > = new Map(); + + const descriptors = []; + + for (const item of items) { + if (typeof item.value === "function") { + const fnKey = item.value; + let nameMap = map.get(fnKey); + if (!nameMap) { + nameMap = new Map(); + map.set(fnKey, nameMap); + } + let desc = nameMap.get(item.name); + if (!desc) { + desc = { value: null }; + descriptors.push(desc); + + // Treat passPerPreset presets as unique, skipping them + // in the merge processing steps. + if (!item.ownPass) nameMap.set(item.name, desc); + } + + if (item.options === false) { + desc.value = null; + } else { + desc.value = item; + } + } else { + descriptors.push({ value: item }); + } + } + + return descriptors.reduce((acc, desc) => { + if (desc.value) acc.push(desc.value); + return acc; + }, []); +} + +/** + * Tests if a filename should be ignored based on "ignore" and "only" options. + */ +function shouldIgnore( + context: ConfigContext, + ignore: ?IgnoreList, + only: ?IgnoreList, + dirname: string, +): boolean { + if (context.filename === null) return false; + // $FlowIgnore - Flow refinements aren't quite smart enough for this :( + const ctx: ConfigContextNamed = context; + + if (ignore) { + if (matchesPatterns(ctx, ignore, dirname)) { + debug( + "Ignored %o because it matched one of %O from %o", + context.filename, + ignore, + dirname, + ); + return true; + } + } + + if (only) { + if (!matchesPatterns(ctx, only, dirname)) { + debug( + "Ignored %o because it failed to match one of %O from %o", + context.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. + */ +function matchesPatterns( + context: ConfigContextNamed, + patterns: IgnoreList, + 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 res.push(pattern); + }); + + const filename = context.filename; + if (res.some(re => re.test(context.filename))) return true; + if (fns.some(fn => fn(filename))) return true; + + if (strings.length > 0) { + const possibleDirs = getPossibleDirs(context); + + 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; +} + +const getPossibleDirs = makeWeakCache((context: ConfigContextNamed) => { + let current = context.filename; + if (current === null) return []; + + const possibleDirs = [current]; + while (true) { + const previous = current; + current = path.dirname(current); + if (previous === current) break; + + possibleDirs.push(current); + } + + return possibleDirs; +}); diff --git a/packages/babel-core/src/config/config-descriptors.js b/packages/babel-core/src/config/config-descriptors.js new file mode 100644 index 0000000000..6b31c640d2 --- /dev/null +++ b/packages/babel-core/src/config/config-descriptors.js @@ -0,0 +1,272 @@ +// @flow + +import { loadPlugin, loadPreset } from "./files"; + +import { + makeWeakCache, + makeStrongCache, + type CacheConfigurator, +} from "./caching"; + +import type { + ValidatedOptions, + PluginList, + PluginItem, +} from "./validation/options"; + +// Represents a config object and functions to lazily load the descriptors +// for the plugins and presets so we don't load the plugins/presets unless +// the options object actually ends up being applicable. +export type OptionsAndDescriptors = { + options: ValidatedOptions, + plugins: () => Array, + presets: () => Array, +}; + +// Represents a plugin or presets at a given location in a config object. +// At this point these have been resolved to a specific object or function, +// but have not yet been executed to call functions with options. +export type UnloadedDescriptor = { + name: string | void, + value: {} | Function, + options: {} | void | false, + dirname: string, + alias: string, + ownPass?: boolean, +}; + +export type ValidatedFile = { + filepath: string, + dirname: string, + options: ValidatedOptions, +}; + +/** + * Create a set of descriptors from a given options object, preserving + * descriptor identity based on the identity of the plugin/preset arrays + * themselves. + */ +export function createCachedDescriptors( + dirname: string, + options: ValidatedOptions, + alias: string, +): OptionsAndDescriptors { + const { plugins, presets, passPerPreset } = options; + return { + options, + plugins: plugins + ? () => createCachedPluginDescriptors(plugins, dirname)(alias) + : () => [], + presets: presets + ? () => + createCachedPresetDescriptors(presets, dirname)(alias)( + !!passPerPreset, + ) + : () => [], + }; +} + +/** + * Create a set of descriptors from a given options object, with consistent + * identity for the descriptors, but not caching based on any specific identity. + */ +export function createUncachedDescriptors( + dirname: string, + options: ValidatedOptions, + alias: string, +): OptionsAndDescriptors { + // The returned result here is cached to represent a config object in + // memory, so we build and memoize the descriptors to ensure the same + // values are returned consistently. + let plugins; + let presets; + + return { + options, + plugins: () => { + if (!plugins) { + plugins = createPluginDescriptors( + options.plugins || [], + dirname, + alias, + ); + } + return plugins; + }, + presets: () => { + if (!presets) { + presets = createPresetDescriptors( + options.presets || [], + dirname, + alias, + !!options.passPerPreset, + ); + } + return presets; + }, + }; +} + +const createCachedPresetDescriptors = makeWeakCache( + (items: PluginList, cache: CacheConfigurator) => { + const dirname = cache.using(dir => dir); + return makeStrongCache((alias: string) => + makeStrongCache((passPerPreset: boolean) => + createPresetDescriptors(items, dirname, alias, passPerPreset), + ), + ); + }, +); + +const createCachedPluginDescriptors = makeWeakCache( + (items: PluginList, cache: CacheConfigurator) => { + const dirname = cache.using(dir => dir); + return makeStrongCache((alias: string) => + createPluginDescriptors(items, dirname, alias), + ); + }, +); + +function createPresetDescriptors( + items: PluginList, + dirname: string, + alias: string, + passPerPreset: boolean, +): Array { + return createDescriptors("preset", items, dirname, alias, passPerPreset); +} + +function createPluginDescriptors( + items: PluginList, + dirname: string, + alias: string, +): Array { + return createDescriptors("plugin", items, dirname, alias); +} + +function createDescriptors( + type: "plugin" | "preset", + items: PluginList, + dirname: string, + alias: string, + ownPass?: boolean, +): Array { + const descriptors = items.map((item, index) => + createDescriptor( + item, + type === "plugin" ? loadPlugin : loadPreset, + dirname, + { + index, + alias, + ownPass: !!ownPass, + }, + ), + ); + + assertNoDuplicates(descriptors); + + return descriptors; +} + +/** + * Given a plugin/preset item, resolve it into a standard format. + */ +function createDescriptor( + pair: PluginItem, + resolver, + dirname, + { + index, + alias, + ownPass, + }: { + index: number, + alias: string, + ownPass?: boolean, + }, +): UnloadedDescriptor { + let name; + let options; + let value = pair; + if (Array.isArray(value)) { + if (value.length === 3) { + // $FlowIgnore - Flow doesn't like the multiple tuple types. + [value, options, name] = value; + } else { + [value, options] = value; + } + } + + let filepath = null; + if (typeof value === "string") { + ({ filepath, value } = resolver(value, dirname)); + } + + if (!value) { + throw new Error(`Unexpected falsy value: ${String(value)}`); + } + + if (typeof value === "object" && value.__esModule) { + if (value.default) { + value = value.default; + } else { + throw new Error("Must export a default export when using ES6 modules."); + } + } + + if (typeof value !== "object" && typeof value !== "function") { + throw new Error( + `Unsupported format: ${typeof value}. Expected an object or a function.`, + ); + } + + if (filepath !== null && typeof value === "object" && value) { + // We allow object values for plugins/presets nested directly within a + // config object, because it can be useful to define them in nested + // configuration contexts. + throw new Error( + "Plugin/Preset files are not allowed to export objects, only functions.", + ); + } + + return { + name, + alias: filepath || `${alias}$${index}`, + value, + options, + dirname, + ownPass, + }; +} + +function assertNoDuplicates(items: Array): void { + const map = new Map(); + + for (const item of items) { + if (typeof item.value !== "function") continue; + + let nameMap = map.get(item.value); + if (!nameMap) { + nameMap = new Set(); + map.set(item.value, nameMap); + } + + if (nameMap.has(item.name)) { + throw new Error( + [ + `Duplicate plugin/preset detected.`, + `If you'd like to use two separate instances of a plugin,`, + `they neen separate names, e.g.`, + ``, + ` plugins: [`, + ` ['some-plugin', {}],`, + ` ['some-plugin', {}, 'some unique name'],`, + ` ]`, + ].join("\n"), + ); + } + + nameMap.add(item.name); + } +} diff --git a/packages/babel-core/src/config/index.js b/packages/babel-core/src/config/index.js index 0bdd1184bb..33ab644a8b 100644 --- a/packages/babel-core/src/config/index.js +++ b/packages/babel-core/src/config/index.js @@ -1,7 +1,29 @@ // @flow -import type Plugin from "./plugin"; -import manageOptions from "./option-manager"; +import path from "path"; +import * as context from "../index"; +import Plugin from "./plugin"; +import merge from "lodash/merge"; +import { + buildRootChain, + buildPresetChain, + type ConfigChain, + type PresetInstance, +} from "./config-chain"; +import type { UnloadedDescriptor } from "./config-descriptors"; +import traverse from "@babel/traverse"; +import clone from "lodash/clone"; +import { makeWeakCache, type CacheConfigurator } from "./caching"; +import { getEnv } from "./helpers/environment"; +import { validate } from "./validation/options"; +import { validatePluginObject } from "./validation/plugins"; + +type LoadedDescriptor = { + value: {}, + options: {}, + dirname: string, + alias: string, +}; export type { InputOptions } from "./validation/options"; @@ -14,13 +36,257 @@ export type { Plugin }; export type PluginPassList = Array; export type PluginPasses = Array; -/** - * Standard API for loading Babel configuration data. Not for public consumption. - */ -export default function loadConfig(opts: mixed): ResolvedConfig | null { - if (opts != null && (typeof opts !== "object" || Array.isArray(opts))) { +export default function loadConfig(inputOpts: mixed): ResolvedConfig | null { + if ( + inputOpts != null && + (typeof inputOpts !== "object" || Array.isArray(inputOpts)) + ) { throw new Error("Babel options must be an object, null, or undefined"); } - return manageOptions(opts || {}); + const args = inputOpts ? validate("arguments", inputOpts) : {}; + + const { envName = getEnv(), cwd = "." } = args; + const absoluteCwd = path.resolve(cwd); + + const configChain = buildRootChain(absoluteCwd, args, envName); + if (!configChain) return null; + + const optionDefaults = {}; + const options = {}; + const passes = [[]]; + try { + (function recurseDescriptors( + config: { + plugins: Array, + presets: Array, + }, + pass: Array, + envName: string, + ) { + const plugins = config.plugins.map(descriptor => + loadPluginDescriptor(descriptor, envName), + ); + const presets = config.presets.map(descriptor => { + return { + preset: loadPresetDescriptor(descriptor, envName), + pass: descriptor.ownPass ? [] : pass, + }; + }); + + // resolve presets + if (presets.length > 0) { + // The passes are created in the same order as the preset list, but are inserted before any + // existing additional passes. + passes.splice( + 1, + 0, + ...presets.map(o => o.pass).filter(p => p !== pass), + ); + + for (const { preset, pass } of presets) { + recurseDescriptors( + { + plugins: preset.plugins, + presets: preset.presets, + }, + pass, + envName, + ); + + preset.options.forEach(opts => { + merge(optionDefaults, opts); + }); + } + } + + // resolve plugins + if (plugins.length > 0) { + pass.unshift(...plugins); + } + })( + { + plugins: configChain.plugins, + presets: configChain.presets, + }, + passes[0], + envName, + ); + + configChain.options.forEach(opts => { + merge(options, opts); + }); + } catch (e) { + // There are a few case where thrown errors will try to annotate themselves multiple times, so + // to keep things simple we just bail out if re-wrapping the message. + if (!/^\[BABEL\]/.test(e.message)) { + e.message = `[BABEL] ${args.filename || "unknown"}: ${e.message}`; + } + + throw e; + } + + const opts: Object = merge(optionDefaults, options); + + // Tack the passes onto the object itself so that, if this object is passed back to Babel a second time, + // it will be in the right structure to not change behavior. + opts.babelrc = false; + opts.plugins = passes[0]; + opts.presets = passes + .slice(1) + .filter(plugins => plugins.length > 0) + .map(plugins => ({ plugins })); + opts.passPerPreset = opts.presets.length > 0; + opts.envName = envName; + opts.cwd = absoluteCwd; + + return { + options: opts, + passes: passes, + }; +} + +/** + * Load a generic plugin/preset from the given descriptor loaded from the config object. + */ +const loadDescriptor = makeWeakCache( + ( + { value, options, dirname, alias }: UnloadedDescriptor, + cache: CacheConfigurator<{ envName: string }>, + ): LoadedDescriptor => { + // Disabled presets should already have been filtered out + if (options === false) throw new Error("Assertion failure"); + + options = options || {}; + + let item = value; + if (typeof value === "function") { + const api = Object.assign(Object.create(context), { + cache: cache.simple(), + env: () => cache.using(data => data.envName), + async: () => false, + }); + + try { + item = value(api, options, dirname); + } catch (e) { + if (alias) { + e.message += ` (While processing: ${JSON.stringify(alias)})`; + } + throw e; + } + } + + if (!item || typeof item !== "object") { + throw new Error("Plugin/Preset did not return an object."); + } + + if (typeof item.then === "function") { + throw new Error( + `You appear to be using an async plugin, ` + + `which your current version of Babel does not support.` + + `If you're using a published plugin, ` + + `you may need to upgrade your @babel/core version.`, + ); + } + + return { value: item, options, dirname, alias }; + }, +); + +/** + * Instantiate a plugin for the given descriptor, returning the plugin/options pair. + */ +function loadPluginDescriptor( + descriptor: UnloadedDescriptor, + envName: string, +): Plugin { + if (descriptor.value instanceof Plugin) { + if (descriptor.options) { + throw new Error( + "Passed options to an existing Plugin instance will not work.", + ); + } + + return descriptor.value; + } + + return instantiatePlugin(loadDescriptor(descriptor, { envName }), { + envName, + }); +} + +const instantiatePlugin = makeWeakCache( + ( + { value, options, dirname, alias }: LoadedDescriptor, + cache: CacheConfigurator<{ envName: string }>, + ): Plugin => { + const pluginObj = validatePluginObject(value); + + const plugin = Object.assign({}, pluginObj); + if (plugin.visitor) { + plugin.visitor = traverse.explode(clone(plugin.visitor)); + } + + if (plugin.inherits) { + const inheritsDescriptor = { + name: undefined, + alias: `${alias}$inherits`, + value: plugin.inherits, + options, + dirname, + }; + + // If the inherited plugin changes, reinstantiate this plugin. + const inherits = cache.invalidate(data => + loadPluginDescriptor(inheritsDescriptor, data.envName), + ); + + plugin.pre = chain(inherits.pre, plugin.pre); + plugin.post = chain(inherits.post, plugin.post); + plugin.manipulateOptions = chain( + inherits.manipulateOptions, + plugin.manipulateOptions, + ); + plugin.visitor = traverse.visitors.merge([ + inherits.visitor || {}, + plugin.visitor || {}, + ]); + } + + return new Plugin(plugin, options, alias); + }, +); + +/** + * Generate a config object that will act as the root of a new nested config. + */ +const loadPresetDescriptor = ( + descriptor: UnloadedDescriptor, + envName: string, +): ConfigChain => { + return buildPresetChain( + instantiatePreset(loadDescriptor(descriptor, { envName })), + ); +}; + +const instantiatePreset = makeWeakCache( + ({ value, dirname, alias }: LoadedDescriptor): PresetInstance => { + return { + options: validate("preset", value), + alias, + dirname, + }; + }, +); + +function chain(a, b) { + const fns = [a, b].filter(Boolean); + if (fns.length <= 1) return fns[0]; + + return function(...args) { + for (const fn of fns) { + fn.apply(this, args); + } + }; } diff --git a/packages/babel-core/src/config/option-manager.js b/packages/babel-core/src/config/option-manager.js deleted file mode 100644 index 37e54aa879..0000000000 --- a/packages/babel-core/src/config/option-manager.js +++ /dev/null @@ -1,278 +0,0 @@ -// @flow - -import path from "path"; -import * as context from "../index"; -import Plugin from "./plugin"; -import merge from "lodash/merge"; -import { - buildRootChain, - buildPresetChain, - type ConfigChain, - type BasicDescriptor, - type PresetInstance, -} from "./build-config-chain"; -import traverse from "@babel/traverse"; -import clone from "lodash/clone"; -import { makeWeakCache, type CacheConfigurator } from "./caching"; -import { getEnv } from "./helpers/environment"; -import { validate } from "./validation/options"; -import { validatePluginObject } from "./validation/plugins"; - -export default function manageOptions(inputOpts: {}): { - options: Object, - passes: Array>, -} | null { - const args = validate("arguments", inputOpts); - - const { envName = getEnv(), cwd = "." } = args; - const absoluteCwd = path.resolve(cwd); - - const configChain = buildRootChain(absoluteCwd, args, envName); - if (!configChain) return null; - - const optionDefaults = {}; - const options = {}; - const passes = [[]]; - try { - (function recurseDescriptors( - config: { - plugins: Array, - presets: Array, - }, - pass: Array, - envName: string, - ) { - const plugins = config.plugins.map(descriptor => - loadPluginDescriptor(descriptor, envName), - ); - const presets = config.presets.map(descriptor => { - return { - preset: loadPresetDescriptor(descriptor, envName), - pass: descriptor.ownPass ? [] : pass, - }; - }); - - // resolve presets - if (presets.length > 0) { - // The passes are created in the same order as the preset list, but are inserted before any - // existing additional passes. - passes.splice( - 1, - 0, - ...presets.map(o => o.pass).filter(p => p !== pass), - ); - - for (const { preset, pass } of presets) { - recurseDescriptors( - { - plugins: preset.plugins, - presets: preset.presets, - }, - pass, - envName, - ); - - preset.options.forEach(opts => { - merge(optionDefaults, opts); - }); - } - } - - // resolve plugins - if (plugins.length > 0) { - pass.unshift(...plugins); - } - })( - { - plugins: configChain.plugins, - presets: configChain.presets, - }, - passes[0], - envName, - ); - - configChain.options.forEach(opts => { - merge(options, opts); - }); - } catch (e) { - // There are a few case where thrown errors will try to annotate themselves multiple times, so - // to keep things simple we just bail out if re-wrapping the message. - if (!/^\[BABEL\]/.test(e.message)) { - e.message = `[BABEL] ${args.filename || "unknown"}: ${e.message}`; - } - - throw e; - } - - const opts: Object = merge(optionDefaults, options); - - // Tack the passes onto the object itself so that, if this object is passed back to Babel a second time, - // it will be in the right structure to not change behavior. - opts.babelrc = false; - opts.plugins = passes[0]; - opts.presets = passes - .slice(1) - .filter(plugins => plugins.length > 0) - .map(plugins => ({ plugins })); - opts.passPerPreset = opts.presets.length > 0; - opts.envName = envName; - opts.cwd = absoluteCwd; - - return { - options: opts, - passes: passes, - }; -} - -type LoadedDescriptor = { - value: {}, - options: {}, - dirname: string, - alias: string, -}; - -/** - * Load a generic plugin/preset from the given descriptor loaded from the config object. - */ -const loadDescriptor = makeWeakCache( - ( - { value, options, dirname, alias }: BasicDescriptor, - cache: CacheConfigurator<{ envName: string }>, - ): LoadedDescriptor => { - // Disabled presets should already have been filtered out - if (options === false) throw new Error("Assertion failure"); - - options = options || {}; - - let item = value; - if (typeof value === "function") { - const api = Object.assign(Object.create(context), { - cache: cache.simple(), - env: () => cache.using(data => data.envName), - async: () => false, - }); - - try { - item = value(api, options, dirname); - } catch (e) { - if (alias) { - e.message += ` (While processing: ${JSON.stringify(alias)})`; - } - throw e; - } - } - - if (!item || typeof item !== "object") { - throw new Error("Plugin/Preset did not return an object."); - } - - if (typeof item.then === "function") { - throw new Error( - `You appear to be using an async plugin, ` + - `which your current version of Babel does not support.` + - `If you're using a published plugin, ` + - `you may need to upgrade your @babel/core version.`, - ); - } - - return { value: item, options, dirname, alias }; - }, -); - -/** - * Instantiate a plugin for the given descriptor, returning the plugin/options pair. - */ -function loadPluginDescriptor( - descriptor: BasicDescriptor, - envName: string, -): Plugin { - if (descriptor.value instanceof Plugin) { - if (descriptor.options) { - throw new Error( - "Passed options to an existing Plugin instance will not work.", - ); - } - - return descriptor.value; - } - - return instantiatePlugin(loadDescriptor(descriptor, { envName }), { - envName, - }); -} - -const instantiatePlugin = makeWeakCache( - ( - { value, options, dirname, alias }: LoadedDescriptor, - cache: CacheConfigurator<{ envName: string }>, - ): Plugin => { - const pluginObj = validatePluginObject(value); - - const plugin = Object.assign({}, pluginObj); - if (plugin.visitor) { - plugin.visitor = traverse.explode(clone(plugin.visitor)); - } - - if (plugin.inherits) { - const inheritsDescriptor = { - name: undefined, - alias: `${alias}$inherits`, - value: plugin.inherits, - options, - dirname, - }; - - // If the inherited plugin changes, reinstantiate this plugin. - const inherits = cache.invalidate(data => - loadPluginDescriptor(inheritsDescriptor, data.envName), - ); - - plugin.pre = chain(inherits.pre, plugin.pre); - plugin.post = chain(inherits.post, plugin.post); - plugin.manipulateOptions = chain( - inherits.manipulateOptions, - plugin.manipulateOptions, - ); - plugin.visitor = traverse.visitors.merge([ - inherits.visitor || {}, - plugin.visitor || {}, - ]); - } - - return new Plugin(plugin, options, alias); - }, -); - -/** - * Generate a config object that will act as the root of a new nested config. - */ -const loadPresetDescriptor = ( - descriptor: BasicDescriptor, - envName: string, -): ConfigChain => { - return buildPresetChain( - instantiatePreset(loadDescriptor(descriptor, { envName })), - ); -}; - -const instantiatePreset = makeWeakCache( - ({ value, dirname, alias }: LoadedDescriptor): PresetInstance => { - return { - type: "preset", - options: validate("preset", value), - alias, - dirname, - }; - }, -); - -function chain(a, b) { - const fns = [a, b].filter(Boolean); - if (fns.length <= 1) return fns[0]; - - return function(...args) { - for (const fn of fns) { - fn.apply(this, args); - } - }; -} diff --git a/packages/babel-core/test/config-chain.js b/packages/babel-core/test/config-chain.js index f36ea98e4f..651639ac36 100644 --- a/packages/babel-core/test/config-chain.js +++ b/packages/babel-core/test/config-chain.js @@ -194,17 +194,50 @@ describe("buildConfigChain", function() { assert.notEqual(opts1.plugins[0], opts2.plugins[1]); }); - it("should cache the env options by identity", () => { - const env = { - foo: { - plugins: plugins1, + it("should cache the env plugins by identity", () => { + const plugins = [() => ({})]; + + const opts1 = loadOptions({ + envName: "foo", + env: { + foo: { + plugins, + }, }, - }; + }); + const opts2 = loadOptions({ + envName: "foo", + env: { + foo: { + plugins, + }, + }, + }); - const opts1 = loadOptions({ envName: "foo", env }); + assert.equal(opts1.plugins.length, 1); + assert.equal(opts2.plugins.length, 1); + assert.equal(opts1.plugins[0], opts2.plugins[0]); + }); - env.foo.plugins = plugins2; - const opts2 = loadOptions({ envName: "foo", env }); + it("should cache the env presets by identity", () => { + const presets = [() => ({ plugins: [() => ({})] })]; + + const opts1 = loadOptions({ + envName: "foo", + env: { + foo: { + presets, + }, + }, + }); + const opts2 = loadOptions({ + envName: "foo", + env: { + foo: { + presets, + }, + }, + }); assert.equal(opts1.plugins.length, 1); assert.equal(opts2.plugins.length, 1);