From a21dd32c46f95bc232a67929c224824692f94b70 Mon Sep 17 00:00:00 2001 From: Sylvan Mably Date: Wed, 21 Jun 2017 23:20:44 -0400 Subject: [PATCH] New: Add `overrides`/`files` options for glob-based config (fixes #3611) (#8081) --- conf/config-schema.js | 68 +++ conf/config-schema.json | 15 - docs/user-guide/configuring.md | 55 +++ lib/config.js | 438 +++++++++--------- lib/config/config-cache.js | 130 ++++++ lib/config/config-file.js | 58 ++- lib/config/config-ops.js | 113 ++++- lib/config/config-validator.js | 4 +- package.json | 1 + .../config-hierarchy/file-structure.json | 6 + tests/lib/cli-engine.js | 2 +- tests/lib/config.js | 289 +++++++++++- tests/lib/config/config-file.js | 232 +++++++--- tests/lib/config/config-ops.js | 125 +++++ tests/lib/config/config-validator.js | 44 ++ 15 files changed, 1259 insertions(+), 321 deletions(-) create mode 100644 conf/config-schema.js delete mode 100644 conf/config-schema.json create mode 100644 lib/config/config-cache.js diff --git a/conf/config-schema.js b/conf/config-schema.js new file mode 100644 index 00000000000..4ef958c40d8 --- /dev/null +++ b/conf/config-schema.js @@ -0,0 +1,68 @@ +/** + * @fileoverview Defines a schema for configs. + * @author Sylvan Mably + */ + +"use strict"; + +const baseConfigProperties = { + env: { type: "object" }, + globals: { type: "object" }, + parser: { type: ["string", "null"] }, + parserOptions: { type: "object" }, + plugins: { type: "array" }, + rules: { type: "object" }, + settings: { type: "object" } +}; + +const overrideProperties = Object.assign( + {}, + baseConfigProperties, + { + files: { + oneOf: [ + { type: "string" }, + { + type: "array", + items: { type: "string" }, + minItems: 1 + } + ] + }, + excludedFiles: { + oneOf: [ + { type: "string" }, + { + type: "array", + items: { type: "string" } + } + ] + } + } +); + +const topLevelConfigProperties = Object.assign( + {}, + baseConfigProperties, + { + extends: { type: ["string", "array"] }, + root: { type: "boolean" }, + overrides: { + type: "array", + items: { + type: "object", + properties: overrideProperties, + required: ["files"], + additionalProperties: false + } + } + } +); + +const configSchema = { + type: "object", + properties: topLevelConfigProperties, + additionalProperties: false +}; + +module.exports = configSchema; diff --git a/conf/config-schema.json b/conf/config-schema.json deleted file mode 100644 index c3676bde925..00000000000 --- a/conf/config-schema.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "type": "object", - "properties": { - "root": { "type": "boolean" }, - "globals": { "type": ["object"] }, - "parser": { "type": ["string", "null"] }, - "env": { "type": "object" }, - "plugins": { "type": ["array"] }, - "settings": { "type": "object" }, - "extends": { "type": ["string", "array"] }, - "rules": { "type": "object" }, - "parserOptions": { "type": "object" } - }, - "additionalProperties": false -} diff --git a/docs/user-guide/configuring.md b/docs/user-guide/configuring.md index 86469a5af4f..ed1f573fa84 100644 --- a/docs/user-guide/configuring.md +++ b/docs/user-guide/configuring.md @@ -713,6 +713,61 @@ module.exports = { } ``` +## Configuration Based on Glob Patterns + +Sometimes a more fine-controlled configuration is necessary, for example if the configuration for files within the same directory has to be different. Therefore you can provide configurations under the `overrides` key that will only apply to files that match specific glob patterns, using the same format you would pass on the command line (e.g., `app/**/*.test.js`). + +### How it works + +* Glob pattern overrides can only be configured within config files (`.eslintrc.*` or `package.json`). +* The patterns are applied against the file path relative to the directory of the config file. For example, if your config file has the path `/Users/john/workspace/any-project/.eslintrc.js` and the file you want to lint has the path `/Users/john/workspace/any-project/lib/util.js`, then the pattern provided in `.eslintrc.js` will be executed against the relative path `lib/util.js`. +* Glob pattern overrides have higher precedence than the regular configuration in the same config file. Multiple overrides within the same config are applied in order. That is, the last override block in a config file always has the highest precedence. +* A glob specific configuration works almost the same as any other ESLint config. Override blocks can contain any configuration options that are valid in a regular config, with the exception of `extends`, `overrides`, and `root`. +* Multiple glob patterns can be provided within a single override block. A file must match at least one of the supplied patterns for the configuration to apply. +* Override blocks can also specify patterns to exclude from matches. If a file matches any of the excluded patterns, the configuration won't apply. + +### Relative glob patterns + +``` +project-root +├── app +│ ├── lib +│ │ ├── foo.js +│ │ ├── fooSpec.js +│ ├── components +│ │ ├── bar.js +│ │ ├── barSpec.js +│ ├── .eslintrc.json +├── server +│ ├── server.js +│ ├── serverSpec.js +├── .eslintrc.json +``` + +The config in `app/.eslintrc.json` defines the glob pattern `**/*Spec.js`. This pattern is relative to the base directory of `app/.eslintrc.json`. So, this pattern would match `app/lib/fooSpec.js` and `app/components/barSpec.js` but **NOT** `server/serverSpec.js`. If you defined the same pattern in the `.eslintrc.json` file within in the `project-root` folder, it would match all three of the `*Spec` files. + +### Example configuration + +In your `.eslintrc.json`: + +```json +{ + "rules": { + "quotes": [ 2, "double" ] + }, + + "overrides": [ + { + "files": [ "bin/*.js", "lib/*.js" ], + "excludedFiles": "*.test.js", + "rules": { + "quotes": [ 2, "single" ] + } + } + ] +} +``` + ## Comments in Configuration Files Both the JSON and YAML configuration file formats support comments (`package.json` files should not include them). You can use JavaScript-style comments or YAML-style comments in either type of file and ESLint will safely ignore them. This allows your configuration files to be more human-friendly. For example: diff --git a/lib/config.js b/lib/config.js index 5c04ee4e152..c69d120ef7f 100644 --- a/lib/config.js +++ b/lib/config.js @@ -13,6 +13,7 @@ const path = require("path"), os = require("os"), ConfigOps = require("./config/config-ops"), ConfigFile = require("./config/config-file"), + ConfigCache = require("./config/config-cache"), Plugins = require("./config/plugins"), FileFinder = require("./file-finder"), isResolvable = require("is-resolvable"); @@ -24,149 +25,22 @@ const debug = require("debug")("eslint:config"); //------------------------------------------------------------------------------ const PERSONAL_CONFIG_DIR = os.homedir() || null; +const SUBCONFIG_SEP = ":"; //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ /** - * Check if item is an javascript object - * @param {*} item object to check for - * @returns {boolean} True if its an object - * @private - */ -function isObject(item) { - return typeof item === "object" && !Array.isArray(item) && item !== null; -} - -/** - * Load and parse a JSON config object from a file. - * @param {string|Object} configToLoad the path to the JSON config file or the config object itself. - * @param {Config} configContext config instance object - * @returns {Object} the parsed config object (empty object if there was a parse error) - * @private - */ -function loadConfig(configToLoad, configContext) { - let config = {}, - filePath = ""; - - if (configToLoad) { - - if (isObject(configToLoad)) { - config = configToLoad; - - if (config.extends) { - config = ConfigFile.applyExtends(config, configContext, filePath); - } - } else { - filePath = configToLoad; - config = ConfigFile.load(filePath, configContext); - } - - } - return config; -} - -/** - * Get personal config object from ~/.eslintrc. - * @param {Config} configContext Plugin context for the config instance - * @returns {Object} the personal config object (null if there is no personal config) - * @private - */ -function getPersonalConfig(configContext) { - let config; - - if (PERSONAL_CONFIG_DIR) { - - const filename = ConfigFile.getFilenameForDirectory(PERSONAL_CONFIG_DIR); - - if (filename) { - debug("Using personal config"); - config = loadConfig(filename, configContext); - } - } - - return config || null; -} - -/** - * Determine if rules were explicitly passed in as options. + * Determines if any rules were explicitly passed in as options. * @param {Object} options The options used to create our configuration. * @returns {boolean} True if rules were passed in as options, false otherwise. + * @private */ function hasRules(options) { return options.rules && Object.keys(options.rules).length > 0; } -/** - * Get a local config object. - * @param {Config} thisConfig A Config object. - * @param {string} directory The directory to start looking in for a local config file. - * @returns {Object} The local config object, or an empty object if there is no local config. - */ -function getLocalConfig(thisConfig, directory) { - - const projectConfigPath = ConfigFile.getFilenameForDirectory(thisConfig.options.cwd); - const localConfigFiles = thisConfig.findLocalConfigFiles(directory); - let found, - config = {}; - - for (const localConfigFile of localConfigFiles) { - - // Don't consider the personal config file in the home directory, - // except if the home directory is the same as the current working directory - if (path.dirname(localConfigFile) === PERSONAL_CONFIG_DIR && localConfigFile !== projectConfigPath) { - continue; - } - - debug(`Loading ${localConfigFile}`); - const localConfig = loadConfig(localConfigFile, thisConfig); - - // Don't consider a local config file found if the config is null - if (!localConfig) { - continue; - } - - found = true; - debug(`Using ${localConfigFile}`); - config = ConfigOps.merge(localConfig, config); - - // Check for root flag - if (localConfig.root === true) { - break; - } - } - - if (!found && !thisConfig.useSpecificConfig) { - - /* - * - Is there a personal config in the user's home directory? If so, - * merge that with the passed-in config. - * - Otherwise, if no rules were manually passed in, throw and error. - * - Note: This function is not called if useEslintrc is false. - */ - const personalConfig = getPersonalConfig(thisConfig); - - if (personalConfig) { - config = ConfigOps.merge(config, personalConfig); - } else if (!hasRules(thisConfig.options) && !thisConfig.options.baseConfig) { - - // No config file, no manual configuration, and no rules, so error. - const noConfigError = new Error("No ESLint configuration found."); - - noConfigError.messageTemplate = "no-config-found"; - noConfigError.messageData = { - directory, - filesExamined: localConfigFiles - }; - - throw noConfigError; - } - } - - return config; -} - //------------------------------------------------------------------------------ // API //------------------------------------------------------------------------------ @@ -177,7 +51,6 @@ function getLocalConfig(thisConfig, directory) { class Config { /** - * Config options * @param {Object} options Options to be passed in * @param {Linter} linterContext Linter instance object */ @@ -187,13 +60,21 @@ class Config { this.linterContext = linterContext; this.plugins = new Plugins(linterContext.environments, linterContext.rules); + this.options = options; this.ignore = options.ignore; this.ignorePath = options.ignorePath; - this.cache = {}; this.parser = options.parser; this.parserOptions = options.parserOptions || {}; - this.baseConfig = options.baseConfig ? loadConfig(options.baseConfig, this) : { rules: {} }; + this.baseConfig = options.baseConfig + ? ConfigOps.merge({}, ConfigFile.loadObject(options.baseConfig, this)) + : { rules: {} }; + this.baseConfig.filePath = ""; + this.baseConfig.baseDirectory = this.options.cwd; + + this.configCache = new ConfigCache(); + this.configCache.setConfig(this.baseConfig.filePath, this.baseConfig); + this.configCache.setMergedVectorConfig(this.baseConfig.filePath, this.baseConfig); this.useEslintrc = (options.useEslintrc !== false); @@ -209,132 +90,275 @@ class Config { * If user declares "foo", convert to "foo:false". */ this.globals = (options.globals || []).reduce((globals, def) => { - const parts = def.split(":"); + const parts = def.split(SUBCONFIG_SEP); globals[parts[0]] = (parts.length > 1 && parts[1] === "true"); return globals; }, {}); - this.options = options; - this.loadConfigFile(options.configFile); + this.loadSpecificConfig(options.configFile); + + // Empty values in configs don't merge properly + const cliConfigOptions = { + env: this.env, + rules: this.options.rules, + globals: this.globals, + parserOptions: this.parserOptions, + plugins: this.options.plugins + }; + + this.cliConfig = {}; + Object.keys(cliConfigOptions).forEach(configKey => { + const value = cliConfigOptions[configKey]; + + if (value) { + this.cliConfig[configKey] = value; + } + }); } /** - * Loads the config from the configuration file - * @param {string} configFile - patch to the config file - * @returns {undefined} - */ - loadConfigFile(configFile) { - if (configFile) { - debug(`Using command line config ${configFile}`); - if (isResolvable(configFile) || isResolvable(`eslint-config-${configFile}`) || configFile.charAt(0) === "@") { - this.useSpecificConfig = loadConfig(configFile, this); - } else { - this.useSpecificConfig = loadConfig(path.resolve(this.options.cwd, configFile), this); + * Loads the config options from a config specified on the command line. + * @param {string} [config] A shareable named config or path to a config file. + * @returns {void} + */ + loadSpecificConfig(config) { + if (config) { + debug(`Using command line config ${config}`); + const isNamedConfig = + isResolvable(config) || + isResolvable(`eslint-config-${config}`) || + config.charAt(0) === "@"; + + if (!isNamedConfig) { + config = path.resolve(this.options.cwd, config); } + + this.specificConfig = ConfigFile.load(config, this); } } /** - * Build a config object merging the base config (conf/eslint-recommended), - * the environments config (conf/environments.js) and eventually the user - * config. - * @param {string} filePath a file in whose directory we start looking for a local config - * @returns {Object} config object + * Gets the personal config object from user's home directory. + * @returns {Object} the personal config object (null if there is no personal config) + * @private */ - getConfig(filePath) { - const directory = filePath ? path.dirname(filePath) : this.options.cwd; - let config, - userConfig; + getPersonalConfig() { + if (typeof this.personalConfig === "undefined") { + let config; - debug(`Constructing config for ${filePath ? filePath : "text"}`); + if (PERSONAL_CONFIG_DIR) { + const filename = ConfigFile.getFilenameForDirectory(PERSONAL_CONFIG_DIR); - config = this.cache[directory]; - if (config) { - debug("Using config from cache"); - return config; + if (filename) { + debug("Using personal config"); + config = ConfigFile.load(filename, this); + } + } + this.personalConfig = config || null; } - // Step 1: Determine user-specified config from .eslintrc.* and package.json files + return this.personalConfig; + } + + /** + * Builds a hierarchy of config objects, including the base config, all local configs from the directory tree, + * and a config file specified on the command line, if applicable. + * @param {string} directory a file in whose directory we start looking for a local config + * @returns {Object[]} The config objects, in ascending order of precedence + * @private + */ + getConfigHierarchy(directory) { + debug(`Constructing config file hierarchy for ${directory}`); + + // Step 1: Always include baseConfig + let configs = [this.baseConfig]; + + // Step 2: Add user-specified config from .eslintrc.* and package.json files if (this.useEslintrc) { debug("Using .eslintrc and package.json files"); - userConfig = getLocalConfig(this, directory); + configs = configs.concat(this.getLocalConfigHierarchy(directory)); } else { debug("Not using .eslintrc or package.json files"); - userConfig = {}; } - // Step 2: Create a copy of the baseConfig - config = ConfigOps.merge({}, this.baseConfig); + // Step 3: Merge in command line config file + if (this.specificConfig) { + debug("Using command line config file"); + configs.push(this.specificConfig); + } - // Step 3: Merge in the user-specified configuration from .eslintrc and package.json - config = ConfigOps.merge(config, userConfig); + return configs; + } - // Step 4: Merge in command line config file - if (this.useSpecificConfig) { - debug("Merging command line config file"); + /** + * Gets a list of config objects extracted from local config files that apply to the current directory, in + * descending order, beginning with the config that is highest in the directory tree. + * @param {string} directory The directory to start looking in for local config files. + * @returns {Object[]} The shallow local config objects, in ascending order of precedence (closest to the current + * directory at the end), or an empty array if there are no local configs. + * @private + */ + getLocalConfigHierarchy(directory) { + const localConfigFiles = this.findLocalConfigFiles(directory), + projectConfigPath = ConfigFile.getFilenameForDirectory(this.options.cwd), + searched = [], + configs = []; - config = ConfigOps.merge(config, this.useSpecificConfig); - } + for (const localConfigFile of localConfigFiles) { + const localConfigDirectory = path.dirname(localConfigFile); + const localConfigHierarchyCache = this.configCache.getHierarchyLocalConfigs(localConfigDirectory); - // Step 5: Merge in command line environments - debug("Merging command line environment settings"); - config = ConfigOps.merge(config, { env: this.env }); + if (localConfigHierarchyCache) { + const localConfigHierarchy = localConfigHierarchyCache.concat(configs.reverse()); - // Step 6: Merge in command line rules - if (this.options.rules) { - debug("Merging command line rules"); - config = ConfigOps.merge(config, { rules: this.options.rules }); - } + this.configCache.setHierarchyLocalConfigs(searched, localConfigHierarchy); + return localConfigHierarchy; + } + + // Don't consider the personal config file in the home directory, + // except if the home directory is the same as the current working directory + if (localConfigDirectory === PERSONAL_CONFIG_DIR && localConfigFile !== projectConfigPath) { + continue; + } - // Step 7: Merge in command line globals - config = ConfigOps.merge(config, { globals: this.globals }); + debug(`Loading ${localConfigFile}`); + const localConfig = ConfigFile.load(localConfigFile, this); - // Only override parser if it is passed explicitly through the command line or if it's not - // defined yet (because the final object will at least have the parser key) - if (this.parser || !config.parser) { - config = ConfigOps.merge(config, { - parser: this.parser - }); - } + // Ignore empty config files + if (!localConfig) { + continue; + } - if (this.parserOptions) { - config = ConfigOps.merge(config, { - parserOptions: this.parserOptions - }); - } + debug(`Using ${localConfigFile}`); + configs.push(localConfig); + searched.push(localConfigDirectory); - // Step 8: Merge in command line plugins - if (this.options.plugins) { - debug("Merging command line plugins"); - this.plugins.loadAll(this.options.plugins); - config = ConfigOps.merge(config, { plugins: this.options.plugins }); + // Stop traversing if a config is found with the root flag set + if (localConfig.root) { + break; + } } - // Step 9: Apply environments to the config if present - if (config.env) { - config = ConfigOps.applyEnvironments(config, this.linterContext.environments); + if (!configs.length && !this.specificConfig) { + + // Fall back on the personal config from ~/.eslintrc + debug("Using personal config file"); + const personalConfig = this.getPersonalConfig(); + + if (personalConfig) { + configs.push(personalConfig); + } else if (!hasRules(this.options) && !this.options.baseConfig) { + + // No config file, no manual configuration, and no rules, so error. + const noConfigError = new Error("No ESLint configuration found."); + + noConfigError.messageTemplate = "no-config-found"; + noConfigError.messageData = { + directory, + filesExamined: localConfigFiles + }; + + throw noConfigError; + } } - this.cache[directory] = config; + // Set the caches for the parent directories + this.configCache.setHierarchyLocalConfigs(searched, configs.reverse()); - return config; + return configs; } /** - * Find local config files from directory and parent directories. + * Gets the vector of applicable configs and subconfigs from the hierarchy for a given file. A vector is an array of + * entries, each of which in an object specifying a config file path and an array of override indices corresponding + * to entries in the config file's overrides section whose glob patterns match the specified file path; e.g., the + * vector entry { configFile: '/home/john/app/.eslintrc', matchingOverrides: [0, 2] } would indicate that the main + * project .eslintrc file and its first and third override blocks apply to the current file. + * @param {string} filePath The file path for which to build the hierarchy and config vector. + * @returns {Array} config vector applicable to the specified path + * @private + */ + getConfigVector(filePath) { + const directory = filePath ? path.dirname(filePath) : this.options.cwd; + + return this.getConfigHierarchy(directory).map(config => { + const vectorEntry = { + filePath: config.filePath, + matchingOverrides: [] + }; + + if (config.overrides) { + const relativePath = path.relative(config.baseDirectory, filePath || directory); + + config.overrides.forEach((override, i) => { + if (ConfigOps.pathMatchesGlobs(relativePath, override.files, override.excludedFiles)) { + vectorEntry.matchingOverrides.push(i); + } + }); + } + + return vectorEntry; + }); + } + + /** + * Finds local config files from the specified directory and its parent directories. * @param {string} directory The directory to start searching from. * @returns {GeneratorFunction} The paths of local config files found. */ findLocalConfigFiles(directory) { - if (!this.localConfigFinder) { this.localConfigFinder = new FileFinder(ConfigFile.CONFIG_FILES, this.options.cwd); } return this.localConfigFinder.findAllInDirectoryAndParents(directory); } + + /** + * Builds the authoritative config object for the specified file path by merging the hierarchy of config objects + * that apply to the current file, including the base config (conf/eslint-recommended), the user's personal config + * from their homedir, all local configs from the directory tree, any specific config file passed on the command + * line, any configuration overrides set directly on the command line, and finally the environment configs + * (conf/environments). + * @param {string} filePath a file in whose directory we start looking for a local config + * @returns {Object} config object + */ + getConfig(filePath) { + const vector = this.getConfigVector(filePath); + let config = this.configCache.getMergedConfig(vector); + + if (config) { + debug("Using config from cache"); + return config; + } + + // Step 1: Merge in the filesystem configurations (base, local, and personal) + config = ConfigOps.getConfigFromVector(vector, this.configCache); + + // Step 2: Merge in command line configurations + config = ConfigOps.merge(config, this.cliConfig); + + if (this.cliConfig.plugins) { + this.plugins.loadAll(this.cliConfig.plugins); + } + + // Step 3: Override parser only if it is passed explicitly through the command line + // or if it's not defined yet (because the final object will at least have the parser key) + if (this.parser || !config.parser) { + config = ConfigOps.merge(config, { parser: this.parser }); + } + + // Step 4: Apply environments to the config if present + if (config.env) { + config = ConfigOps.applyEnvironments(config, this.linterContext.environments); + } + + this.configCache.setMergedConfig(vector, config); + + return config; + } } module.exports = Config; diff --git a/lib/config/config-cache.js b/lib/config/config-cache.js new file mode 100644 index 00000000000..0ffcae9440f --- /dev/null +++ b/lib/config/config-cache.js @@ -0,0 +1,130 @@ +/** + * @fileoverview Responsible for caching config files + * @author Sylvan Mably + */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +/** + * Get a string hash for a config vector + * @param {Array} vector config vector to hash + * @returns {string} hash of the vector values + * @private + */ +function hash(vector) { + return JSON.stringify(vector); +} + +//------------------------------------------------------------------------------ +// API +//------------------------------------------------------------------------------ + +/** + * Configuration caching class (not exported) + */ +class ConfigCache { + + constructor() { + this.filePathCache = new Map(); + this.localHierarchyCache = new Map(); + this.mergedVectorCache = new Map(); + this.mergedCache = new Map(); + } + + /** + * Gets a config object from the cache for the specified config file path. + * @param {string} configFilePath the absolute path to the config file + * @returns {Object|null} config object, if found in the cache, otherwise null + * @private + */ + getConfig(configFilePath) { + return this.filePathCache.get(configFilePath); + } + + /** + * Sets a config object in the cache for the specified config file path. + * @param {string} configFilePath the absolute path to the config file + * @param {Object} config the config object to add to the cache + * @returns {void} + * @private + */ + setConfig(configFilePath, config) { + this.filePathCache.set(configFilePath, config); + } + + /** + * Gets a list of hierarchy-local config objects that apply to the specified directory. + * @param {string} directory the path to the directory + * @returns {Object[]|null} a list of config objects, if found in the cache, otherwise null + * @private + */ + getHierarchyLocalConfigs(directory) { + return this.localHierarchyCache.get(directory); + } + + /** + * For each of the supplied parent directories, sets the list of config objects for that directory to the + * appropriate subset of the supplied parent config objects. + * @param {string[]} parentDirectories a list of parent directories to add to the config cache + * @param {Object[]} parentConfigs a list of config objects that apply to the lowest directory in parentDirectories + * @returns {void} + * @private + */ + setHierarchyLocalConfigs(parentDirectories, parentConfigs) { + parentDirectories.forEach((localConfigDirectory, i) => { + const directoryParentConfigs = parentConfigs.slice(0, parentConfigs.length - i); + + this.localHierarchyCache.set(localConfigDirectory, directoryParentConfigs); + }); + } + + /** + * Gets a merged config object corresponding to the supplied vector. + * @param {Array} vector the vector to find a merged config for + * @returns {Object|null} a merged config object, if found in the cache, otherwise null + * @private + */ + getMergedVectorConfig(vector) { + return this.mergedVectorCache.get(hash(vector)); + } + + /** + * Sets a merged config object in the cache for the supplied vector. + * @param {Array} vector the vector to save a merged config for + * @param {Object} config the merged config object to add to the cache + * @returns {void} + * @private + */ + setMergedVectorConfig(vector, config) { + this.mergedVectorCache.set(hash(vector), config); + } + + /** + * Gets a merged config object corresponding to the supplied vector, including configuration options from outside + * the vector. + * @param {Array} vector the vector to find a merged config for + * @returns {Object|null} a merged config object, if found in the cache, otherwise null + * @private + */ + getMergedConfig(vector) { + return this.mergedCache.get(hash(vector)); + } + + /** + * Sets a merged config object in the cache for the supplied vector, including configuration options from outside + * the vector. + * @param {Array} vector the vector to save a merged config for + * @param {Object} config the merged config object to add to the cache + * @returns {void} + * @private + */ + setMergedConfig(vector, config) { + this.mergedCache.set(hash(vector), config); + } +} + +module.exports = ConfigCache; diff --git a/lib/config/config-file.js b/lib/config/config-file.js index c9fcc9dff80..83252995287 100644 --- a/lib/config/config-file.js +++ b/lib/config/config-file.js @@ -418,7 +418,7 @@ function applyExtends(config, configContext, filePath, relativeTo) { ); } debug(`Loading ${parentPath}`); - return ConfigOps.merge(load(parentPath, configContext, false, relativeTo), previousValue); + return ConfigOps.merge(load(parentPath, configContext, relativeTo), previousValue); } catch (e) { /* @@ -517,16 +517,12 @@ function resolve(filePath, relativeTo) { /** * Loads a configuration file from the given file path. - * @param {string} filePath The filename or package name to load the configuration - * information from. + * @param {Object} resolvedPath The value from calling resolve() on a filename or package name. * @param {Config} configContext Plugins context - * @param {boolean} [applyEnvironments=false] Set to true to merge in environment settings. - * @param {string} [relativeTo] The path to resolve relative to. * @returns {Object} The configuration information. */ -function load(filePath, configContext, applyEnvironments, relativeTo) { - const resolvedPath = resolve(filePath, relativeTo), - dirname = path.dirname(resolvedPath.filePath), +function loadFromDisk(resolvedPath, configContext) { + const dirname = path.dirname(resolvedPath.filePath), lookupPath = getLookupPath(dirname); let config = loadConfigFile(resolvedPath); @@ -547,27 +543,60 @@ function load(filePath, configContext, applyEnvironments, relativeTo) { } // validate the configuration before continuing - validator.validate(config, filePath, configContext.linterContext.rules, configContext.linterContext.environments); + validator.validate(config, resolvedPath, configContext.linterContext.rules, configContext.linterContext.environments); /* * If an `extends` property is defined, it represents a configuration file to use as * a "parent". Load the referenced file and merge the configuration recursively. */ if (config.extends) { - config = applyExtends(config, configContext, filePath, dirname); + config = applyExtends(config, configContext, resolvedPath.filePath, dirname); } + } - if (config.env && applyEnvironments) { + return config; +} - // Merge in environment-specific globals and parserOptions. - config = ConfigOps.applyEnvironments(config, configContext.linterContext.environments); - } +/** + * Loads a config object, applying extends if present. + * @param {Object} configObject a config object to load + * @returns {Object} the config object with extends applied if present, or the passed config if not + * @private + */ +function loadObject(configObject) { + return configObject.extends ? applyExtends(configObject, "") : configObject; +} + +/** + * Loads a config object from the config cache based on its filename, falling back to the disk if the file is not yet + * cached. + * @param {string} filePath the path to the config file + * @param {Config} configContext Context for the config instance + * @param {string} [relativeTo] The path to resolve relative to. + * @returns {Object} the parsed config object (empty object if there was a parse error) + * @private + */ +function load(filePath, configContext, relativeTo) { + const resolvedPath = resolve(filePath, relativeTo); + + const cachedConfig = configContext.configCache.getConfig(resolvedPath.filePath); + if (cachedConfig) { + return cachedConfig; + } + + const config = loadFromDisk(resolvedPath, configContext); + + if (config) { + config.filePath = resolvedPath.filePath; + config.baseDirectory = path.dirname(resolvedPath.filePath); + configContext.configCache.setConfig(resolvedPath.filePath, config); } return config; } + //------------------------------------------------------------------------------ // Public Interface //------------------------------------------------------------------------------ @@ -577,6 +606,7 @@ module.exports = { getBaseDir, getLookupPath, load, + loadObject, resolve, write, applyExtends, diff --git a/lib/config/config-ops.js b/lib/config/config-ops.js index e1d9a901357..d169e60dcfa 100644 --- a/lib/config/config-ops.js +++ b/lib/config/config-ops.js @@ -9,6 +9,9 @@ // Requirements //------------------------------------------------------------------------------ +const minimatch = require("minimatch"), + path = require("path"); + const debug = require("debug")("eslint:config-ops"); //------------------------------------------------------------------------------ @@ -174,7 +177,9 @@ module.exports = { }); } Object.keys(src).forEach(key => { - if (Array.isArray(src[key]) || Array.isArray(target[key])) { + if (key === "overrides") { + dst[key] = (target[key] || []).concat(src[key] || []); + } else if (Array.isArray(src[key]) || Array.isArray(target[key])) { dst[key] = deepmerge(target[key], src[key], key === "plugins" || key === "extends", isRule); } else if (typeof src[key] !== "object" || !src[key] || key === "exported" || key === "astGlobals") { dst[key] = src[key]; @@ -268,5 +273,111 @@ module.exports = { */ isEverySeverityValid(config) { return Object.keys(config).every(ruleId => this.isValidSeverity(config[ruleId])); + }, + + /** + * Merges all configurations in a given config vector. A vector is an array of objects, each containing a config + * file path and a list of subconfig indices that match the current file path. All config data is assumed to be + * cached. + * @param {Array} vector list of config files and their subconfig indices that match the current file path + * @param {Object} configCache the config cache + * @returns {Object} config object + */ + getConfigFromVector(vector, configCache) { + + const cachedConfig = configCache.getMergedVectorConfig(vector); + + if (cachedConfig) { + return cachedConfig; + } + + debug("Using config from partial cache"); + + const subvector = Array.from(vector); + let nearestCacheIndex = subvector.length - 1, + partialCachedConfig; + + while (nearestCacheIndex >= 0) { + partialCachedConfig = configCache.getMergedVectorConfig(subvector); + if (partialCachedConfig) { + break; + } + subvector.pop(); + nearestCacheIndex--; + } + + if (!partialCachedConfig) { + partialCachedConfig = {}; + } + + let finalConfig = partialCachedConfig; + + // Start from entry immediately following nearest cached config (first uncached entry) + for (let i = nearestCacheIndex + 1; i < vector.length; i++) { + finalConfig = this.mergeVectorEntry(finalConfig, vector[i], configCache); + configCache.setMergedVectorConfig(vector.slice(0, i + 1), finalConfig); + } + + return finalConfig; + }, + + /** + * Merges the config options from a single vector entry into the supplied config. + * @param {Object} config the base config to merge the vector entry's options into + * @param {Object} vectorEntry a single entry from a vector, consisting of a config file path and an array of + * matching override indices + * @param {Object} configCache the config cache + * @returns {Object} merged config object + */ + mergeVectorEntry(config, vectorEntry, configCache) { + const vectorEntryConfig = Object.assign({}, configCache.getConfig(vectorEntry.filePath)); + let mergedConfig = Object.assign({}, config), + overrides; + + if (vectorEntryConfig.overrides) { + overrides = vectorEntryConfig.overrides.filter( + (override, overrideIndex) => vectorEntry.matchingOverrides.indexOf(overrideIndex) !== -1 + ); + } else { + overrides = []; + } + + mergedConfig = this.merge(mergedConfig, vectorEntryConfig); + + delete mergedConfig.overrides; + + mergedConfig = overrides.reduce((lastConfig, override) => this.merge(lastConfig, override), mergedConfig); + + if (mergedConfig.filePath) { + delete mergedConfig.filePath; + delete mergedConfig.baseDirectory; + } else if (mergedConfig.files) { + delete mergedConfig.files; + } + + return mergedConfig; + }, + + /** + * Checks that the specified file path matches all of the supplied glob patterns. + * @param {string} filePath The file path to test patterns against + * @param {string|string[]} patterns One or more glob patterns, of which at least one should match the file path + * @param {string|string[]} [excludedPatterns] One or more glob patterns, of which none should match the file path + * @returns {boolean} True if all the supplied patterns match the file path, false otherwise + */ + pathMatchesGlobs(filePath, patterns, excludedPatterns) { + const patternList = [].concat(patterns); + const excludedPatternList = [].concat(excludedPatterns || []); + + patternList.concat(excludedPatternList).forEach(pattern => { + if (path.isAbsolute(pattern) || pattern.includes("..")) { + throw new Error(`Invalid override pattern (expected relative path not containing '..'): ${pattern}`); + } + }); + + const opts = { matchBase: true }; + + return patternList.some(pattern => minimatch(filePath, pattern, opts)) && + !excludedPatternList.some(excludedPattern => minimatch(filePath, excludedPattern, opts)); } }; diff --git a/lib/config/config-validator.js b/lib/config/config-validator.js index 329a5087df9..8754485f449 100644 --- a/lib/config/config-validator.js +++ b/lib/config/config-validator.js @@ -10,7 +10,7 @@ //------------------------------------------------------------------------------ const schemaValidator = require("is-my-json-valid"), - configSchema = require("../../conf/config-schema.json"), + configSchema = require("../../conf/config-schema.js"), util = require("util"); const validators = { @@ -170,7 +170,7 @@ function formatErrors(errors) { return `Property "${formattedField}" is the wrong type (expected ${formattedExpectedType} but got \`${formattedValue}\`)`; } - return `"${error.field.replace(/^(data\.)/, "")}" ${error.message}. Value: ${error.value}`; + return `"${error.field.replace(/^(data\.)/, "")}" ${error.message}. Value: ${JSON.stringify(error.value)}`; }).map(message => `\t- ${message}.\n`).join(""); } diff --git a/package.json b/package.json index 26dbdd8c837..df699ab8935 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "json-stable-stringify": "^1.0.1", "levn": "^0.3.0", "lodash": "^4.17.4", + "minimatch": "^3.0.2", "mkdirp": "^0.5.1", "natural-compare": "^1.4.0", "optionator": "^0.8.2", diff --git a/tests/fixtures/config-hierarchy/file-structure.json b/tests/fixtures/config-hierarchy/file-structure.json index 9ebe7de3087..f840fead518 100644 --- a/tests/fixtures/config-hierarchy/file-structure.json +++ b/tests/fixtures/config-hierarchy/file-structure.json @@ -48,6 +48,12 @@ ".eslintrc": "{\n \"env\": {\n \"commonjs\": true\n }\n}\n" } }, + "overrides": { + ".eslintrc": "{\n \"rules\": {\n \"quotes\": [2, \"double\"],\n \"no-else-return\": 0,\n \"no-unused-vars\": 1,\n \"semi\": [1, \"never\"]\n },\n \"overrides\": [\n {\n \"files\": \"foo.js\",\n \"rules\": {\n \"quotes\": [2, \"single\"]\n }\n },\n {\n \"files\": \"bar.js\",\n \"rules\": {\n \"no-else-return\": 1\n }\n },\n {\n \"files\": \"**/*one.js\",\n \"rules\": {\n \"curly\": [\"error\", \"multi\", \"consistent\"]\n }\n },\n {\n \"files\": \"two/child-two.js\",\n \"rules\": {\n \"no-unused-vars\": 2,\n \"no-console\": 1\n }\n }\n ]\n}\n", + "two": { + ".eslintrc": "{\n \"rules\": {\n \"semi\": [2, \"never\"]\n },\n \"overrides\": [\n {\n \"files\": \"child-two.js\",\n \"rules\": {\n \"no-console\": 0\n }\n }\n ]\n}\n" + } + }, "packagejson": { ".eslintrc": "rules:\r\n quotes: [2, \"double\"]\r\n", "package.json": "{\r\n \"name\": \"\",\r\n \"version\": \"\",\r\n \"eslintConfig\": {\r\n \"rules\": {\r\n \"quotes\": [1, \"single\"]\r\n }\r\n }\r\n}\r\n", diff --git a/tests/lib/cli-engine.js b/tests/lib/cli-engine.js index fcc80b876bb..7529d8edf61 100644 --- a/tests/lib/cli-engine.js +++ b/tests/lib/cli-engine.js @@ -77,7 +77,7 @@ describe("CLIEngine", () => { engine.config.plugins.define(examplePreprocessorName, require("../fixtures/processors/custom-processor")); // load the real file now so that it can consume the loaded plugins - engine.config.loadConfigFile(options.configFile); + engine.config.loadSpecificConfig(options.configFile); return engine; } diff --git a/tests/lib/config.js b/tests/lib/config.js index 1942a1c08aa..935c210bd33 100644 --- a/tests/lib/config.js +++ b/tests/lib/config.js @@ -338,8 +338,8 @@ describe("Config", () => { it("should load the config file when there are JS-style comments in the text", () => { const configPath = path.resolve(__dirname, "..", "fixtures", "configurations", "comments.json"), configHelper = new Config({ configFile: configPath }, linter), - semi = configHelper.useSpecificConfig.rules.semi, - strict = configHelper.useSpecificConfig.rules.strict; + semi = configHelper.specificConfig.rules.semi, + strict = configHelper.specificConfig.rules.strict; assert.equal(semi, 1); assert.equal(strict, 0); @@ -349,8 +349,8 @@ describe("Config", () => { it("should load the config file when a YAML file is used", () => { const configPath = path.resolve(__dirname, "..", "fixtures", "configurations", "env-browser.yaml"), configHelper = new Config({ configFile: configPath }, linter), - noAlert = configHelper.useSpecificConfig.rules["no-alert"], - noUndef = configHelper.useSpecificConfig.rules["no-undef"]; + noAlert = configHelper.specificConfig.rules["no-alert"], + noUndef = configHelper.specificConfig.rules["no-undef"]; assert.equal(noAlert, 0); assert.equal(noUndef, 2); @@ -1081,6 +1081,287 @@ describe("Config", () => { }, "No ESLint configuration found"); }); }); + + + describe("with overrides", () => { + + /** + * Returns the path inside of the fixture directory. + * @param {...string} pathSegments One or more path segments, in order of depth, shallowest first + * @returns {string} The path inside the fixture directory. + * @private + */ + function getFakeFixturePath() { + const pathSegments = Array.from(arguments); + + pathSegments.unshift("config-hierarchy"); + pathSegments.unshift("fixtures"); + pathSegments.unshift("eslint"); + pathSegments.unshift(process.cwd()); + + return path.join.apply(path, pathSegments); + } + + before(() => { + mockFs({ + eslint: { + fixtures: { + "config-hierarchy": DIRECTORY_CONFIG_HIERARCHY + } + } + }); + }); + + after(() => { + mockFs.restore(); + }); + + it("should merge override config when the pattern matches the file name", () => { + const config = new Config({ cwd: process.cwd() }, linter); + const targetPath = getFakeFixturePath("overrides", "foo.js"); + const expected = { + rules: { + quotes: [2, "single"], + "no-else-return": 0, + "no-unused-vars": 1, + semi: [1, "never"] + } + }; + const actual = config.getConfig(targetPath); + + assertConfigsEqual(actual, expected); + }); + + it("should merge override config when the pattern matches the file path relative to the config file", () => { + const config = new Config({ cwd: process.cwd() }, linter); + const targetPath = getFakeFixturePath("overrides", "child", "child-one.js"); + const expected = { + rules: { + curly: ["error", "multi", "consistent"], + "no-else-return": 0, + "no-unused-vars": 1, + quotes: [2, "double"], + semi: [1, "never"] + } + }; + const actual = config.getConfig(targetPath); + + assertConfigsEqual(actual, expected); + }); + + it("should not merge override config when the pattern matches the absolute file path", () => { + const targetPath = getFakeFixturePath("overrides", "bar.js"); + const resolvedPath = path.resolve(__dirname, "..", "fixtures", "config-hierarchy", "overrides", "bar.js"); + const config = new Config({ + cwd: process.cwd(), + baseConfig: { + overrides: [{ + files: resolvedPath, + rules: { + quotes: [1, "double"] + } + }], + useEslintrc: false + } + }, linter); + + assert.throws(() => config.getConfig(targetPath), /Invalid override pattern/); + }); + + it("should not merge override config when the pattern traverses up the directory tree", () => { + const targetPath = getFakeFixturePath("overrides", "bar.js"); + const parentPath = "overrides/../**/*.js"; + + const config = new Config({ + cwd: process.cwd(), + baseConfig: { + overrides: [{ + files: parentPath, + rules: { + quotes: [1, "single"] + } + }], + useEslintrc: false + } + }, linter); + + assert.throws(() => config.getConfig(targetPath), /Invalid override pattern/); + }); + + it("should merge all local configs (override and non-override) before non-local configs", () => { + const config = new Config({ cwd: process.cwd() }, linter); + const targetPath = getFakeFixturePath("overrides", "two", "child-two.js"); + const expected = { + rules: { + "no-console": 0, + "no-else-return": 0, + "no-unused-vars": 2, + quotes: [2, "double"], + semi: [2, "never"] + } + }; + const actual = config.getConfig(targetPath); + + assertConfigsEqual(actual, expected); + }); + + it("should apply overrides in parent .eslintrc over non-override rules in child .eslintrc", () => { + const targetPath = getFakeFixturePath("overrides", "three", "foo.js"); + const config = new Config({ + cwd: getFakeFixturePath("overrides"), + baseConfig: { + overrides: [ + { + files: "three/**/*.js", + rules: { + "semi-style": [2, "last"] + } + } + ] + }, + useEslintrc: false + }, linter); + const expected = { + rules: { + "semi-style": [2, "last"] + } + }; + const actual = config.getConfig(targetPath); + + assertConfigsEqual(actual, expected); + }); + + it("should apply overrides if all glob patterns match", () => { + const targetPath = getFakeFixturePath("overrides", "one", "child-one.js"); + const config = new Config({ + cwd: getFakeFixturePath("overrides"), + baseConfig: { + overrides: [{ + files: ["one/**/*", "*.js"], + rules: { + quotes: [2, "single"] + } + }] + }, + useEslintrc: false + }, linter); + const expected = { + rules: { + quotes: [2, "single"] + } + }; + const actual = config.getConfig(targetPath); + + assertConfigsEqual(actual, expected); + }); + + it("should apply overrides even if some glob patterns do not match", () => { + const targetPath = getFakeFixturePath("overrides", "one", "child-one.js"); + const config = new Config({ + cwd: getFakeFixturePath("overrides"), + baseConfig: { + overrides: [{ + files: ["one/**/*", "*two.js"], + rules: { + quotes: [2, "single"] + } + }] + }, + useEslintrc: false + }, linter); + const expected = { + rules: { + quotes: [2, "single"] + } + }; + const actual = config.getConfig(targetPath); + + assertConfigsEqual(actual, expected); + }); + + it("should not apply overrides if any excluded glob patterns match", () => { + const targetPath = getFakeFixturePath("overrides", "one", "child-one.js"); + const config = new Config({ + cwd: getFakeFixturePath("overrides"), + baseConfig: { + overrides: [{ + files: "one/**/*", + excludedFiles: ["two/**/*", "*one.js"], + rules: { + quotes: [2, "single"] + } + }] + }, + useEslintrc: false + }, linter); + const expected = { + rules: {} + }; + const actual = config.getConfig(targetPath); + + assertConfigsEqual(actual, expected); + }); + + it("should apply overrides if all excluded glob patterns fail to match", () => { + const targetPath = getFakeFixturePath("overrides", "one", "child-one.js"); + const config = new Config({ + cwd: getFakeFixturePath("overrides"), + baseConfig: { + overrides: [{ + files: "one/**/*", + excludedFiles: ["two/**/*", "*two.js"], + rules: { + quotes: [2, "single"] + } + }] + }, + useEslintrc: false + }, linter); + const expected = { + rules: { + quotes: [2, "single"] + } + }; + const actual = config.getConfig(targetPath); + + assertConfigsEqual(actual, expected); + }); + + it("should cascade", () => { + const targetPath = getFakeFixturePath("overrides", "foo.js"); + const config = new Config({ + cwd: getFakeFixturePath("overrides"), + baseConfig: { + overrides: [ + { + files: "foo.js", + rules: { + semi: [2, "never"], + quotes: [2, "single"] + } + }, + { + files: "foo.js", + rules: { + semi: [2, "never"], + quotes: [2, "double"] + } + } + ] + }, + useEslintrc: false + }, linter); + const expected = { + rules: { + semi: [2, "never"], + quotes: [2, "double"] + } + }; + const actual = config.getConfig(targetPath); + + assertConfigsEqual(actual, expected); + }); + }); }); describe("Plugin Environments", () => { diff --git a/tests/lib/config/config-file.js b/tests/lib/config/config-file.js index a8f6046d2bc..a9d2baa0d55 100644 --- a/tests/lib/config/config-file.js +++ b/tests/lib/config/config-file.js @@ -16,7 +16,6 @@ const assert = require("chai").assert, os = require("os"), yaml = require("js-yaml"), shell = require("shelljs"), - environments = require("../../../conf/environments"), ConfigFile = require("../../../lib/config/config-file"), Linter = require("../../../lib/linter"), Config = require("../../../lib/config"); @@ -24,7 +23,7 @@ const assert = require("chai").assert, const userHome = os.homedir(); const temp = require("temp").track(); const proxyquire = require("proxyquire").noCallThru().noPreserveCache(); -const configContext = new Config({}, new Linter()); +let configContext; //------------------------------------------------------------------------------ // Helpers @@ -144,6 +143,10 @@ function createStubModuleResolver(mapping) { describe("ConfigFile", () => { + beforeEach(() => { + configContext = new Config({}, new Linter()); + }); + describe("CONFIG_FILES", () => { it("should be present when imported", () => { assert.isTrue(Array.isArray(ConfigFile.CONFIG_FILES)); @@ -177,6 +180,8 @@ describe("ConfigFile", () => { }, configContext, "/whatever"); assert.deepEqual(config, { + baseDirectory: path.dirname(resolvedPath), + filePath: resolvedPath, extends: "foo", parserOptions: {}, env: { browser: true }, @@ -332,6 +337,8 @@ describe("ConfigFile", () => { }, configContext, "/whatever"); assert.deepEqual(config, { + baseDirectory: path.dirname(resolvedPaths[0]), + filePath: resolvedPaths[0], extends: "foo", parserOptions: {}, env: { browser: true }, @@ -346,13 +353,18 @@ describe("ConfigFile", () => { it("should apply extensions when specified from a JavaScript file", () => { + const extendsFile = ".eslintrc.js"; + const filePath = getFixturePath("js/foo.js"); + const config = ConfigFile.applyExtends({ - extends: ".eslintrc.js", + extends: extendsFile, rules: { eqeqeq: 2 } - }, configContext, getFixturePath("js/foo.js")); + }, configContext, filePath); assert.deepEqual(config, { - extends: ".eslintrc.js", + baseDirectory: path.dirname(filePath), + filePath: path.join(path.dirname(filePath), extendsFile), + extends: extendsFile, parserOptions: {}, env: {}, globals: {}, @@ -366,13 +378,18 @@ describe("ConfigFile", () => { it("should apply extensions when specified from a YAML file", () => { + const extendsFile = ".eslintrc.yaml"; + const filePath = getFixturePath("yaml/foo.js"); + const config = ConfigFile.applyExtends({ - extends: ".eslintrc.yaml", + extends: extendsFile, rules: { eqeqeq: 2 } - }, configContext, getFixturePath("yaml/foo.js")); + }, configContext, filePath); assert.deepEqual(config, { - extends: ".eslintrc.yaml", + baseDirectory: path.dirname(filePath), + filePath: path.join(path.dirname(filePath), extendsFile), + extends: extendsFile, parserOptions: {}, env: { browser: true }, globals: {}, @@ -385,13 +402,18 @@ describe("ConfigFile", () => { it("should apply extensions when specified from a JSON file", () => { + const extendsFile = ".eslintrc.json"; + const filePath = getFixturePath("json/foo.js"); + const config = ConfigFile.applyExtends({ - extends: ".eslintrc.json", + extends: extendsFile, rules: { eqeqeq: 2 } - }, configContext, getFixturePath("json/foo.js")); + }, configContext, filePath); assert.deepEqual(config, { - extends: ".eslintrc.json", + baseDirectory: path.dirname(filePath), + filePath: path.join(path.dirname(filePath), extendsFile), + extends: extendsFile, parserOptions: {}, env: {}, globals: {}, @@ -405,13 +427,18 @@ describe("ConfigFile", () => { it("should apply extensions when specified from a package.json file in a sibling directory", () => { + const extendsFile = "../package-json/package.json"; + const filePath = getFixturePath("json/foo.js"); + const config = ConfigFile.applyExtends({ - extends: "../package-json/package.json", + extends: extendsFile, rules: { eqeqeq: 2 } - }, configContext, getFixturePath("json/foo.js")); + }, configContext, filePath); assert.deepEqual(config, { - extends: "../package-json/package.json", + baseDirectory: path.dirname(path.resolve(path.dirname(filePath), extendsFile)), + filePath: path.resolve(path.dirname(filePath), extendsFile), + extends: extendsFile, parserOptions: {}, env: { es6: true }, globals: {}, @@ -437,9 +464,12 @@ describe("ConfigFile", () => { }); it("should load information from a legacy file", () => { - const config = ConfigFile.load(getFixturePath("legacy/.eslintrc"), configContext); + const configFilePath = getFixturePath("legacy/.eslintrc"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, parserOptions: {}, env: {}, globals: {}, @@ -450,9 +480,12 @@ describe("ConfigFile", () => { }); it("should load information from a JavaScript file", () => { - const config = ConfigFile.load(getFixturePath("js/.eslintrc.js"), configContext); + const configFilePath = getFixturePath("js/.eslintrc.js"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, parserOptions: {}, env: {}, globals: {}, @@ -469,9 +502,12 @@ describe("ConfigFile", () => { }); it("should interpret parser module name when present in a JavaScript file", () => { - const config = ConfigFile.load(getFixturePath("js/.eslintrc.parser.js"), configContext); + const configFilePath = getFixturePath("js/.eslintrc.parser.js"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, parser: path.resolve(getFixturePath("js/node_modules/foo/index.js")), parserOptions: {}, env: {}, @@ -483,9 +519,12 @@ describe("ConfigFile", () => { }); it("should interpret parser path when present in a JavaScript file", () => { - const config = ConfigFile.load(getFixturePath("js/.eslintrc.parser2.js"), configContext); + const configFilePath = getFixturePath("js/.eslintrc.parser2.js"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, parser: path.resolve(getFixturePath("js/not-a-config.js")), parserOptions: {}, env: {}, @@ -497,9 +536,12 @@ describe("ConfigFile", () => { }); it("should interpret parser module name or path when parser is set to default parser in a JavaScript file", () => { - const config = ConfigFile.load(getFixturePath("js/.eslintrc.parser3.js"), configContext); + const configFilePath = getFixturePath("js/.eslintrc.parser3.js"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, parser: require.resolve("espree"), parserOptions: {}, env: {}, @@ -511,9 +553,12 @@ describe("ConfigFile", () => { }); it("should load information from a JSON file", () => { - const config = ConfigFile.load(getFixturePath("json/.eslintrc.json"), configContext); + const configFilePath = getFixturePath("json/.eslintrc.json"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, parserOptions: {}, env: {}, globals: {}, @@ -544,16 +589,26 @@ describe("ConfigFile", () => { tmpFilePath = writeTempConfigFile(initialConfig, tmpFilename); let config = ConfigFile.load(tmpFilePath, configContext); - assert.deepEqual(config, initialConfig); + assert.deepEqual(config, Object.assign({}, initialConfig, { + baseDirectory: path.dirname(tmpFilePath), + filePath: tmpFilePath + })); writeTempConfigFile(updatedConfig, tmpFilename, path.dirname(tmpFilePath)); + configContext = new Config({}, new Linter()); config = ConfigFile.load(tmpFilePath, configContext); - assert.deepEqual(config, updatedConfig); + assert.deepEqual(config, Object.assign({}, updatedConfig, { + baseDirectory: path.dirname(tmpFilePath), + filePath: tmpFilePath + })); }); it("should load information from a package.json file", () => { - const config = ConfigFile.load(getFixturePath("package-json/package.json"), configContext); + const configFilePath = getFixturePath("package-json/package.json"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, parserOptions: {}, env: { es6: true }, globals: {}, @@ -567,17 +622,6 @@ describe("ConfigFile", () => { }, /Cannot read config file/); }); - it("should load information from a package.json file and apply environments", () => { - const config = ConfigFile.load(getFixturePath("package-json/package.json"), configContext, true); - - assert.deepEqual(config, { - parserOptions: { ecmaVersion: 6 }, - env: { es6: true }, - globals: environments.es6.globals, - rules: {} - }); - }); - it("should load fresh information from a package.json file", () => { const initialConfig = { eslintConfig: { @@ -603,10 +647,17 @@ describe("ConfigFile", () => { tmpFilePath = writeTempConfigFile(initialConfig, tmpFilename); let config = ConfigFile.load(tmpFilePath, configContext); - assert.deepEqual(config, initialConfig.eslintConfig); + assert.deepEqual(config, Object.assign({}, initialConfig.eslintConfig, { + baseDirectory: path.dirname(tmpFilePath), + filePath: tmpFilePath + })); writeTempConfigFile(updatedConfig, tmpFilename, path.dirname(tmpFilePath)); + configContext = new Config({}, new Linter()); config = ConfigFile.load(tmpFilePath, configContext); - assert.deepEqual(config, updatedConfig.eslintConfig); + assert.deepEqual(config, Object.assign({}, updatedConfig.eslintConfig, { + baseDirectory: path.dirname(tmpFilePath), + filePath: tmpFilePath + })); }); it("should load fresh information from a .eslintrc.js file", () => { @@ -630,16 +681,26 @@ describe("ConfigFile", () => { tmpFilePath = writeTempJsConfigFile(initialConfig, tmpFilename); let config = ConfigFile.load(tmpFilePath, configContext); - assert.deepEqual(config, initialConfig); + assert.deepEqual(config, Object.assign({}, initialConfig, { + baseDirectory: path.dirname(tmpFilePath), + filePath: tmpFilePath + })); writeTempJsConfigFile(updatedConfig, tmpFilename, path.dirname(tmpFilePath)); + configContext = new Config({}, new Linter()); config = ConfigFile.load(tmpFilePath, configContext); - assert.deepEqual(config, updatedConfig); + assert.deepEqual(config, Object.assign({}, updatedConfig, { + baseDirectory: path.dirname(tmpFilePath), + filePath: tmpFilePath + })); }); it("should load information from a YAML file", () => { - const config = ConfigFile.load(getFixturePath("yaml/.eslintrc.yaml"), configContext); + const configFilePath = getFixturePath("yaml/.eslintrc.yaml"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, parserOptions: {}, env: { browser: true }, globals: {}, @@ -648,9 +709,12 @@ describe("ConfigFile", () => { }); it("should load information from a YAML file", () => { - const config = ConfigFile.load(getFixturePath("yaml/.eslintrc.empty.yaml"), configContext); + const configFilePath = getFixturePath("yaml/.eslintrc.empty.yaml"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, parserOptions: {}, env: {}, globals: {}, @@ -658,21 +722,13 @@ describe("ConfigFile", () => { }); }); - it("should load information from a YAML file and apply environments", () => { - const config = ConfigFile.load(getFixturePath("yaml/.eslintrc.yaml"), configContext, true); - - assert.deepEqual(config, { - parserOptions: {}, - env: { browser: true }, - globals: environments.browser.globals, - rules: {} - }); - }); - it("should load information from a YML file", () => { - const config = ConfigFile.load(getFixturePath("yml/.eslintrc.yml"), configContext); + const configFilePath = getFixturePath("yml/.eslintrc.yml"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, parserOptions: {}, env: { node: true }, globals: {}, @@ -680,33 +736,28 @@ describe("ConfigFile", () => { }); }); - it("should load information from a YML file and apply environments", () => { - const config = ConfigFile.load(getFixturePath("yml/.eslintrc.yml"), configContext, true); - - assert.deepEqual(config, { - parserOptions: { ecmaFeatures: { globalReturn: true } }, - env: { node: true }, - globals: environments.node.globals, - rules: {} - }); - }); - it("should load information from a YML file and apply extensions", () => { - const config = ConfigFile.load(getFixturePath("extends/.eslintrc.yml"), configContext, true); + const configFilePath = getFixturePath("extends/.eslintrc.yml"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { - extends: "../package-json/package.json", - parserOptions: { ecmaVersion: 6 }, + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, env: { es6: true }, - globals: environments.es6.globals, + extends: "../package-json/package.json", + globals: {}, + parserOptions: {}, rules: { booya: 2 } }); }); it("should load information from `extends` chain.", () => { - const config = ConfigFile.load(getFixturePath("extends-chain/.eslintrc.json"), configContext); + const configFilePath = getFixturePath("extends-chain/.eslintrc.json"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, env: {}, extends: "a", globals: {}, @@ -720,9 +771,12 @@ describe("ConfigFile", () => { }); it("should load information from `extends` chain with relative path.", () => { - const config = ConfigFile.load(getFixturePath("extends-chain-2/.eslintrc.json"), configContext); + const configFilePath = getFixturePath("extends-chain-2/.eslintrc.json"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, env: {}, extends: "a", globals: {}, @@ -735,9 +789,12 @@ describe("ConfigFile", () => { }); it("should load information from `extends` chain in .eslintrc with relative path.", () => { - const config = ConfigFile.load(getFixturePath("extends-chain-2/relative.eslintrc.json"), configContext); + const configFilePath = getFixturePath("extends-chain-2/relative.eslintrc.json"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, env: {}, extends: "./node_modules/eslint-config-a/index.js", globals: {}, @@ -750,10 +807,13 @@ describe("ConfigFile", () => { }); it("should load information from `parser` in .eslintrc with relative path.", () => { - const config = ConfigFile.load(getFixturePath("extends-chain-2/parser.eslintrc.json"), configContext); + const configFilePath = getFixturePath("extends-chain-2/parser.eslintrc.json"); + const config = ConfigFile.load(configFilePath, configContext); const parserPath = getFixturePath("extends-chain-2/parser.js"); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, env: {}, globals: {}, parser: parserPath, @@ -778,9 +838,12 @@ describe("ConfigFile", () => { }); it("should load information from `extends` chain in .eslintrc with relative path.", () => { - const config = ConfigFile.load(path.join(fixturePath, "relative.eslintrc.json"), configContext); + const configFilePath = path.join(fixturePath, "relative.eslintrc.json"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, env: {}, extends: "./node_modules/eslint-config-a/index.js", globals: {}, @@ -793,10 +856,13 @@ describe("ConfigFile", () => { }); it("should load information from `parser` in .eslintrc with relative path.", () => { - const config = ConfigFile.load(path.join(fixturePath, "parser.eslintrc.json"), configContext); + const configFilePath = path.join(fixturePath, "parser.eslintrc.json"); + const config = ConfigFile.load(configFilePath, configContext); const parserPath = path.join(fixturePath, "parser.js"); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, env: {}, globals: {}, parser: parserPath, @@ -818,9 +884,12 @@ describe("ConfigFile", () => { } }); - const config = ConfigFile.load(getFixturePath("plugins/.eslintrc.yml"), stubConfig); + const configFilePath = getFixturePath("plugins/.eslintrc.yml"); + const config = ConfigFile.load(configFilePath, stubConfig); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, parserOptions: {}, env: { "test/bar": true }, globals: {}, @@ -834,9 +903,12 @@ describe("ConfigFile", () => { describe("even if config files have Unicode BOM,", () => { it("should read the JSON config file correctly.", () => { - const config = ConfigFile.load(getFixturePath("bom/.eslintrc.json"), configContext); + const configFilePath = getFixturePath("bom/.eslintrc.json"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, env: {}, globals: {}, parserOptions: {}, @@ -847,9 +919,12 @@ describe("ConfigFile", () => { }); it("should read the YAML config file correctly.", () => { - const config = ConfigFile.load(getFixturePath("bom/.eslintrc.yaml"), configContext); + const configFilePath = getFixturePath("bom/.eslintrc.yaml"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, env: {}, globals: {}, parserOptions: {}, @@ -860,9 +935,12 @@ describe("ConfigFile", () => { }); it("should read the config in package.json correctly.", () => { - const config = ConfigFile.load(getFixturePath("bom/package.json"), configContext); + const configFilePath = getFixturePath("bom/package.json"); + const config = ConfigFile.load(configFilePath, configContext); assert.deepEqual(config, { + baseDirectory: path.dirname(configFilePath), + filePath: configFilePath, env: {}, globals: {}, parserOptions: {}, diff --git a/tests/lib/config/config-ops.js b/tests/lib/config/config-ops.js index f0c72c4880a..ed95238d073 100644 --- a/tests/lib/config/config-ops.js +++ b/tests/lib/config/config-ops.js @@ -12,6 +12,7 @@ const assert = require("chai").assert, leche = require("leche"), environments = require("../../../conf/environments"), Environments = require("../../../lib/config/environments"), + ConfigCache = require("../../../lib/config/config-cache"), ConfigOps = require("../../../lib/config/config-ops"); const envContext = new Environments(); @@ -475,7 +476,78 @@ describe("ConfigOps", () => { }); }); + describe("overrides", () => { + it("should combine the override entries in the correct order", () => { + const baseConfig = { overrides: [{ files: ["**/*Spec.js"], env: { mocha: true } }] }; + const customConfig = { overrides: [{ files: ["**/*.jsx"], ecmaFeatures: { jsx: true } }] }; + const expectedResult = { + overrides: [ + { files: ["**/*Spec.js"], env: { mocha: true } }, + { files: ["**/*.jsx"], ecmaFeatures: { jsx: true } } + ] + }; + const result = ConfigOps.merge(baseConfig, customConfig); + + assert.deepEqual(result, expectedResult); + }); + + it("should work if the base config doesn’t have an overrides property", () => { + const baseConfig = {}; + const customConfig = { overrides: [{ files: ["**/*.jsx"], ecmaFeatures: { jsx: true } }] }; + const expectedResult = { + overrides: [ + { files: ["**/*.jsx"], ecmaFeatures: { jsx: true } } + ] + }; + + const result = ConfigOps.merge(baseConfig, customConfig); + + assert.deepEqual(result, expectedResult); + }); + + it("should work if the custom config doesn’t have an overrides property", () => { + const baseConfig = { overrides: [{ files: ["**/*Spec.js"], env: { mocha: true } }] }; + const customConfig = {}; + const expectedResult = { + overrides: [ + { files: ["**/*Spec.js"], env: { mocha: true } } + ] + }; + + const result = ConfigOps.merge(baseConfig, customConfig); + + assert.deepEqual(result, expectedResult); + }); + + it("should work if overrides are null in the base config", () => { + const baseConfig = { overrides: null }; + const customConfig = { overrides: [{ files: ["**/*.jsx"], ecmaFeatures: { jsx: true } }] }; + const expectedResult = { + overrides: [ + { files: ["**/*.jsx"], ecmaFeatures: { jsx: true } } + ] + }; + + const result = ConfigOps.merge(baseConfig, customConfig); + + assert.deepEqual(result, expectedResult); + }); + + it("should work if overrides are null in the custom config", () => { + const baseConfig = { overrides: [{ files: ["**/*Spec.js"], env: { mocha: true } }] }; + const customConfig = { overrides: null }; + const expectedResult = { + overrides: [ + { files: ["**/*Spec.js"], env: { mocha: true } } + ] + }; + + const result = ConfigOps.merge(baseConfig, customConfig); + + assert.deepEqual(result, expectedResult); + }); + }); }); describe("normalize()", () => { @@ -794,4 +866,57 @@ describe("ConfigOps", () => { }); + describe("getConfigFromVector()", () => { + let configCache; + + beforeEach(() => { + configCache = new ConfigCache(); + }); + + it("should get from merged vector cache when present", () => { + const vector = [ + { filePath: "configFile1", matchingOverrides: [1] }, + { filePath: "configFile2", matchingOverrides: [0, 1] } + ]; + const merged = { merged: true }; + + configCache.setMergedVectorConfig(vector, merged); + + const result = ConfigOps.getConfigFromVector(vector, configCache); + + assert.deepEqual(result, merged); + }); + + it("should get from raw cached configs when no merged vectors are cached", () => { + const config = [ + { + rules: { foo1: "off" }, + overrides: [ + { files: "pattern1", rules: { foo1: "warn" } }, + { files: "pattern2", rules: { foo1: "error" } } + ] + }, + { + rules: { foo2: "warn" }, + overrides: [ + { files: "pattern1", rules: { foo2: "error" } }, + { files: "pattern2", rules: { foo2: "off" } } + ] + } + ]; + + configCache.setConfig("configFile1", config[0]); + configCache.setConfig("configFile2", config[1]); + + const vector = [ + { filePath: "configFile1", matchingOverrides: [1] }, + { filePath: "configFile2", matchingOverrides: [0, 1] } + ]; + + const result = ConfigOps.getConfigFromVector(vector, configCache); + + assert.equal(result.rules.foo1, "error"); + assert.equal(result.rules.foo2, "off"); + }); + }); }); diff --git a/tests/lib/config/config-validator.js b/tests/lib/config/config-validator.js index 3349c07efd5..d220fdb93ea 100644 --- a/tests/lib/config/config-validator.js +++ b/tests/lib/config/config-validator.js @@ -354,6 +354,50 @@ describe("Validator", () => { }); }); + describe("overrides", () => { + it("should not throw with an empty overrides array", () => { + const fn = validator.validate.bind(null, { overrides: [] }, "tests", linter.rules, linter.environments); + + assert.doesNotThrow(fn); + }); + + it("should not throw with a valid overrides array", () => { + const fn = validator.validate.bind(null, { overrides: [{ files: "*", rules: {} }] }, "tests", linter.rules, linter.environments); + + assert.doesNotThrow(fn); + }); + + it("should throw if override does not specify files", () => { + const fn = validator.validate.bind(null, { overrides: [{ rules: {} }] }, "tests", linter.rules, linter.environments); + + assert.throws(fn, "tests:\n\tESLint configuration is invalid:\n\t- \"overrides.0.files\" is required. Value: {\"rules\":{}}.\n"); + }); + + it("should throw if override has an empty files array", () => { + const fn = validator.validate.bind(null, { overrides: [{ files: [] }] }, "tests", linter.rules, linter.environments); + + assert.throws(fn, "tests:\n\tESLint configuration is invalid:\n\t- \"overrides.0.files\" no (or more than one) schemas match. Value: [].\n"); + }); + + it("should throw if override has nested overrides", () => { + const fn = validator.validate.bind(null, { overrides: [{ files: "*", overrides: [{ files: "*", rules: {} }] }] }, "tests", linter.rules, linter.environments); + + assert.throws(fn, "tests:\n\tESLint configuration is invalid:\n\t- Unexpected top-level property \"overrides[j].overrides\".\n"); + }); + + it("should throw if override extends", () => { + const fn = validator.validate.bind(null, { overrides: [{ files: "*", extends: "eslint-recommended" }] }, "tests", linter.rules, linter.environments); + + assert.throws(fn, "tests:\n\tESLint configuration is invalid:\n\t- Unexpected top-level property \"overrides[j].extends\".\n"); + }); + + it("should throw if override tries to set root", () => { + const fn = validator.validate.bind(null, { overrides: [{ files: "*", root: "true" }] }, "tests", linter.rules, linter.environments); + + assert.throws(fn, "tests:\n\tESLint configuration is invalid:\n\t- Unexpected top-level property \"overrides[j].root\".\n"); + }); + }); + }); describe("getRuleOptionsSchema", () => {