596 lines
19 KiB
JavaScript
596 lines
19 KiB
JavaScript
|
/**
|
||
|
* @fileoverview Helper to locate and load configuration files.
|
||
|
* @author Nicholas C. Zakas
|
||
|
*/
|
||
|
|
||
|
"use strict";
|
||
|
|
||
|
//------------------------------------------------------------------------------
|
||
|
// Requirements
|
||
|
//------------------------------------------------------------------------------
|
||
|
|
||
|
const fs = require("fs"),
|
||
|
path = require("path"),
|
||
|
ConfigOps = require("./config-ops"),
|
||
|
validator = require("./config-validator"),
|
||
|
ModuleResolver = require("../util/module-resolver"),
|
||
|
naming = require("../util/naming"),
|
||
|
pathIsInside = require("path-is-inside"),
|
||
|
stripComments = require("strip-json-comments"),
|
||
|
stringify = require("json-stable-stringify-without-jsonify"),
|
||
|
requireUncached = require("require-uncached");
|
||
|
|
||
|
const debug = require("debug")("eslint:config-file");
|
||
|
|
||
|
//------------------------------------------------------------------------------
|
||
|
// Helpers
|
||
|
//------------------------------------------------------------------------------
|
||
|
|
||
|
/**
|
||
|
* Determines sort order for object keys for json-stable-stringify
|
||
|
*
|
||
|
* see: https://github.com/samn/json-stable-stringify#cmp
|
||
|
*
|
||
|
* @param {Object} a The first comparison object ({key: akey, value: avalue})
|
||
|
* @param {Object} b The second comparison object ({key: bkey, value: bvalue})
|
||
|
* @returns {number} 1 or -1, used in stringify cmp method
|
||
|
*/
|
||
|
function sortByKey(a, b) {
|
||
|
return a.key > b.key ? 1 : -1;
|
||
|
}
|
||
|
|
||
|
//------------------------------------------------------------------------------
|
||
|
// Private
|
||
|
//------------------------------------------------------------------------------
|
||
|
|
||
|
const CONFIG_FILES = [
|
||
|
".eslintrc.js",
|
||
|
".eslintrc.yaml",
|
||
|
".eslintrc.yml",
|
||
|
".eslintrc.json",
|
||
|
".eslintrc",
|
||
|
"package.json"
|
||
|
];
|
||
|
|
||
|
const resolver = new ModuleResolver();
|
||
|
|
||
|
/**
|
||
|
* Convenience wrapper for synchronously reading file contents.
|
||
|
* @param {string} filePath The filename to read.
|
||
|
* @returns {string} The file contents, with the BOM removed.
|
||
|
* @private
|
||
|
*/
|
||
|
function readFile(filePath) {
|
||
|
return fs.readFileSync(filePath, "utf8").replace(/^\ufeff/, "");
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines if a given string represents a filepath or not using the same
|
||
|
* conventions as require(), meaning that the first character must be nonalphanumeric
|
||
|
* and not the @ sign which is used for scoped packages to be considered a file path.
|
||
|
* @param {string} filePath The string to check.
|
||
|
* @returns {boolean} True if it's a filepath, false if not.
|
||
|
* @private
|
||
|
*/
|
||
|
function isFilePath(filePath) {
|
||
|
return path.isAbsolute(filePath) || !/\w|@/.test(filePath.charAt(0));
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Loads a YAML configuration from a file.
|
||
|
* @param {string} filePath The filename to load.
|
||
|
* @returns {Object} The configuration object from the file.
|
||
|
* @throws {Error} If the file cannot be read.
|
||
|
* @private
|
||
|
*/
|
||
|
function loadYAMLConfigFile(filePath) {
|
||
|
debug(`Loading YAML config file: ${filePath}`);
|
||
|
|
||
|
// lazy load YAML to improve performance when not used
|
||
|
const yaml = require("js-yaml");
|
||
|
|
||
|
try {
|
||
|
|
||
|
// empty YAML file can be null, so always use
|
||
|
return yaml.safeLoad(readFile(filePath)) || {};
|
||
|
} catch (e) {
|
||
|
debug(`Error reading YAML file: ${filePath}`);
|
||
|
e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
|
||
|
throw e;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Loads a JSON configuration from a file.
|
||
|
* @param {string} filePath The filename to load.
|
||
|
* @returns {Object} The configuration object from the file.
|
||
|
* @throws {Error} If the file cannot be read.
|
||
|
* @private
|
||
|
*/
|
||
|
function loadJSONConfigFile(filePath) {
|
||
|
debug(`Loading JSON config file: ${filePath}`);
|
||
|
|
||
|
try {
|
||
|
return JSON.parse(stripComments(readFile(filePath)));
|
||
|
} catch (e) {
|
||
|
debug(`Error reading JSON file: ${filePath}`);
|
||
|
e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
|
||
|
throw e;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Loads a legacy (.eslintrc) configuration from a file.
|
||
|
* @param {string} filePath The filename to load.
|
||
|
* @returns {Object} The configuration object from the file.
|
||
|
* @throws {Error} If the file cannot be read.
|
||
|
* @private
|
||
|
*/
|
||
|
function loadLegacyConfigFile(filePath) {
|
||
|
debug(`Loading config file: ${filePath}`);
|
||
|
|
||
|
// lazy load YAML to improve performance when not used
|
||
|
const yaml = require("js-yaml");
|
||
|
|
||
|
try {
|
||
|
return yaml.safeLoad(stripComments(readFile(filePath))) || /* istanbul ignore next */ {};
|
||
|
} catch (e) {
|
||
|
debug(`Error reading YAML file: ${filePath}`);
|
||
|
e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
|
||
|
throw e;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Loads a JavaScript configuration from a file.
|
||
|
* @param {string} filePath The filename to load.
|
||
|
* @returns {Object} The configuration object from the file.
|
||
|
* @throws {Error} If the file cannot be read.
|
||
|
* @private
|
||
|
*/
|
||
|
function loadJSConfigFile(filePath) {
|
||
|
debug(`Loading JS config file: ${filePath}`);
|
||
|
try {
|
||
|
return requireUncached(filePath);
|
||
|
} catch (e) {
|
||
|
debug(`Error reading JavaScript file: ${filePath}`);
|
||
|
e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
|
||
|
throw e;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Loads a configuration from a package.json file.
|
||
|
* @param {string} filePath The filename to load.
|
||
|
* @returns {Object} The configuration object from the file.
|
||
|
* @throws {Error} If the file cannot be read.
|
||
|
* @private
|
||
|
*/
|
||
|
function loadPackageJSONConfigFile(filePath) {
|
||
|
debug(`Loading package.json config file: ${filePath}`);
|
||
|
try {
|
||
|
return loadJSONConfigFile(filePath).eslintConfig || null;
|
||
|
} catch (e) {
|
||
|
debug(`Error reading package.json file: ${filePath}`);
|
||
|
e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
|
||
|
throw e;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Creates an error to notify about a missing config to extend from.
|
||
|
* @param {string} configName The name of the missing config.
|
||
|
* @returns {Error} The error object to throw
|
||
|
* @private
|
||
|
*/
|
||
|
function configMissingError(configName) {
|
||
|
const error = new Error(`Failed to load config "${configName}" to extend from.`);
|
||
|
|
||
|
error.messageTemplate = "extend-config-missing";
|
||
|
error.messageData = {
|
||
|
configName
|
||
|
};
|
||
|
return error;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Loads a configuration file regardless of the source. Inspects the file path
|
||
|
* to determine the correctly way to load the config file.
|
||
|
* @param {Object} file The path to the configuration.
|
||
|
* @returns {Object} The configuration information.
|
||
|
* @private
|
||
|
*/
|
||
|
function loadConfigFile(file) {
|
||
|
const filePath = file.filePath;
|
||
|
let config;
|
||
|
|
||
|
switch (path.extname(filePath)) {
|
||
|
case ".js":
|
||
|
config = loadJSConfigFile(filePath);
|
||
|
if (file.configName) {
|
||
|
config = config.configs[file.configName];
|
||
|
if (!config) {
|
||
|
throw configMissingError(file.configFullName);
|
||
|
}
|
||
|
}
|
||
|
break;
|
||
|
|
||
|
case ".json":
|
||
|
if (path.basename(filePath) === "package.json") {
|
||
|
config = loadPackageJSONConfigFile(filePath);
|
||
|
if (config === null) {
|
||
|
return null;
|
||
|
}
|
||
|
} else {
|
||
|
config = loadJSONConfigFile(filePath);
|
||
|
}
|
||
|
break;
|
||
|
|
||
|
case ".yaml":
|
||
|
case ".yml":
|
||
|
config = loadYAMLConfigFile(filePath);
|
||
|
break;
|
||
|
|
||
|
default:
|
||
|
config = loadLegacyConfigFile(filePath);
|
||
|
}
|
||
|
|
||
|
return ConfigOps.merge(ConfigOps.createEmptyConfig(), config);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Writes a configuration file in JSON format.
|
||
|
* @param {Object} config The configuration object to write.
|
||
|
* @param {string} filePath The filename to write to.
|
||
|
* @returns {void}
|
||
|
* @private
|
||
|
*/
|
||
|
function writeJSONConfigFile(config, filePath) {
|
||
|
debug(`Writing JSON config file: ${filePath}`);
|
||
|
|
||
|
const content = stringify(config, { cmp: sortByKey, space: 4 });
|
||
|
|
||
|
fs.writeFileSync(filePath, content, "utf8");
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Writes a configuration file in YAML format.
|
||
|
* @param {Object} config The configuration object to write.
|
||
|
* @param {string} filePath The filename to write to.
|
||
|
* @returns {void}
|
||
|
* @private
|
||
|
*/
|
||
|
function writeYAMLConfigFile(config, filePath) {
|
||
|
debug(`Writing YAML config file: ${filePath}`);
|
||
|
|
||
|
// lazy load YAML to improve performance when not used
|
||
|
const yaml = require("js-yaml");
|
||
|
|
||
|
const content = yaml.safeDump(config, { sortKeys: true });
|
||
|
|
||
|
fs.writeFileSync(filePath, content, "utf8");
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Writes a configuration file in JavaScript format.
|
||
|
* @param {Object} config The configuration object to write.
|
||
|
* @param {string} filePath The filename to write to.
|
||
|
* @returns {void}
|
||
|
* @private
|
||
|
*/
|
||
|
function writeJSConfigFile(config, filePath) {
|
||
|
debug(`Writing JS config file: ${filePath}`);
|
||
|
|
||
|
const content = `module.exports = ${stringify(config, { cmp: sortByKey, space: 4 })};`;
|
||
|
|
||
|
fs.writeFileSync(filePath, content, "utf8");
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Writes a configuration file.
|
||
|
* @param {Object} config The configuration object to write.
|
||
|
* @param {string} filePath The filename to write to.
|
||
|
* @returns {void}
|
||
|
* @throws {Error} When an unknown file type is specified.
|
||
|
* @private
|
||
|
*/
|
||
|
function write(config, filePath) {
|
||
|
switch (path.extname(filePath)) {
|
||
|
case ".js":
|
||
|
writeJSConfigFile(config, filePath);
|
||
|
break;
|
||
|
|
||
|
case ".json":
|
||
|
writeJSONConfigFile(config, filePath);
|
||
|
break;
|
||
|
|
||
|
case ".yaml":
|
||
|
case ".yml":
|
||
|
writeYAMLConfigFile(config, filePath);
|
||
|
break;
|
||
|
|
||
|
default:
|
||
|
throw new Error("Can't write to unknown file type.");
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines the base directory for node packages referenced in a config file.
|
||
|
* This does not include node_modules in the path so it can be used for all
|
||
|
* references relative to a config file.
|
||
|
* @param {string} configFilePath The config file referencing the file.
|
||
|
* @returns {string} The base directory for the file path.
|
||
|
* @private
|
||
|
*/
|
||
|
function getBaseDir(configFilePath) {
|
||
|
|
||
|
// calculates the path of the project including ESLint as dependency
|
||
|
const projectPath = path.resolve(__dirname, "../../../");
|
||
|
|
||
|
if (configFilePath && pathIsInside(configFilePath, projectPath)) {
|
||
|
|
||
|
// be careful of https://github.com/substack/node-resolve/issues/78
|
||
|
return path.join(path.resolve(configFilePath));
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* default to ESLint project path since it's unlikely that plugins will be
|
||
|
* in this directory
|
||
|
*/
|
||
|
return path.join(projectPath);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines the lookup path, including node_modules, for package
|
||
|
* references relative to a config file.
|
||
|
* @param {string} configFilePath The config file referencing the file.
|
||
|
* @returns {string} The lookup path for the file path.
|
||
|
* @private
|
||
|
*/
|
||
|
function getLookupPath(configFilePath) {
|
||
|
const basedir = getBaseDir(configFilePath);
|
||
|
|
||
|
return path.join(basedir, "node_modules");
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Resolves a eslint core config path
|
||
|
* @param {string} name The eslint config name.
|
||
|
* @returns {string} The resolved path of the config.
|
||
|
* @private
|
||
|
*/
|
||
|
function getEslintCoreConfigPath(name) {
|
||
|
if (name === "eslint:recommended") {
|
||
|
|
||
|
/*
|
||
|
* Add an explicit substitution for eslint:recommended to
|
||
|
* conf/eslint-recommended.js.
|
||
|
*/
|
||
|
return path.resolve(__dirname, "../../conf/eslint-recommended.js");
|
||
|
}
|
||
|
|
||
|
if (name === "eslint:all") {
|
||
|
|
||
|
/*
|
||
|
* Add an explicit substitution for eslint:all to conf/eslint-all.js
|
||
|
*/
|
||
|
return path.resolve(__dirname, "../../conf/eslint-all.js");
|
||
|
}
|
||
|
|
||
|
throw configMissingError(name);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Applies values from the "extends" field in a configuration file.
|
||
|
* @param {Object} config The configuration information.
|
||
|
* @param {Config} configContext Plugin context for the config instance
|
||
|
* @param {string} filePath The file path from which the configuration information
|
||
|
* was loaded.
|
||
|
* @param {string} [relativeTo] The path to resolve relative to.
|
||
|
* @returns {Object} A new configuration object with all of the "extends" fields
|
||
|
* loaded and merged.
|
||
|
* @private
|
||
|
*/
|
||
|
function applyExtends(config, configContext, filePath, relativeTo) {
|
||
|
let configExtends = config.extends;
|
||
|
|
||
|
// normalize into an array for easier handling
|
||
|
if (!Array.isArray(config.extends)) {
|
||
|
configExtends = [config.extends];
|
||
|
}
|
||
|
|
||
|
// Make the last element in an array take the highest precedence
|
||
|
config = configExtends.reduceRight((previousValue, parentPath) => {
|
||
|
try {
|
||
|
if (parentPath.startsWith("eslint:")) {
|
||
|
parentPath = getEslintCoreConfigPath(parentPath);
|
||
|
} else if (isFilePath(parentPath)) {
|
||
|
|
||
|
/*
|
||
|
* If the `extends` path is relative, use the directory of the current configuration
|
||
|
* file as the reference point. Otherwise, use as-is.
|
||
|
*/
|
||
|
parentPath = (path.isAbsolute(parentPath)
|
||
|
? parentPath
|
||
|
: path.join(relativeTo || path.dirname(filePath), parentPath)
|
||
|
);
|
||
|
}
|
||
|
debug(`Loading ${parentPath}`);
|
||
|
|
||
|
// eslint-disable-next-line no-use-before-define
|
||
|
return ConfigOps.merge(load(parentPath, configContext, relativeTo), previousValue);
|
||
|
} catch (e) {
|
||
|
|
||
|
/*
|
||
|
* If the file referenced by `extends` failed to load, add the path
|
||
|
* to the configuration file that referenced it to the error
|
||
|
* message so the user is able to see where it was referenced from,
|
||
|
* then re-throw.
|
||
|
*/
|
||
|
e.message += `\nReferenced from: ${filePath}`;
|
||
|
throw e;
|
||
|
}
|
||
|
|
||
|
}, config);
|
||
|
|
||
|
return config;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Resolves a configuration file path into the fully-formed path, whether filename
|
||
|
* or package name.
|
||
|
* @param {string} filePath The filepath to resolve.
|
||
|
* @param {string} [relativeTo] The path to resolve relative to.
|
||
|
* @returns {Object} An object containing 3 properties:
|
||
|
* - 'filePath' (required) the resolved path that can be used directly to load the configuration.
|
||
|
* - 'configName' the name of the configuration inside the plugin.
|
||
|
* - 'configFullName' (required) the name of the configuration as used in the eslint config(e.g. 'plugin:node/recommended'),
|
||
|
* or the absolute path to a config file. This should uniquely identify a config.
|
||
|
* @private
|
||
|
*/
|
||
|
function resolve(filePath, relativeTo) {
|
||
|
if (isFilePath(filePath)) {
|
||
|
const fullPath = path.resolve(relativeTo || "", filePath);
|
||
|
|
||
|
return { filePath: fullPath, configFullName: fullPath };
|
||
|
}
|
||
|
let normalizedPackageName;
|
||
|
|
||
|
if (filePath.startsWith("plugin:")) {
|
||
|
const configFullName = filePath;
|
||
|
const pluginName = filePath.slice(7, filePath.lastIndexOf("/"));
|
||
|
const configName = filePath.slice(filePath.lastIndexOf("/") + 1);
|
||
|
|
||
|
normalizedPackageName = naming.normalizePackageName(pluginName, "eslint-plugin");
|
||
|
debug(`Attempting to resolve ${normalizedPackageName}`);
|
||
|
filePath = resolver.resolve(normalizedPackageName, getLookupPath(relativeTo));
|
||
|
return { filePath, configName, configFullName };
|
||
|
}
|
||
|
normalizedPackageName = naming.normalizePackageName(filePath, "eslint-config");
|
||
|
debug(`Attempting to resolve ${normalizedPackageName}`);
|
||
|
filePath = resolver.resolve(normalizedPackageName, getLookupPath(relativeTo));
|
||
|
return { filePath, configFullName: filePath };
|
||
|
|
||
|
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Loads a configuration file from the given file path.
|
||
|
* @param {Object} resolvedPath The value from calling resolve() on a filename or package name.
|
||
|
* @param {Config} configContext Plugins context
|
||
|
* @returns {Object} The configuration information.
|
||
|
*/
|
||
|
function loadFromDisk(resolvedPath, configContext) {
|
||
|
const dirname = path.dirname(resolvedPath.filePath),
|
||
|
lookupPath = getLookupPath(dirname);
|
||
|
let config = loadConfigFile(resolvedPath);
|
||
|
|
||
|
if (config) {
|
||
|
|
||
|
// ensure plugins are properly loaded first
|
||
|
if (config.plugins) {
|
||
|
configContext.plugins.loadAll(config.plugins);
|
||
|
}
|
||
|
|
||
|
// include full path of parser if present
|
||
|
if (config.parser) {
|
||
|
if (isFilePath(config.parser)) {
|
||
|
config.parser = path.resolve(dirname || "", config.parser);
|
||
|
} else {
|
||
|
config.parser = resolver.resolve(config.parser, lookupPath);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const ruleMap = configContext.linterContext.getRules();
|
||
|
|
||
|
// validate the configuration before continuing
|
||
|
validator.validate(config, resolvedPath.configFullName, ruleMap.get.bind(ruleMap), 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, resolvedPath.filePath, dirname);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return config;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Loads a config object, applying extends if present.
|
||
|
* @param {Object} configObject a config object to load
|
||
|
* @param {Config} configContext Context for the config instance
|
||
|
* @returns {Object} the config object with extends applied if present, or the passed config if not
|
||
|
* @private
|
||
|
*/
|
||
|
function loadObject(configObject, configContext) {
|
||
|
return configObject.extends ? applyExtends(configObject, configContext, "") : 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.configFullName);
|
||
|
|
||
|
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.configFullName, config);
|
||
|
}
|
||
|
|
||
|
return config;
|
||
|
}
|
||
|
|
||
|
|
||
|
//------------------------------------------------------------------------------
|
||
|
// Public Interface
|
||
|
//------------------------------------------------------------------------------
|
||
|
|
||
|
module.exports = {
|
||
|
|
||
|
getBaseDir,
|
||
|
getLookupPath,
|
||
|
load,
|
||
|
loadObject,
|
||
|
resolve,
|
||
|
write,
|
||
|
applyExtends,
|
||
|
CONFIG_FILES,
|
||
|
|
||
|
/**
|
||
|
* Retrieves the configuration filename for a given directory. It loops over all
|
||
|
* of the valid configuration filenames in order to find the first one that exists.
|
||
|
* @param {string} directory The directory to check for a config file.
|
||
|
* @returns {?string} The filename of the configuration file for the directory
|
||
|
* or null if there is no configuration file in the directory.
|
||
|
*/
|
||
|
getFilenameForDirectory(directory) {
|
||
|
for (let i = 0, len = CONFIG_FILES.length; i < len; i++) {
|
||
|
const filename = path.join(directory, CONFIG_FILES[i]);
|
||
|
|
||
|
if (fs.existsSync(filename) && fs.statSync(filename).isFile()) {
|
||
|
return filename;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
}
|
||
|
};
|