config-file.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. /**
  2. * @fileoverview Helper to locate and load configuration files.
  3. * @author Nicholas C. Zakas
  4. */
  5. "use strict";
  6. //------------------------------------------------------------------------------
  7. // Requirements
  8. //------------------------------------------------------------------------------
  9. const fs = require("fs"),
  10. path = require("path"),
  11. ConfigOps = require("./config-ops"),
  12. validator = require("./config-validator"),
  13. ModuleResolver = require("../util/module-resolver"),
  14. naming = require("../util/naming"),
  15. pathIsInside = require("path-is-inside"),
  16. stripComments = require("strip-json-comments"),
  17. stringify = require("json-stable-stringify-without-jsonify"),
  18. requireUncached = require("require-uncached");
  19. const debug = require("debug")("eslint:config-file");
  20. //------------------------------------------------------------------------------
  21. // Helpers
  22. //------------------------------------------------------------------------------
  23. /**
  24. * Determines sort order for object keys for json-stable-stringify
  25. *
  26. * see: https://github.com/samn/json-stable-stringify#cmp
  27. *
  28. * @param {Object} a The first comparison object ({key: akey, value: avalue})
  29. * @param {Object} b The second comparison object ({key: bkey, value: bvalue})
  30. * @returns {number} 1 or -1, used in stringify cmp method
  31. */
  32. function sortByKey(a, b) {
  33. return a.key > b.key ? 1 : -1;
  34. }
  35. //------------------------------------------------------------------------------
  36. // Private
  37. //------------------------------------------------------------------------------
  38. const CONFIG_FILES = [
  39. ".eslintrc.js",
  40. ".eslintrc.yaml",
  41. ".eslintrc.yml",
  42. ".eslintrc.json",
  43. ".eslintrc",
  44. "package.json"
  45. ];
  46. const resolver = new ModuleResolver();
  47. /**
  48. * Convenience wrapper for synchronously reading file contents.
  49. * @param {string} filePath The filename to read.
  50. * @returns {string} The file contents, with the BOM removed.
  51. * @private
  52. */
  53. function readFile(filePath) {
  54. return fs.readFileSync(filePath, "utf8").replace(/^\ufeff/, "");
  55. }
  56. /**
  57. * Determines if a given string represents a filepath or not using the same
  58. * conventions as require(), meaning that the first character must be nonalphanumeric
  59. * and not the @ sign which is used for scoped packages to be considered a file path.
  60. * @param {string} filePath The string to check.
  61. * @returns {boolean} True if it's a filepath, false if not.
  62. * @private
  63. */
  64. function isFilePath(filePath) {
  65. return path.isAbsolute(filePath) || !/\w|@/.test(filePath.charAt(0));
  66. }
  67. /**
  68. * Loads a YAML configuration from a file.
  69. * @param {string} filePath The filename to load.
  70. * @returns {Object} The configuration object from the file.
  71. * @throws {Error} If the file cannot be read.
  72. * @private
  73. */
  74. function loadYAMLConfigFile(filePath) {
  75. debug(`Loading YAML config file: ${filePath}`);
  76. // lazy load YAML to improve performance when not used
  77. const yaml = require("js-yaml");
  78. try {
  79. // empty YAML file can be null, so always use
  80. return yaml.safeLoad(readFile(filePath)) || {};
  81. } catch (e) {
  82. debug(`Error reading YAML file: ${filePath}`);
  83. e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
  84. throw e;
  85. }
  86. }
  87. /**
  88. * Loads a JSON configuration from a file.
  89. * @param {string} filePath The filename to load.
  90. * @returns {Object} The configuration object from the file.
  91. * @throws {Error} If the file cannot be read.
  92. * @private
  93. */
  94. function loadJSONConfigFile(filePath) {
  95. debug(`Loading JSON config file: ${filePath}`);
  96. try {
  97. return JSON.parse(stripComments(readFile(filePath)));
  98. } catch (e) {
  99. debug(`Error reading JSON file: ${filePath}`);
  100. e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
  101. throw e;
  102. }
  103. }
  104. /**
  105. * Loads a legacy (.eslintrc) configuration from a file.
  106. * @param {string} filePath The filename to load.
  107. * @returns {Object} The configuration object from the file.
  108. * @throws {Error} If the file cannot be read.
  109. * @private
  110. */
  111. function loadLegacyConfigFile(filePath) {
  112. debug(`Loading config file: ${filePath}`);
  113. // lazy load YAML to improve performance when not used
  114. const yaml = require("js-yaml");
  115. try {
  116. return yaml.safeLoad(stripComments(readFile(filePath))) || /* istanbul ignore next */ {};
  117. } catch (e) {
  118. debug(`Error reading YAML file: ${filePath}`);
  119. e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
  120. throw e;
  121. }
  122. }
  123. /**
  124. * Loads a JavaScript configuration from a file.
  125. * @param {string} filePath The filename to load.
  126. * @returns {Object} The configuration object from the file.
  127. * @throws {Error} If the file cannot be read.
  128. * @private
  129. */
  130. function loadJSConfigFile(filePath) {
  131. debug(`Loading JS config file: ${filePath}`);
  132. try {
  133. return requireUncached(filePath);
  134. } catch (e) {
  135. debug(`Error reading JavaScript file: ${filePath}`);
  136. e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
  137. throw e;
  138. }
  139. }
  140. /**
  141. * Loads a configuration from a package.json file.
  142. * @param {string} filePath The filename to load.
  143. * @returns {Object} The configuration object from the file.
  144. * @throws {Error} If the file cannot be read.
  145. * @private
  146. */
  147. function loadPackageJSONConfigFile(filePath) {
  148. debug(`Loading package.json config file: ${filePath}`);
  149. try {
  150. return loadJSONConfigFile(filePath).eslintConfig || null;
  151. } catch (e) {
  152. debug(`Error reading package.json file: ${filePath}`);
  153. e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
  154. throw e;
  155. }
  156. }
  157. /**
  158. * Creates an error to notify about a missing config to extend from.
  159. * @param {string} configName The name of the missing config.
  160. * @returns {Error} The error object to throw
  161. * @private
  162. */
  163. function configMissingError(configName) {
  164. const error = new Error(`Failed to load config "${configName}" to extend from.`);
  165. error.messageTemplate = "extend-config-missing";
  166. error.messageData = {
  167. configName
  168. };
  169. return error;
  170. }
  171. /**
  172. * Loads a configuration file regardless of the source. Inspects the file path
  173. * to determine the correctly way to load the config file.
  174. * @param {Object} file The path to the configuration.
  175. * @returns {Object} The configuration information.
  176. * @private
  177. */
  178. function loadConfigFile(file) {
  179. const filePath = file.filePath;
  180. let config;
  181. switch (path.extname(filePath)) {
  182. case ".js":
  183. config = loadJSConfigFile(filePath);
  184. if (file.configName) {
  185. config = config.configs[file.configName];
  186. if (!config) {
  187. throw configMissingError(file.configFullName);
  188. }
  189. }
  190. break;
  191. case ".json":
  192. if (path.basename(filePath) === "package.json") {
  193. config = loadPackageJSONConfigFile(filePath);
  194. if (config === null) {
  195. return null;
  196. }
  197. } else {
  198. config = loadJSONConfigFile(filePath);
  199. }
  200. break;
  201. case ".yaml":
  202. case ".yml":
  203. config = loadYAMLConfigFile(filePath);
  204. break;
  205. default:
  206. config = loadLegacyConfigFile(filePath);
  207. }
  208. return ConfigOps.merge(ConfigOps.createEmptyConfig(), config);
  209. }
  210. /**
  211. * Writes a configuration file in JSON format.
  212. * @param {Object} config The configuration object to write.
  213. * @param {string} filePath The filename to write to.
  214. * @returns {void}
  215. * @private
  216. */
  217. function writeJSONConfigFile(config, filePath) {
  218. debug(`Writing JSON config file: ${filePath}`);
  219. const content = stringify(config, { cmp: sortByKey, space: 4 });
  220. fs.writeFileSync(filePath, content, "utf8");
  221. }
  222. /**
  223. * Writes a configuration file in YAML format.
  224. * @param {Object} config The configuration object to write.
  225. * @param {string} filePath The filename to write to.
  226. * @returns {void}
  227. * @private
  228. */
  229. function writeYAMLConfigFile(config, filePath) {
  230. debug(`Writing YAML config file: ${filePath}`);
  231. // lazy load YAML to improve performance when not used
  232. const yaml = require("js-yaml");
  233. const content = yaml.safeDump(config, { sortKeys: true });
  234. fs.writeFileSync(filePath, content, "utf8");
  235. }
  236. /**
  237. * Writes a configuration file in JavaScript format.
  238. * @param {Object} config The configuration object to write.
  239. * @param {string} filePath The filename to write to.
  240. * @returns {void}
  241. * @private
  242. */
  243. function writeJSConfigFile(config, filePath) {
  244. debug(`Writing JS config file: ${filePath}`);
  245. const content = `module.exports = ${stringify(config, { cmp: sortByKey, space: 4 })};`;
  246. fs.writeFileSync(filePath, content, "utf8");
  247. }
  248. /**
  249. * Writes a configuration file.
  250. * @param {Object} config The configuration object to write.
  251. * @param {string} filePath The filename to write to.
  252. * @returns {void}
  253. * @throws {Error} When an unknown file type is specified.
  254. * @private
  255. */
  256. function write(config, filePath) {
  257. switch (path.extname(filePath)) {
  258. case ".js":
  259. writeJSConfigFile(config, filePath);
  260. break;
  261. case ".json":
  262. writeJSONConfigFile(config, filePath);
  263. break;
  264. case ".yaml":
  265. case ".yml":
  266. writeYAMLConfigFile(config, filePath);
  267. break;
  268. default:
  269. throw new Error("Can't write to unknown file type.");
  270. }
  271. }
  272. /**
  273. * Determines the base directory for node packages referenced in a config file.
  274. * This does not include node_modules in the path so it can be used for all
  275. * references relative to a config file.
  276. * @param {string} configFilePath The config file referencing the file.
  277. * @returns {string} The base directory for the file path.
  278. * @private
  279. */
  280. function getBaseDir(configFilePath) {
  281. // calculates the path of the project including ESLint as dependency
  282. const projectPath = path.resolve(__dirname, "../../../");
  283. if (configFilePath && pathIsInside(configFilePath, projectPath)) {
  284. // be careful of https://github.com/substack/node-resolve/issues/78
  285. return path.join(path.resolve(configFilePath));
  286. }
  287. /*
  288. * default to ESLint project path since it's unlikely that plugins will be
  289. * in this directory
  290. */
  291. return path.join(projectPath);
  292. }
  293. /**
  294. * Determines the lookup path, including node_modules, for package
  295. * references relative to a config file.
  296. * @param {string} configFilePath The config file referencing the file.
  297. * @returns {string} The lookup path for the file path.
  298. * @private
  299. */
  300. function getLookupPath(configFilePath) {
  301. const basedir = getBaseDir(configFilePath);
  302. return path.join(basedir, "node_modules");
  303. }
  304. /**
  305. * Resolves a eslint core config path
  306. * @param {string} name The eslint config name.
  307. * @returns {string} The resolved path of the config.
  308. * @private
  309. */
  310. function getEslintCoreConfigPath(name) {
  311. if (name === "eslint:recommended") {
  312. /*
  313. * Add an explicit substitution for eslint:recommended to
  314. * conf/eslint-recommended.js.
  315. */
  316. return path.resolve(__dirname, "../../conf/eslint-recommended.js");
  317. }
  318. if (name === "eslint:all") {
  319. /*
  320. * Add an explicit substitution for eslint:all to conf/eslint-all.js
  321. */
  322. return path.resolve(__dirname, "../../conf/eslint-all.js");
  323. }
  324. throw configMissingError(name);
  325. }
  326. /**
  327. * Applies values from the "extends" field in a configuration file.
  328. * @param {Object} config The configuration information.
  329. * @param {Config} configContext Plugin context for the config instance
  330. * @param {string} filePath The file path from which the configuration information
  331. * was loaded.
  332. * @param {string} [relativeTo] The path to resolve relative to.
  333. * @returns {Object} A new configuration object with all of the "extends" fields
  334. * loaded and merged.
  335. * @private
  336. */
  337. function applyExtends(config, configContext, filePath, relativeTo) {
  338. let configExtends = config.extends;
  339. // normalize into an array for easier handling
  340. if (!Array.isArray(config.extends)) {
  341. configExtends = [config.extends];
  342. }
  343. // Make the last element in an array take the highest precedence
  344. return configExtends.reduceRight((previousValue, parentPath) => {
  345. try {
  346. let extensionPath;
  347. if (parentPath.startsWith("eslint:")) {
  348. extensionPath = getEslintCoreConfigPath(parentPath);
  349. } else if (isFilePath(parentPath)) {
  350. /*
  351. * If the `extends` path is relative, use the directory of the current configuration
  352. * file as the reference point. Otherwise, use as-is.
  353. */
  354. extensionPath = (path.isAbsolute(parentPath)
  355. ? parentPath
  356. : path.join(relativeTo || path.dirname(filePath), parentPath)
  357. );
  358. } else {
  359. extensionPath = parentPath;
  360. }
  361. debug(`Loading ${extensionPath}`);
  362. // eslint-disable-next-line no-use-before-define
  363. return ConfigOps.merge(load(extensionPath, configContext, relativeTo), previousValue);
  364. } catch (e) {
  365. /*
  366. * If the file referenced by `extends` failed to load, add the path
  367. * to the configuration file that referenced it to the error
  368. * message so the user is able to see where it was referenced from,
  369. * then re-throw.
  370. */
  371. e.message += `\nReferenced from: ${filePath}`;
  372. throw e;
  373. }
  374. }, config);
  375. }
  376. /**
  377. * Resolves a configuration file path into the fully-formed path, whether filename
  378. * or package name.
  379. * @param {string} filePath The filepath to resolve.
  380. * @param {string} [relativeTo] The path to resolve relative to.
  381. * @returns {Object} An object containing 3 properties:
  382. * - 'filePath' (required) the resolved path that can be used directly to load the configuration.
  383. * - 'configName' the name of the configuration inside the plugin.
  384. * - 'configFullName' (required) the name of the configuration as used in the eslint config(e.g. 'plugin:node/recommended'),
  385. * or the absolute path to a config file. This should uniquely identify a config.
  386. * @private
  387. */
  388. function resolve(filePath, relativeTo) {
  389. if (isFilePath(filePath)) {
  390. const fullPath = path.resolve(relativeTo || "", filePath);
  391. return { filePath: fullPath, configFullName: fullPath };
  392. }
  393. let normalizedPackageName;
  394. if (filePath.startsWith("plugin:")) {
  395. const configFullName = filePath;
  396. const pluginName = filePath.slice(7, filePath.lastIndexOf("/"));
  397. const configName = filePath.slice(filePath.lastIndexOf("/") + 1);
  398. normalizedPackageName = naming.normalizePackageName(pluginName, "eslint-plugin");
  399. debug(`Attempting to resolve ${normalizedPackageName}`);
  400. return {
  401. filePath: resolver.resolve(normalizedPackageName, getLookupPath(relativeTo)),
  402. configName,
  403. configFullName
  404. };
  405. }
  406. normalizedPackageName = naming.normalizePackageName(filePath, "eslint-config");
  407. debug(`Attempting to resolve ${normalizedPackageName}`);
  408. return {
  409. filePath: resolver.resolve(normalizedPackageName, getLookupPath(relativeTo)),
  410. configFullName: filePath
  411. };
  412. }
  413. /**
  414. * Loads a configuration file from the given file path.
  415. * @param {Object} resolvedPath The value from calling resolve() on a filename or package name.
  416. * @param {Config} configContext Plugins context
  417. * @returns {Object} The configuration information.
  418. */
  419. function loadFromDisk(resolvedPath, configContext) {
  420. const dirname = path.dirname(resolvedPath.filePath),
  421. lookupPath = getLookupPath(dirname);
  422. let config = loadConfigFile(resolvedPath);
  423. if (config) {
  424. // ensure plugins are properly loaded first
  425. if (config.plugins) {
  426. configContext.plugins.loadAll(config.plugins);
  427. }
  428. // include full path of parser if present
  429. if (config.parser) {
  430. if (isFilePath(config.parser)) {
  431. config.parser = path.resolve(dirname || "", config.parser);
  432. } else {
  433. config.parser = resolver.resolve(config.parser, lookupPath);
  434. }
  435. }
  436. const ruleMap = configContext.linterContext.getRules();
  437. // validate the configuration before continuing
  438. validator.validate(config, resolvedPath.configFullName, ruleMap.get.bind(ruleMap), configContext.linterContext.environments);
  439. /*
  440. * If an `extends` property is defined, it represents a configuration file to use as
  441. * a "parent". Load the referenced file and merge the configuration recursively.
  442. */
  443. if (config.extends) {
  444. config = applyExtends(config, configContext, resolvedPath.filePath, dirname);
  445. }
  446. }
  447. return config;
  448. }
  449. /**
  450. * Loads a config object, applying extends if present.
  451. * @param {Object} configObject a config object to load
  452. * @param {Config} configContext Context for the config instance
  453. * @returns {Object} the config object with extends applied if present, or the passed config if not
  454. * @private
  455. */
  456. function loadObject(configObject, configContext) {
  457. return configObject.extends ? applyExtends(configObject, configContext, "") : configObject;
  458. }
  459. /**
  460. * Loads a config object from the config cache based on its filename, falling back to the disk if the file is not yet
  461. * cached.
  462. * @param {string} filePath the path to the config file
  463. * @param {Config} configContext Context for the config instance
  464. * @param {string} [relativeTo] The path to resolve relative to.
  465. * @returns {Object} the parsed config object (empty object if there was a parse error)
  466. * @private
  467. */
  468. function load(filePath, configContext, relativeTo) {
  469. const resolvedPath = resolve(filePath, relativeTo);
  470. const cachedConfig = configContext.configCache.getConfig(resolvedPath.configFullName);
  471. if (cachedConfig) {
  472. return cachedConfig;
  473. }
  474. const config = loadFromDisk(resolvedPath, configContext);
  475. if (config) {
  476. config.filePath = resolvedPath.filePath;
  477. config.baseDirectory = path.dirname(resolvedPath.filePath);
  478. configContext.configCache.setConfig(resolvedPath.configFullName, config);
  479. }
  480. return config;
  481. }
  482. //------------------------------------------------------------------------------
  483. // Public Interface
  484. //------------------------------------------------------------------------------
  485. module.exports = {
  486. getBaseDir,
  487. getLookupPath,
  488. load,
  489. loadObject,
  490. resolve,
  491. write,
  492. applyExtends,
  493. CONFIG_FILES,
  494. /**
  495. * Retrieves the configuration filename for a given directory. It loops over all
  496. * of the valid configuration filenames in order to find the first one that exists.
  497. * @param {string} directory The directory to check for a config file.
  498. * @returns {?string} The filename of the configuration file for the directory
  499. * or null if there is no configuration file in the directory.
  500. */
  501. getFilenameForDirectory(directory) {
  502. for (let i = 0, len = CONFIG_FILES.length; i < len; i++) {
  503. const filename = path.join(directory, CONFIG_FILES[i]);
  504. if (fs.existsSync(filename) && fs.statSync(filename).isFile()) {
  505. return filename;
  506. }
  507. }
  508. return null;
  509. }
  510. };