diff --git a/packages/babel-core/src/config/option-manager.js b/packages/babel-core/src/config/option-manager.js index b026725926..d8010f7d56 100644 --- a/packages/babel-core/src/config/option-manager.js +++ b/packages/babel-core/src/config/option-manager.js @@ -30,13 +30,9 @@ export default function manageOptions(opts: {}): { } class OptionManager { - constructor() { - this.options = {}; - this.passes = [[]]; - } - - options: ValidatedOptions; - passes: Array>; + optionDefaults: ValidatedOptions = {}; + options: ValidatedOptions = {}; + passes: Array> = [[]]; /** * This is called when we want to merge the input `opts` into the @@ -46,16 +42,23 @@ class OptionManager { * - `loc` is used to point to the original config. * - `dirname` is used to resolve plugins relative to it. */ - mergeOptions(config: MergeOptions, pass: Array, envName: string) { - const result = loadConfig(config); - - const plugins = result.plugins.map(descriptor => + mergeOptions( + config: { + plugins: Array, + presets: Array, + }, + pass: Array, + envName: string, + ) { + const plugins = config.plugins.map(descriptor => loadPluginDescriptor(descriptor, envName), ); - const presets = result.presets.map(descriptor => ({ - pass: descriptor.ownPass ? [] : pass, - preset: loadPresetDescriptor(descriptor, envName), - })); + const presets = config.presets.map(descriptor => { + return { + preset: loadPresetDescriptor(descriptor, envName), + pass: descriptor.ownPass ? [] : pass, + }; + }); // resolve presets if (presets.length > 0) { @@ -68,7 +71,18 @@ class OptionManager { ); presets.forEach(({ preset, pass }) => { - this.mergeOptions(preset, pass, envName); + const loadedConfig = loadConfig(preset); + this.mergeOptions( + { + // Call dedupDescriptors() to remove 'false' descriptors. + plugins: dedupDescriptors(loadedConfig.plugins), + presets: dedupDescriptors(loadedConfig.presets), + }, + pass, + envName, + ); + + merge(this.optionDefaults, normalizeOptions(loadedConfig.options)); }); } @@ -76,24 +90,23 @@ class OptionManager { if (plugins.length > 0) { pass.unshift(...plugins); } + } - const options = Object.assign({}, result.options); - delete options.extends; - delete options.env; - delete options.plugins; - delete options.presets; - delete options.passPerPreset; - delete options.ignore; - delete options.only; + mergeConfigChain(chain: $ReadOnlyArray, envName: string) { + const config = dedupLoadedConfigs(chain.map(config => loadConfig(config))); - // "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; - } + this.mergeOptions( + { + plugins: config.plugins, + presets: config.presets, + }, + this.passes[0], + envName, + ); - merge(this.options, options); + config.options.forEach(opts => { + merge(this.options, normalizeOptions(opts)); + }); } init(inputOpts: {}) { @@ -106,9 +119,7 @@ class OptionManager { if (!configChain) return null; try { - for (const config of configChain) { - this.mergeOptions(config, this.passes[0], envName); - } + this.mergeConfigChain(configChain, envName); } 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. @@ -119,7 +130,7 @@ class OptionManager { throw e; } - const opts: Object = this.options; + const opts: Object = merge(this.optionDefaults, this.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. @@ -140,10 +151,29 @@ class OptionManager { } } +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; +} + type BasicDescriptor = { name: string | void, value: {} | Function, - options: {} | void, + options: {} | void | false, dirname: string, alias: string, ownPass?: boolean, @@ -156,14 +186,16 @@ type LoadedDescriptor = { alias: string, }; +type LoadedConfig = { + options: ValidatedOptions, + plugins: Array, + presets: Array, +}; + /** * Load and validate the given config into a set of options, plugins, and presets. */ -const loadConfig = makeWeakCache((config: MergeOptions): { - options: {}, - plugins: Array, - presets: Array, -} => { +const loadConfig = makeWeakCache((config: MergeOptions): LoadedConfig => { const options = config.options; const plugins = (config.options.plugins || []).map((plugin, index) => @@ -173,25 +205,133 @@ const loadConfig = makeWeakCache((config: MergeOptions): { }), ); + assertNoDuplicates(plugins); + const presets = (config.options.presets || []).map((preset, index) => createDescriptor(preset, loadPreset, config.dirname, { index, alias: config.alias, - ownPass: config.options.passPerPreset, + ownPass: options.passPerPreset, }), ); + assertNoDuplicates(presets); + return { 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, +): { + plugins: Array, + presets: Array, + options: Array, +} { + 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; + }, []); +} + /** * Load a generic plugin/preset from the given descriptor loaded from the config object. */ const loadDescriptor = makeWeakCache( ( - { value, options = {}, dirname, alias }: BasicDescriptor, + { 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), { diff --git a/packages/babel-core/src/config/options.js b/packages/babel-core/src/config/options.js index 3e5b36688a..ff5ba9602f 100644 --- a/packages/babel-core/src/config/options.js +++ b/packages/babel-core/src/config/options.js @@ -187,7 +187,7 @@ export type EnvSet = { export type IgnoreItem = string | Function | RegExp; export type IgnoreList = $ReadOnlyArray; -export type PluginOptions = {} | void; +export type PluginOptions = {} | void | false; export type PluginTarget = string | {} | Function; export type PluginItem = | Plugin diff --git a/packages/babel-core/test/api.js b/packages/babel-core/test/api.js index dfbe5a8504..c37c2bcf28 100644 --- a/packages/babel-core/test/api.js +++ b/packages/babel-core/test/api.js @@ -310,30 +310,30 @@ describe("api", function() { assert.equal( result.code, [ - "argtwo;", - "argone;", - "eleven;", - "twelve;", + "thirteen;", + "fourteen;", + "seventeen;", + "eighteen;", "one;", "two;", + "eleven;", + "twelve;", + "argtwo;", + "argone;", "five;", "six;", "three;", "four;", - "seventeen;", - "eighteen;", "nineteen;", "twenty;", - "thirteen;", - "fourteen;", "fifteen;", "sixteen;", - "argthree;", - "argfour;", "seven;", "eight;", "nine;", "ten;", + "argthree;", + "argfour;", ].join("\n"), ); }); diff --git a/packages/babel-core/test/config-loading.js b/packages/babel-core/test/config-loading.js index 2f09e6dbc5..130cdb386a 100644 --- a/packages/babel-core/test/config-loading.js +++ b/packages/babel-core/test/config-loading.js @@ -42,10 +42,10 @@ describe("@babel/core config loading", () => { const options1 = loadConfig(opts).options; expect(options1.plugins.map(p => p.key)).to.eql([ - "plugin6", - "plugin5", "plugin1", "plugin2", + "plugin6", + "plugin5", "plugin4", "plugin3", ]); @@ -86,7 +86,7 @@ describe("@babel/core config loading", () => { expect(options2.plugins.length).to.equal(options1.plugins.length); for (let i = 0; i < options1.plugins.length; i++) { - if (i === 2) { + if (i === 0) { expect(options2.plugins[i]).not.to.equal(options1.plugins[i]); } else { expect(options2.plugins[i]).to.equal(options1.plugins[i]); @@ -99,7 +99,7 @@ describe("@babel/core config loading", () => { expect(options3.plugins.length).to.equal(options1.plugins.length); for (let i = 0; i < options1.plugins.length; i++) { - if (i === 2 || i === 5) { + if (i === 0 || i === 5) { expect(options3.plugins[i]).not.to.equal(options1.plugins[i]); } else { expect(options3.plugins[i]).to.equal(options1.plugins[i]); @@ -150,7 +150,7 @@ describe("@babel/core config loading", () => { expect(options2.plugins.length).to.equal(options1.plugins.length); for (let i = 0; i < options1.plugins.length; i++) { - if (i === 2 || i === 3 || i === 4 || i === 5 || i === 6) { + if (i === 0 || i === 1 || i === 4 || i === 5 || i === 6) { expect(options2.plugins[i]).not.to.equal(options1.plugins[i]); } else { expect(options2.plugins[i]).to.equal(options1.plugins[i]); @@ -185,7 +185,7 @@ describe("@babel/core config loading", () => { expect(options2.plugins.length).to.equal(options1.plugins.length); for (let i = 0; i < options2.plugins.length; i++) { - if (i === 0) { + if (i === 2) { expect(options2.plugins[i]).not.to.equal(options1.plugins[i]); } else { expect(options2.plugins[i]).to.equal(options1.plugins[i]); @@ -205,7 +205,7 @@ describe("@babel/core config loading", () => { expect(options2.plugins.length).to.equal(options1.plugins.length); for (let i = 0; i < options2.plugins.length; i++) { - if (i === 1) { + if (i === 3) { expect(options2.plugins[i]).not.to.equal(options1.plugins[i]); } else { expect(options2.plugins[i]).to.equal(options1.plugins[i]); @@ -224,7 +224,7 @@ describe("@babel/core config loading", () => { expect(options2.plugins.length).to.equal(options1.plugins.length); for (let i = 0; i < options1.plugins.length; i++) { - if (i === 0) { + if (i === 2) { expect(options2.plugins[i]).not.to.equal(options1.plugins[i]); } else { expect(options2.plugins[i]).to.equal(options1.plugins[i]); @@ -243,7 +243,7 @@ describe("@babel/core config loading", () => { expect(options2.plugins.length).to.equal(options1.plugins.length); for (let i = 0; i < options1.plugins.length; i++) { - if (i === 1) { + if (i === 3) { expect(options2.plugins[i]).not.to.equal(options1.plugins[i]); } else { expect(options2.plugins[i]).to.equal(options1.plugins[i]); diff --git a/packages/babel-core/test/option-manager.js b/packages/babel-core/test/option-manager.js index 210c70dc84..6c862cb536 100644 --- a/packages/babel-core/test/option-manager.js +++ b/packages/babel-core/test/option-manager.js @@ -11,6 +11,113 @@ describe("option-manager", () => { }, /Babel 5 plugin is being run with an unsupported Babel/); }); + describe("config plugin/preset flattening and overriding", () => { + function makePlugin() { + const calls = []; + const plugin = (api, opts) => { + calls.push(opts); + return {}; + }; + return { plugin, calls }; + } + + it("should throw if a plugin is repeated", () => { + const { calls, plugin } = makePlugin(); + + assert.throws(() => { + manageOptions({ + plugins: [plugin, plugin], + }); + }, /Duplicate plugin\/preset detected/); + assert.deepEqual(calls, []); + }); + + it("should not throw if a repeated plugin has a different name", () => { + const { calls: calls1, plugin: plugin1 } = makePlugin(); + const { calls: calls2, plugin: plugin2 } = makePlugin(); + + manageOptions({ + plugins: [[plugin1, { arg: 1 }], [plugin2, { arg: 2 }, "some-name"]], + }); + assert.deepEqual(calls1, [{ arg: 1 }]); + assert.deepEqual(calls2, [{ arg: 2 }]); + }); + + it("should merge .env[] plugins with parent presets", () => { + const { calls: calls1, plugin: plugin1 } = makePlugin(); + const { calls: calls2, plugin: plugin2 } = makePlugin(); + + manageOptions({ + envName: "test", + plugins: [[plugin1, { arg: 1 }]], + env: { + test: { + plugins: [[plugin1, { arg: 3 }], [plugin2, { arg: 2 }]], + }, + }, + }); + assert.deepEqual(calls1, [{ arg: 3 }]); + assert.deepEqual(calls2, [{ arg: 2 }]); + }); + + it("should throw if a preset is repeated", () => { + const { calls, plugin: preset } = makePlugin(); + + assert.throws(() => { + manageOptions({ + presets: [preset, preset], + }); + }, /Duplicate plugin\/preset detected/); + assert.deepEqual(calls, []); + }); + + it("should not throw if a repeated preset has a different name", () => { + const { calls: calls1, plugin: preset1 } = makePlugin(); + const { calls: calls2, plugin: preset2 } = makePlugin(); + + manageOptions({ + presets: [[preset1, { arg: 1 }], [preset2, { arg: 2 }, "some-name"]], + }); + assert.deepEqual(calls1, [{ arg: 1 }]); + assert.deepEqual(calls2, [{ arg: 2 }]); + }); + + it("should merge .env[] presets with parent presets", () => { + const { calls: calls1, plugin: preset1 } = makePlugin(); + const { calls: calls2, plugin: preset2 } = makePlugin(); + + manageOptions({ + envName: "test", + presets: [[preset1, { arg: 1 }]], + env: { + test: { + presets: [[preset1, { arg: 3 }], [preset2, { arg: 2 }]], + }, + }, + }); + assert.deepEqual(calls1, [{ arg: 3 }]); + assert.deepEqual(calls2, [{ arg: 2 }]); + }); + + it("should not merge .env[] presets with parent presets when passPerPreset", () => { + const { calls: calls1, plugin: preset1 } = makePlugin(); + const { calls: calls2, plugin: preset2 } = makePlugin(); + + manageOptions({ + envName: "test", + passPerPreset: true, + presets: [[preset1, { arg: 1 }]], + env: { + test: { + presets: [[preset1, { arg: 3 }], [preset2, { arg: 2 }]], + }, + }, + }); + assert.deepEqual(calls1, [{ arg: 1 }, { arg: 3 }]); + assert.deepEqual(calls2, [{ arg: 2 }]); + }); + }); + describe("mergeOptions", () => { it("throws for removed babel 5 options", () => { return assert.throws(() => {