diff --git a/docs/user-guide/configuring.md b/docs/user-guide/configuring.md index d425d74674dc..8c2edf33969d 100644 --- a/docs/user-guide/configuring.md +++ b/docs/user-guide/configuring.md @@ -709,6 +709,65 @@ module.exports = { } ``` +## Glob Pattern based Configuration + +Sometimes a more fine-controlled configuration is necessary e.g. if the configuration for files within the same directory has to be different. Therefore you can provide configurations that will only apply to files that match a specific glob pattern (based on [minimatch](https://github.com/isaacs/minimatch)). + +### How it works + +* Glob pattern based configurations can only be configured within `.eslintrc` files. +* The patterns are applied against the file path relative to the directory of the `.eslintrc` file. + Example: If your `.eslintrc` file has the path `/Users/john/workspace/any-project/.eslintrc` + and the file you want to lint has the path `/Users/john/workspace/any-project/lib/util.js` then the pattern, provided in `.eslintrc` will be executed against the relative path `lib/util.js`. +* Glob pattern based configuration has higher precedence than the regular configuration in the same `.eslintrc` file. +* A glob specific configuration works almost the same as the regular configuration in your `.eslintrc`, except that you can’t define nested glob based configurations or using `extends`. + +### Examples + +#### Relative relative glob patterns + +``` +project-root +├── app +│ ├── lib +│ │ ├── foo.js +│ │ ├── fooSpec.js +│ ├── components +│ │ ├── bar.js +│ │ ├── barSpec.js +│ ├── .eslintrc +├── server +│ ├── server.js +│ ├── serverSpec.js +├── .eslintrc +``` + +The config in `app/.eslintrc` defines the glob pattern `**/*Spec.js`. This pattern is relative to the base directory of `app/.eslintrc`. So, this pattern would match `app/lib/fooSpec.js` and `app/components/barSpec.js` but **NOT** `server/serverSpec.js`. +If you would define the same pattern in the `.eslintrc` file within in the `project-root` folder, it would match all three of the `*Spec` files. + +#### Configurations + +In your `.eslintrc`: + +```json +{ + "rules": { + "quotes": [ 2, "double" ] + }, + + "overrides": [ + { + "files": [ "lib/*.jsx" ], + "config": { + "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 03fda87c973c..5d6c85064313 100644 --- a/lib/config.js +++ b/lib/config.js @@ -16,7 +16,8 @@ const path = require("path"), FileFinder = require("./file-finder"), userHome = require("user-home"), isResolvable = require("is-resolvable"), - pathIsInside = require("path-is-inside"); + pathIsInside = require("path-is-inside"), + minimatch = require("minimatch"); const debug = require("debug")("eslint:config"); @@ -25,6 +26,8 @@ const debug = require("debug")("eslint:config"); //------------------------------------------------------------------------------ const PERSONAL_CONFIG_DIR = userHome || null; +const VECTOR_SEP = ","; +const SUBCONFIG_SEP = ":"; //------------------------------------------------------------------------------ // Helpers @@ -51,7 +54,6 @@ function loadConfig(configToLoad) { filePath = ""; if (configToLoad) { - if (isObject(configToLoad)) { config = configToLoad; @@ -61,6 +63,10 @@ function loadConfig(configToLoad) { } else { filePath = configToLoad; config = ConfigFile.load(filePath); + if (config) { + config.filePath = filePath; + config.baseDirectory = path.dirname(filePath); + } } } @@ -68,110 +74,33 @@ function loadConfig(configToLoad) { return config; } -/** - * Get personal config object from ~/.eslintrc. - * @returns {Object} the personal config object (null if there is no personal config) - * @private - */ -function getPersonalConfig() { - let config; - - if (PERSONAL_CONFIG_DIR) { - const filename = ConfigFile.getFilenameForDirectory(PERSONAL_CONFIG_DIR); - - if (filename) { - debug("Using personal config"); - config = loadConfig(filename); - } - } - - return config || null; -} - /** * Determine if 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 {Object} 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. + * Helper to run iterative glob matching. + * @param {string|string[]} filePaths The file paths to test patterns against + * @param {string|string[]} patterns Glob patterns, match against results of previous pattern + * @returns {string[]|null} The matching filePaths, null otherwise. + * @private */ -function getLocalConfig(thisConfig, directory) { - const localConfigFiles = thisConfig.findLocalConfigFiles(directory), - numFiles = localConfigFiles.length, - projectConfigPath = ConfigFile.getFilenameForDirectory(thisConfig.options.cwd); - let found, - config = {}, - rootPath; - - for (let i = 0; i < numFiles; i++) { - - const localConfigFile = localConfigFiles[i]; - - // 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; - } - - // If root flag is set, don't consider file if it is above root - if (rootPath && !pathIsInside(path.dirname(localConfigFile), rootPath)) { - continue; +function matchFiles(filePaths, patterns) { + filePaths = [].concat(filePaths); + patterns = [].concat(patterns); + for (const pattern of patterns) { + filePaths = minimatch.match(filePaths, pattern, { matchBase: true }); + if (!filePaths.length) { + return null; } - - debug(`Loading ${localConfigFile}`); - const localConfig = loadConfig(localConfigFile); - - // Don't consider a local config file found if the config is null - if (!localConfig) { - continue; - } - - // Check for root flag - if (localConfig.root === true) { - rootPath = path.dirname(localConfigFile); - } - - found = true; - debug(`Using ${localConfigFile}`); - config = ConfigOps.merge(localConfig, config); } - - 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(); - - 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; + return filePaths; } //------------------------------------------------------------------------------ @@ -184,24 +113,32 @@ function getLocalConfig(thisConfig, directory) { class Config { /** - * Config options * @param {Object} options Options to be passed in */ constructor(options) { options = options || {}; + this.options = options; this.ignore = options.ignore; this.ignorePath = options.ignorePath; this.cache = {}; + this.mergedCache = {}; + this.vectorConfigCache = {}; + this.fileHierarchyCache = {}; + this.localHierarchyCache = {}; this.parser = options.parser; this.parserOptions = options.parserOptions || {}; - this.baseConfig = options.baseConfig ? loadConfig(options.baseConfig) : { rules: {} }; + this.baseConfig = options.baseConfig ? ConfigOps.merge({}, this.loadConfig(options.baseConfig)) : { rules: {} }; + this.baseConfig.filePath = ""; + this.baseConfig.baseDirectory = this.options.cwd; + this.cache[this.baseConfig.filePath] = this.baseConfig; + this.vectorConfigCache[this.baseConfig.filePath] = this.baseConfig; this.useEslintrc = (options.useEslintrc !== false); this.env = (options.envs || []).reduce((envs, name) => { - envs[ name ] = true; + envs[name] = true; return envs; }, {}); @@ -212,110 +149,307 @@ 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; }, {}); - const useConfig = options.configFile; - - this.options = options; + let useConfig = options.configFile; if (useConfig) { debug(`Using command line config ${useConfig}`); - if (isResolvable(useConfig) || isResolvable(`eslint-config-${useConfig}`) || useConfig.charAt(0) === "@") { - this.useSpecificConfig = loadConfig(useConfig); - } else { - this.useSpecificConfig = loadConfig(path.resolve(this.options.cwd, useConfig)); + if (!(isResolvable(useConfig) || isResolvable(`eslint-config-${useConfig}`) || useConfig.charAt(0) === "@")) { + useConfig = path.resolve(this.options.cwd, useConfig); } + this.useSpecificConfig = this.loadConfig(useConfig); + } + + if (this.options.plugins) { + Plugins.loadAll(this.options.plugins); } + + // 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(function(configKey) { + const value = cliConfigOptions[configKey]; + + if (value) { + this.cliConfig[configKey] = value; + } + }, 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 + * Cached version of loadConfig helper that caches when a path is passed. + * @param {string|Object} configToLoad the path to the JSON config file or the config object itself. + * @returns {Object} the parsed config object loaded from cache (empty object if there was a parse error) + * @private */ - getConfig(filePath) { - const directory = filePath ? path.dirname(filePath) : this.options.cwd; - let config, - userConfig; + loadConfig(configToLoad) { + if (typeof configToLoad !== "string") { + return loadConfig(configToLoad); + } + + const filePath = configToLoad; + let config = this.cache[filePath]; - debug(`Constructing config for ${filePath ? filePath : "text"}`); + if (!config) { + config = this.cache[filePath] = loadConfig(filePath); + } + return config; + } + + /** + * Get personal config object from user's home directory + * @returns {Object} the personal config object (null if there is no personal config) + * @private + */ + getPersonalConfig() { + if (typeof this.personalConfig === "undefined") { + let config; - config = this.cache[directory]; + if (PERSONAL_CONFIG_DIR) { + const filename = ConfigFile.getFilenameForDirectory(PERSONAL_CONFIG_DIR); - if (config) { - debug("Using config from cache"); - return config; + if (filename) { + debug("Using personal config"); + config = this.loadConfig(filename); + } + } + this.personalConfig = config || null; } - // Step 1: Determine user-specified config from .eslintrc.* and package.json files + return this.personalConfig; + } + + /** + * Build a config hierarchy including the base config (conf/eslint.json), the + * environments config (conf/environments.js) and eventually the user config. + * @param {string} directory a file in whose directory we start looking for a local config + * @returns {Object[]} The config objects + * @private + */ + getConfigHierarchy(directory) { + let configs; + + debug(`Constructing config file hierarchy for ${directory}`); + + // Step 1: Always include baseConfig + 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.useSpecificConfig) { + debug("Using command line config file"); + configs.push(this.useSpecificConfig); + } - // 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"); + /** + * Get the local config hierarchy for a given directory. + * @param {string} directory The directory to start looking in for a local config file. + * @returns {Object[]} The shallow local config objects, or an empty array if there are no local config. + * @private + */ + getLocalConfigHierarchy(directory) { + const localConfigFiles = this.findLocalConfigFiles(directory), + projectConfigPath = ConfigFile.getFilenameForDirectory(this.options.cwd), + searched = [], + configs = []; + let rootPath, + cache; + + for (const localConfigFile of localConfigFiles) { + const localConfigDirectory = path.dirname(localConfigFile); + + cache = this.localHierarchyCache[localConfigDirectory]; + if (cache) { + break; + } - config = ConfigOps.merge(config, this.useSpecificConfig); - } + // 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; + } + + // If root flag is set, don't consider file if it is above root + if (rootPath && !pathIsInside(path.dirname(localConfigFile), rootPath)) { + continue; + } + + debug(`Loading ${localConfigFile}`); + const localConfig = this.loadConfig(localConfigFile); - // Step 5: Merge in command line environments - debug("Merging command line environment settings"); - config = ConfigOps.merge(config, { env: this.env }); + // Ignore empty config files + if (!localConfig) { + continue; + } + + // Check for root flag + if (localConfig.root === true) { + rootPath = path.dirname(localConfigFile); + } - // Step 6: Merge in command line rules - if (this.options.rules) { - debug("Merging command line rules"); - config = ConfigOps.merge(config, { rules: this.options.rules }); + debug(`Using ${localConfigFile}`); + configs.push(localConfig); + searched.push(localConfigDirectory); } - // Step 7: Merge in command line globals - config = ConfigOps.merge(config, { globals: this.globals }); - // 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 - }); + if (!configs.length && !cache && !this.useSpecificConfig) { + + // 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; + } } - if (this.parserOptions) { - config = ConfigOps.merge(config, { - parserOptions: this.parserOptions + // Merged with any cached portion + configs.reverse(); + cache = cache ? cache.concat(configs) : configs; + + // Set the caches for the parent directories + searched.forEach((localConfigDirectory, i) => { + const subCache = cache.slice(0, cache.length - i); + + this.localHierarchyCache[localConfigDirectory] = subCache; + }); + + return cache; + } + + /** + * Get the vector of applicable configs from the hierarchy for a given file (glob matching occurs here). + * @param {string} filePath The file path for which to build the hierarchy and config vector. + * @returns {Array} array of config file paths or nested override indices + * @private + */ + getConfigVector(filePath) { + const directory = filePath ? path.dirname(filePath) : this.options.cwd, + vector = []; + + this.getConfigHierarchy(directory).forEach(config => { + const overrides = config.overrides; + + vector.push(config.filePath); + + if (!overrides) { + return; + } + + const relativePath = (filePath || directory).substr(config.baseDirectory.length + 1); + + overrides.forEach((override, i) => { + if (matchFiles(relativePath, override.files)) { + vector.push(i); + } }); - } + }); - // Step 8: Merge in command line plugins - if (this.options.plugins) { - debug("Merging command line plugins"); - Plugins.loadAll(this.options.plugins); - config = ConfigOps.merge(config, { plugins: this.options.plugins }); + return vector; + } + + /** + * Merges all configurations for a given config vector + * @param {Array} vector array of config file paths or relative override indices + * @returns {Object} config object + * @private + */ + getConfigFromVector(vector) { + + // Extract matching configs + let hash = vector.join(VECTOR_SEP), + config, + subConfigs, + nearestCacheIndex = vector.length - 1; + + for (; nearestCacheIndex >= 0; nearestCacheIndex--) { + config = this.vectorConfigCache[hash]; + if (config) { + break; + } + hash = hash.substr(0, hash.length - String(vector[nearestCacheIndex]).length - 1); } - // Step 9: Apply environments to the config if present - if (config.env) { - config = ConfigOps.applyEnvironments(config); + if (config) { + if (nearestCacheIndex === vector.length - 1) { + return config; + } + debug("Using config from partial cache"); + + // Get parent config if configKey is an override index + if (typeof vector[nearestCacheIndex + 1] === "number") { + + // If the first non-cached vector is an override index, subConfigs needs to be set + let parentConfigKey = vector[nearestCacheIndex]; + + for (let i = nearestCacheIndex - 1; i >= 0 && typeof parentConfigKey !== "string"; i--) { + parentConfigKey = vector[i]; + } + subConfigs = this.cache[parentConfigKey].overrides; + } + hash += VECTOR_SEP; + } else { + config = {}; } - this.cache[directory] = config; + // Start from index of nearest cached config + for (let i = nearestCacheIndex + 1; i < vector.length; i++) { + const configKey = vector[i]; + let shallowConfig; + + hash += configKey; + if (typeof configKey === "string") { + shallowConfig = this.cache[configKey]; + subConfigs = shallowConfig.overrides; + } else { + shallowConfig = subConfigs[configKey]; + } + config = ConfigOps.merge(config, shallowConfig); + if (config.filePath) { + delete config.filePath; + delete config.baseDirectory; + } else if (config.files) { + delete config.files; + } + this.vectorConfigCache[hash] = config; + hash += VECTOR_SEP; + } return config; } @@ -324,15 +458,55 @@ class Config { * Find local config files from directory and parent directories. * @param {string} directory The directory to start searching from. * @returns {string[]} The paths of local config files found. + * @private */ findLocalConfigFiles(directory) { - if (!this.localConfigFinder) { this.localConfigFinder = new FileFinder(ConfigFile.CONFIG_FILES, this.options.cwd); } return this.localConfigFinder.findAllInDirectoryAndParents(directory); } + + /** + * Build a config object merging the base config (conf/eslint.json), 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 + */ + getConfig(filePath) { + const vector = this.getConfigVector(filePath); + const hash = vector.join(VECTOR_SEP); + let config = this.mergedCache[hash]; + + if (config) { + debug("Using config from cache"); + return config; + } + + // Step 1: Merge in the file configurations (base, local, personal & cli-specified) + config = this.getConfigFromVector(vector); + + // Step 2: Merge in command line configurations + config = ConfigOps.merge(config, this.cliConfig); + + // 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.mergedCache[hash] = config; + + return config; + } } module.exports = Config; diff --git a/lib/config/config-ops.js b/lib/config/config-ops.js index 52dea1a106df..f357926269b9 100644 --- a/lib/config/config-ops.js +++ b/lib/config/config-ops.js @@ -174,7 +174,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", isRule); } else if (typeof src[key] !== "object" || !src[key] || key === "exported" || key === "astGlobals") { dst[key] = src[key]; diff --git a/package.json b/package.json index 11d4948d4800..a944db1a39a6 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,10 @@ "json-stable-stringify": "^1.0.0", "levn": "^0.3.0", "lodash": "^4.0.0", + "lodash.clonedeep": "^3.0.1", + "lodash.merge": "^3.3.2", + "lodash.omit": "^3.1.0", + "minimatch": "^3.0.0", "mkdirp": "^0.5.0", "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 9ebe7de30878..886dacc321d6 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 },\n \"overrides\": [\n {\n \"files\": \"foo.js\",\n \"rules\": {\n \"quotes\": [2, \"single\"]\n }\n },\n {\n \"files\": \"bar.js\",\n \"rules\": {\n \"quotes\": [2, \"double\"]\n }\n },\n {\n \"files\": \"../file-structures.json\",\n \"rules\": {\n \"quotes\": [2, \"single\"]\n }\n },\n {\n \"files\": \"**/*one.js\",\n \"rules\": {\n \"quotes\": [2, \"single\"]\n }\n },\n {\n \"files\": \"two/child-two.js\",\n rules: {\n quotes: [2, \"single\"],\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/fixtures/config-hierarchy/overrides/.eslintrc b/tests/fixtures/config-hierarchy/overrides/.eslintrc new file mode 100644 index 000000000000..5baf0f51e33c --- /dev/null +++ b/tests/fixtures/config-hierarchy/overrides/.eslintrc @@ -0,0 +1,38 @@ +{ + "rules": { + "quotes": [2, "double"] + }, + "overrides": [ + { + "files": "foo.js", + "rules": { + "quotes": [2, "single"] + } + }, + { + "files": "bar.js", + "rules": { + "quotes": [2, "double"] + } + }, + { + "files": "../file-structures.json", + "rules": { + "quotes": [2, "single"] + } + }, + { + "files": "**/*one.js", + "rules": { + "quotes": [2, "single"] + } + }, + { + "files": "two/child-two.js", + rules: { + quotes: [2, "single"], + "no-console": 1 + } + } + ] +} diff --git a/tests/fixtures/config-hierarchy/overrides/bar.js b/tests/fixtures/config-hierarchy/overrides/bar.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/fixtures/config-hierarchy/overrides/foo.js b/tests/fixtures/config-hierarchy/overrides/foo.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/fixtures/config-hierarchy/overrides/one/child-one.js b/tests/fixtures/config-hierarchy/overrides/one/child-one.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/fixtures/config-hierarchy/overrides/two/.eslintrc b/tests/fixtures/config-hierarchy/overrides/two/.eslintrc new file mode 100644 index 000000000000..ad8c8031fa49 --- /dev/null +++ b/tests/fixtures/config-hierarchy/overrides/two/.eslintrc @@ -0,0 +1,13 @@ +{ + "rules": { + "semi": [2, "never"] + }, + "overrides": [ + { + "files": "child-two.js", + "rules": { + "no-console": 0 + } + } + ] +} diff --git a/tests/fixtures/config-hierarchy/overrides/two/child-two.js b/tests/fixtures/config-hierarchy/overrides/two/child-two.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/lib/config.js b/tests/lib/config.js index fbf65d67f62d..45dc8a1908be 100644 --- a/tests/lib/config.js +++ b/tests/lib/config.js @@ -1058,6 +1058,178 @@ describe("Config", () => { }, "No ESLint configuration found"); }); }); + + + describe("with overrides", () => { + + /** + * Returns the path inside of the fixture directory. + * @returns {string} The path inside the fixture directory. + * @private + */ + function getFakeFixturePath() { + const args = Array.from(arguments); + + args.unshift("config-hierarchy"); + args.unshift("fixtures"); + args.unshift("eslint"); + args.unshift(process.cwd()); + return path.join.apply(path, args); + } + + 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() }); + const targetPath = getFakeFixturePath("overrides", "foo.js"); + const expected = { + rules: { + quotes: [2, "single"] + } + }; + 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() }); + const targetPath = getFakeFixturePath("overrides", "child", "child-one.js"); + const expected = { + rules: { + quotes: [2, "single"] + } + }; + + 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() }); + const targetPath = getFakeFixturePath("overrides", "child", "child-one.js"); + const expected = { + rules: { + quotes: [2, "single"] + } + }; + 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: [2, "single"] + } + }], + useEslintrc: false + } + }); + const expected = { + rules: { + quotes: [2, "double"] + } + }; + const actual = config.getConfig(targetPath); + + assertConfigsEqual(actual, expected); + }); + + it("should merge all local configs (override and non-override) before non-local configs", () => { + const config = new Config({ cwd: process.cwd() }); + const targetPath = getFakeFixturePath("overrides", "two", "child-two.js"); + const expected = { + rules: { + semi: [2, "never"], + "no-console": 0, + quotes: [2, "single"] + } + }; + const actual = config.getConfig(targetPath); + + assertConfigsEqual(actual, expected); + }); + + it("should support a compounding array of patterns", () => { + 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 + }); + 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 + }); + 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-ops.js b/tests/lib/config/config-ops.js index 6cd32884ae1d..efdf4df38b3a 100644 --- a/tests/lib/config/config-ops.js +++ b/tests/lib/config/config-ops.js @@ -476,7 +476,50 @@ 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); + }); + }); }); describe("normalize()", () => {