Typecheck much more of the config loading process (#5642)

* Add type declarations for micromatch.

* Enable Flowtype on all config loading.

* Remove unneeded comments.
This commit is contained in:
Logan Smyth 2017-04-18 09:28:18 -07:00 committed by GitHub
parent c46ef658b5
commit 6af8e64711
13 changed files with 199 additions and 101 deletions

View File

@ -9,6 +9,7 @@ packages/*/src
lib/file.js
lib/parser.js
lib/types.js
lib/third-party-libs.js.flow
[options]
strip_root=true

View File

@ -0,0 +1,9 @@
/**
* Basic declarations for the npm modules we use.
*/
declare module "micromatch" {
declare function exports(Array<string>, Array<string>, ?{
nocase: boolean,
}): Array<string>;
}

View File

@ -1,10 +1,24 @@
import * as babel from "../index";
// @flow
import { getEnv } from "./helpers/environment";
import path from "path";
import micromatch from "micromatch";
import { findConfigs, loadConfig } from "./loading/files";
export default function buildConfigChain(opts: Object = {}) {
type ConfigItem = {
type: "options"|"arguments",
options: {},
dirname: string,
alias: string,
loc: 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);
@ -17,7 +31,7 @@ export default function buildConfigChain(opts: Object = {}) {
});
// resolve all .babelrc files
if (opts.babelrc !== false) {
if (opts.babelrc !== false && filename) {
builder.findConfigs(filename);
}
} catch (e) {
@ -30,6 +44,10 @@ export default function buildConfigChain(opts: Object = {}) {
}
class ConfigChainBuilder {
filename: string|null;
configs: Array<ConfigItem>;
possibleDirs: null|Array<string>;
constructor(filename) {
this.configs = [];
this.filename = filename;
@ -40,59 +58,68 @@ class ConfigChainBuilder {
* Tests if a filename should be ignored based on "ignore" and "only" options.
*/
shouldIgnore(
ignore: Array<string | RegExp | Function>,
only?: Array<string | RegExp | Function>,
ignore: mixed,
only: mixed,
dirname: string,
): boolean {
if (!this.filename) return false;
if (ignore && !Array.isArray(ignore)) {
throw new Error(`.ignore should be an array, ${JSON.stringify(ignore)} given`);
if (ignore) {
if (!Array.isArray(ignore)) {
throw new Error(`.ignore should be an array, ${JSON.stringify(ignore)} given`);
}
if (this.matchesPatterns(ignore, dirname)) return true;
}
if (only && !Array.isArray(only)) {
throw new Error(`.only should be an array, ${JSON.stringify(only)} given`);
if (only) {
if (!Array.isArray(only)) {
throw new Error(`.only should be an array, ${JSON.stringify(only)} given`);
}
if (!this.matchesPatterns(only, dirname)) return true;
}
return (ignore && this.matchesPatterns(ignore, dirname)) ||
(only && !this.matchesPatterns(only, dirname));
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<string | Function | RegExp>, dirname: string) {
matchesPatterns(patterns: Array<mixed>, 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) => {
const type = typeof pattern;
if (type === "string") strings.push(pattern);
else if (type === "function") fns.push(pattern);
else res.push(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(this.filename))) return true;
if (fns.some((fn) => fn(this.filename))) return true;
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 (!this.possibleDirs) {
this.possibleDirs = [];
if (!possibleDirs) {
possibleDirs = this.possibleDirs = [];
if (this.filename) {
this.possibleDirs.push(this.filename);
possibleDirs.push(filename);
let current = this.filename;
while (true) {
const previous = current;
current = path.dirname(current);
if (previous === current) break;
let current = filename;
while (true) {
const previous = current;
current = path.dirname(current);
if (previous === current) break;
this.possibleDirs.push(current);
}
possibleDirs.push(current);
}
}
@ -104,7 +131,7 @@ class ConfigChainBuilder {
return (negate ? "!" : "") + path.resolve(dirname, pattern);
});
if (micromatch(this.possibleDirs, absolutePatterns, { nocase: true }).length > 0) {
if (micromatch(possibleDirs, absolutePatterns, { nocase: true }).length > 0) {
return true;
}
}
@ -113,12 +140,6 @@ class ConfigChainBuilder {
}
findConfigs(loc: string) {
if (!loc) return;
if (!path.isAbsolute(loc)) {
loc = path.join(process.cwd(), loc);
}
findConfigs(path.dirname(loc)).forEach(({ filepath, dirname, options }) => {
this.mergeConfig({
type: "options",
@ -131,31 +152,33 @@ class ConfigChainBuilder {
mergeConfig({
type,
options,
options: rawOpts,
alias,
loc,
dirname,
}) {
if (!options) {
return false;
}
// 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(options.ignore, options.only, dirname)) {
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."), { code: "BABEL_IGNORED_FILE" });
throw Object.assign((new Error("This file has been ignored."): any), { code: "BABEL_IGNORED_FILE" });
}
options = Object.assign({}, options);
const options = Object.assign({}, rawOpts);
delete options.env;
delete options.extends;
loc = loc || alias;
const envKey = getEnv();
// env
const envKey = babel.getEnv();
if (options.env) {
const envOpts = options.env[envKey];
delete options.env;
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,
@ -168,13 +191,14 @@ class ConfigChainBuilder {
type,
options,
alias,
loc,
loc: alias,
dirname,
});
// add extends clause
if (options.extends) {
const extendsConfig = loadConfig(options.extends, 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;
@ -187,7 +211,6 @@ class ConfigChainBuilder {
dirname: extendsConfig.dirname,
});
}
delete options.extends;
}
}
}

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: Object, ResultT>(
export function makeWeakCache<ArgT: {}, ResultT>(
handler: (ArgT, CacheConfigurator) => ResultT,
autoPermacache?: boolean,
): (ArgT) => ResultT {

View File

@ -1,5 +1,6 @@
export function getEnv(defaultValue = "development") {
// @flow
export function getEnv(defaultValue: string = "development"): string {
return process.env.BABEL_ENV
|| process.env.NODE_ENV
|| defaultValue;

View File

@ -1,9 +1,11 @@
// @flow
import mergeWith from "lodash/mergeWith";
export default function (dest?: Object, src?: Object): ?Object {
export default function<T: {}>(dest?: T, src?: T) {
if (!dest || !src) return;
return mergeWith(dest, src, function (a, b) {
mergeWith(dest, src, function (a, b) {
if (b && Array.isArray(a)) {
const newArray = b.slice(0);

View File

@ -1,14 +1,20 @@
// @flow
import type Plugin from "./plugin";
import manageOptions from "./option-manager";
export type ResolvedConfig = {
options: Object,
passes: Array<Array<Plugin>>,
passes: Array<Array<[ Plugin, ?{} ]>>,
};
/**
* Standard API for loading Babel configuration data. Not for public consumption.
*/
export default function loadConfig(opts: Object): ResolvedConfig|null {
return manageOptions(opts);
export default function loadConfig(opts: mixed): ResolvedConfig|null {
if (opts != null && typeof opts !== "object") {
throw new Error("Babel options must be an object, null, or undefined");
}
return manageOptions(opts || {});
}

View File

@ -10,7 +10,7 @@ import { makeStrongCache } from "../../caching";
type ConfigFile = {
filepath: string,
dirname: string,
options: Object,
options: {},
};
const BABELRC_FILENAME = ".babelrc";

View File

@ -3,7 +3,7 @@
type ConfigFile = {
filepath: string,
dirname: string,
options: Object,
options: {},
};
// eslint-disable-next-line no-unused-vars

View File

@ -1,3 +1,5 @@
// @flow
import * as context from "../index";
import Plugin from "./plugin";
import * as messages from "babel-messages";
@ -12,11 +14,11 @@ import clone from "lodash/clone";
import { loadPlugin, loadPreset, loadParser, loadGenerator } from "./loading/files";
type MergeOptions = {
type: "arguments"|"options"|"preset",
options?: Object,
+type: "arguments"|"options"|"preset",
options: {},
alias: string,
loc?: string,
dirname?: string
loc: string,
dirname: string
};
const optionNames = new Set([
@ -70,7 +72,10 @@ const ALLOWED_PLUGIN_KEYS = new Set([
"inherits",
]);
export default function manageOptions(opts?: Object) {
export default function manageOptions(opts: {}): {
options: Object,
passes: Array<Array<[ Plugin, ?{} ]>>,
}|null {
return new OptionManager().init(opts);
}
@ -81,7 +86,7 @@ class OptionManager {
}
options: Object;
passes: Array<Array<Plugin>>;
passes: Array<Array<[Plugin, ?{}]>>;
/**
* This is called when we want to merge the input `opts` into the
@ -92,12 +97,19 @@ class OptionManager {
* - `dirname` is used to resolve plugins relative to it.
*/
mergeOptions(config: MergeOptions, pass?: Array<Plugin>) {
mergeOptions(config: MergeOptions, pass?: Array<[Plugin, ?{}]>) {
const result = loadConfig(config);
const plugins = result.plugins.map((descriptor) => loadPluginDescriptor(descriptor));
const presets = result.presets.map((descriptor) => loadPresetDescriptor(descriptor));
if (
config.options.passPerPreset != null &&
typeof config.options.passPerPreset !== "boolean"
) {
throw new Error(".passPerPreset must be a boolean or undefined");
}
const passPerPreset = config.options.passPerPreset;
pass = pass || this.passes[0];
@ -124,7 +136,7 @@ class OptionManager {
merge(this.options, result.options);
}
init(opts: Object = {}): Object {
init(opts: {}) {
const configChain = buildConfigChain(opts);
if (!configChain) return null;
@ -136,7 +148,8 @@ class OptionManager {
// 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] ${opts.filename || "unknown"}: ${e.message}`;
const filename = typeof opts.filename === "string" ? opts.filename : null;
e.message = `[BABEL] ${filename || "unknown"}: ${e.message}`;
}
throw e;
@ -185,12 +198,28 @@ class OptionManager {
}
}
type BasicDescriptor = {
value: {}|Function,
options: ?{},
dirname: string,
alias: string,
loc: string,
};
/**
* Load and validate the given config into a set of options, plugins, and presets.
*/
function loadConfig(config) {
function loadConfig(config): {
options: {},
plugins: Array<BasicDescriptor>,
presets: Array<BasicDescriptor>,
} {
const options = normalizeOptions(config);
if (config.options.plugins != null && !Array.isArray(config.options.plugins)) {
throw new Error(".plugins should be an array, null, or undefined");
}
const plugins = (config.options.plugins || []).map((plugin, index) => {
const { filepath, value, options } = normalizePair(plugin, loadPlugin, config.dirname);
@ -202,6 +231,11 @@ function loadConfig(config) {
dirname: config.dirname,
};
});
if (config.options.presets != null && !Array.isArray(config.options.presets)) {
throw new Error(".presets should be an array, null, or undefined");
}
const presets = (config.options.presets || []).map((preset, index) => {
const { filepath, value, options } = normalizePair(preset, loadPreset, config.dirname);
@ -259,19 +293,19 @@ function loadPluginDescriptor(descriptor) {
return [ result, descriptor.options];
}
function instantiatePlugin({ value: pluginObject, descriptor }) {
Object.keys(pluginObject).forEach((key) => {
function instantiatePlugin({ value: pluginObj, descriptor }) {
Object.keys(pluginObj).forEach((key) => {
if (!ALLOWED_PLUGIN_KEYS.has(key)) {
throw new Error(messages.get("pluginInvalidProperty", descriptor.alias, key));
}
});
if (pluginObject.visitor && (pluginObject.visitor.enter || pluginObject.visitor.exit)) {
if (pluginObj.visitor && (pluginObj.visitor.enter || pluginObj.visitor.exit)) {
throw new Error("Plugins aren't allowed to specify catch-all enter/exit handlers. " +
"Please target individual nodes.");
}
const plugin = Object.assign({}, pluginObject, {
visitor: clone(pluginObject.visitor || {}),
const plugin = Object.assign({}, pluginObj, {
visitor: clone(pluginObj.visitor || {}),
});
traverse.explode(plugin.visitor);
@ -284,6 +318,7 @@ function instantiatePlugin({ value: pluginObject, descriptor }) {
loc: descriptor.loc,
value: plugin.inherits,
options: descriptor.options,
dirname: descriptor.dirname,
};
inherits = loadPluginDescriptor(inheritsDescriptor)[0];
@ -300,7 +335,7 @@ function instantiatePlugin({ value: pluginObject, descriptor }) {
/**
* Generate a config object that will act as the root of a new nested config.
*/
function loadPresetDescriptor(descriptor) {
function loadPresetDescriptor(descriptor): MergeOptions {
return {
type: "preset",
options: loadDescriptor(descriptor).value,
@ -375,13 +410,6 @@ function normalizeOptions(config) {
options.generatorOpts.generator = loadGenerator(options.generatorOpts.generator, config.dirname).value;
}
if (config.options.presets && !Array.isArray(config.options.presets)) {
throw new Error(`${alias}.presets should be an array`);
}
if (config.options.plugins && !Array.isArray(config.options.plugins)) {
throw new Error(`${alias}.plugins should be an array`);
}
delete options.passPerPreset;
delete options.plugins;
delete options.presets;
@ -392,15 +420,19 @@ function normalizeOptions(config) {
/**
* Given a plugin/preset item, resolve it into a standard format.
*/
function normalizePair(pair, resolver, dirname) {
function normalizePair(pair: mixed, resolver, dirname): {
filepath: string|null,
value: {}|Function,
options: ?{},
} {
let options;
let value = pair;
if (Array.isArray(value)) {
if (value.length > 2) {
throw new Error(`Unexpected extra options ${JSON.stringify(value.slice(2))}.`);
if (Array.isArray(pair)) {
if (pair.length > 2) {
throw new Error(`Unexpected extra options ${JSON.stringify(pair.slice(2))}.`);
}
[value, options] = value;
[value, options] = pair;
}
let filepath = null;
@ -411,6 +443,10 @@ function normalizePair(pair, resolver, dirname) {
} = 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;
@ -419,13 +455,12 @@ function normalizePair(pair, resolver, dirname) {
}
}
if (!value) {
throw new Error(`Unexpected falsy value: ${value}`);
if (typeof value !== "object" && typeof value !== "function") {
throw new Error(`Unsupported format: ${typeof value}. Expected an object or a function.`);
}
const type = typeof value;
if (type !== "object" && type !== "function") {
throw new Error(`Unsupported format: ${type}. Expected an object or a function.`);
if (options != null && typeof options !== "object") {
throw new Error("Plugin/Preset options must be an object, null, or undefined");
}
return { filepath, value, options };

View File

@ -1,5 +1,23 @@
// @flow
export default class Plugin {
constructor(plugin: Object, key?: string) {
constructor(plugin: {}, key?: string) {
if (plugin.name != null && typeof plugin.name !== "string") {
throw new Error("Plugin .name must be a string, null, or undefined");
}
if (plugin.manipulateOptions != null && typeof plugin.manipulateOptions !== "function") {
throw new Error("Plugin .manipulateOptions must be a function, null, or undefined");
}
if (plugin.post != null && typeof plugin.post !== "function") {
throw new Error("Plugin .post must be a function, null, or undefined");
}
if (plugin.pre != null && typeof plugin.pre !== "function") {
throw new Error("Plugin .pre must be a function, null, or undefined");
}
if (plugin.visitor != null && typeof plugin.visitor !== "object") {
throw new Error("Plugin .visitor must be an object, null, or undefined");
}
this.key = plugin.name || key;
this.manipulateOptions = plugin.manipulateOptions;
@ -12,5 +30,5 @@ export default class Plugin {
manipulateOptions: ?Function;
post: ?Function;
pre: ?Function;
visitor: Object;
visitor: ?{};
}

View File

@ -1,3 +1,4 @@
// @flow
/* eslint max-len: "off" */
export default {

View File

@ -1,3 +1,5 @@
{
"plugins": [["transform-react-jsx", "foo.bar"]]
"plugins": [
["transform-react-jsx", {"pragma": "foo.bar"}]
]
}