config-file.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  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. importFresh = require("import-fresh");
  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/u, "");
  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|@/u.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. e.messageTemplate = "failed-to-read-json";
  102. e.messageData = {
  103. path: filePath,
  104. message: e.message
  105. };
  106. throw e;
  107. }
  108. }
  109. /**
  110. * Loads a legacy (.eslintrc) configuration from a file.
  111. * @param {string} filePath The filename to load.
  112. * @returns {Object} The configuration object from the file.
  113. * @throws {Error} If the file cannot be read.
  114. * @private
  115. */
  116. function loadLegacyConfigFile(filePath) {
  117. debug(`Loading config file: ${filePath}`);
  118. // lazy load YAML to improve performance when not used
  119. const yaml = require("js-yaml");
  120. try {
  121. return yaml.safeLoad(stripComments(readFile(filePath))) || /* istanbul ignore next */ {};
  122. } catch (e) {
  123. debug(`Error reading YAML file: ${filePath}`);
  124. e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
  125. throw e;
  126. }
  127. }
  128. /**
  129. * Loads a JavaScript configuration from a file.
  130. * @param {string} filePath The filename to load.
  131. * @returns {Object} The configuration object from the file.
  132. * @throws {Error} If the file cannot be read.
  133. * @private
  134. */
  135. function loadJSConfigFile(filePath) {
  136. debug(`Loading JS config file: ${filePath}`);
  137. try {
  138. return importFresh(filePath);
  139. } catch (e) {
  140. debug(`Error reading JavaScript file: ${filePath}`);
  141. e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
  142. throw e;
  143. }
  144. }
  145. /**
  146. * Loads a configuration from a package.json file.
  147. * @param {string} filePath The filename to load.
  148. * @returns {Object} The configuration object from the file.
  149. * @throws {Error} If the file cannot be read.
  150. * @private
  151. */
  152. function loadPackageJSONConfigFile(filePath) {
  153. debug(`Loading package.json config file: ${filePath}`);
  154. try {
  155. return loadJSONConfigFile(filePath).eslintConfig || null;
  156. } catch (e) {
  157. debug(`Error reading package.json file: ${filePath}`);
  158. e.message = `Cannot read config file: ${filePath}\nError: ${e.message}`;
  159. throw e;
  160. }
  161. }
  162. /**
  163. * Creates an error to notify about a missing config to extend from.
  164. * @param {string} configName The name of the missing config.
  165. * @returns {Error} The error object to throw
  166. * @private
  167. */
  168. function configMissingError(configName) {
  169. const error = new Error(`Failed to load config "${configName}" to extend from.`);
  170. error.messageTemplate = "extend-config-missing";
  171. error.messageData = {
  172. configName
  173. };
  174. return error;
  175. }
  176. /**
  177. * Loads a configuration file regardless of the source. Inspects the file path
  178. * to determine the correctly way to load the config file.
  179. * @param {Object} file The path to the configuration.
  180. * @returns {Object} The configuration information.
  181. * @private
  182. */
  183. function loadConfigFile(file) {
  184. const filePath = file.filePath;
  185. let config;
  186. switch (path.extname(filePath)) {
  187. case ".js":
  188. config = loadJSConfigFile(filePath);
  189. if (file.configName) {
  190. config = config.configs[file.configName];
  191. if (!config) {
  192. throw configMissingError(file.configFullName);
  193. }
  194. }
  195. break;
  196. case ".json":
  197. if (path.basename(filePath) === "package.json") {
  198. config = loadPackageJSONConfigFile(filePath);
  199. if (config === null) {
  200. return null;
  201. }
  202. } else {
  203. config = loadJSONConfigFile(filePath);
  204. }
  205. break;
  206. case ".yaml":
  207. case ".yml":
  208. config = loadYAMLConfigFile(filePath);
  209. break;
  210. default:
  211. config = loadLegacyConfigFile(filePath);
  212. }
  213. return ConfigOps.merge(ConfigOps.createEmptyConfig(), config);
  214. }
  215. /**
  216. * Writes a configuration file in JSON format.
  217. * @param {Object} config The configuration object to write.
  218. * @param {string} filePath The filename to write to.
  219. * @returns {void}
  220. * @private
  221. */
  222. function writeJSONConfigFile(config, filePath) {
  223. debug(`Writing JSON config file: ${filePath}`);
  224. const content = stringify(config, { cmp: sortByKey, space: 4 });
  225. fs.writeFileSync(filePath, content, "utf8");
  226. }
  227. /**
  228. * Writes a configuration file in YAML format.
  229. * @param {Object} config The configuration object to write.
  230. * @param {string} filePath The filename to write to.
  231. * @returns {void}
  232. * @private
  233. */
  234. function writeYAMLConfigFile(config, filePath) {
  235. debug(`Writing YAML config file: ${filePath}`);
  236. // lazy load YAML to improve performance when not used
  237. const yaml = require("js-yaml");
  238. const content = yaml.safeDump(config, { sortKeys: true });
  239. fs.writeFileSync(filePath, content, "utf8");
  240. }
  241. /**
  242. * Writes a configuration file in JavaScript format.
  243. * @param {Object} config The configuration object to write.
  244. * @param {string} filePath The filename to write to.
  245. * @throws {Error} If an error occurs linting the config file contents.
  246. * @returns {void}
  247. * @private
  248. */
  249. function writeJSConfigFile(config, filePath) {
  250. debug(`Writing JS config file: ${filePath}`);
  251. let contentToWrite;
  252. const stringifiedContent = `module.exports = ${stringify(config, { cmp: sortByKey, space: 4 })};`;
  253. try {
  254. const CLIEngine = require("../cli-engine");
  255. const linter = new CLIEngine({
  256. baseConfig: config,
  257. fix: true,
  258. useEslintrc: false
  259. });
  260. const report = linter.executeOnText(stringifiedContent);
  261. contentToWrite = report.results[0].output || stringifiedContent;
  262. } catch (e) {
  263. debug("Error linting JavaScript config file, writing unlinted version");
  264. const errorMessage = e.message;
  265. contentToWrite = stringifiedContent;
  266. e.message = "An error occurred while generating your JavaScript config file. ";
  267. e.message += "A config file was still generated, but the config file itself may not follow your linting rules.";
  268. e.message += `\nError: ${errorMessage}`;
  269. throw e;
  270. } finally {
  271. fs.writeFileSync(filePath, contentToWrite, "utf8");
  272. }
  273. }
  274. /**
  275. * Writes a configuration file.
  276. * @param {Object} config The configuration object to write.
  277. * @param {string} filePath The filename to write to.
  278. * @returns {void}
  279. * @throws {Error} When an unknown file type is specified.
  280. * @private
  281. */
  282. function write(config, filePath) {
  283. switch (path.extname(filePath)) {
  284. case ".js":
  285. writeJSConfigFile(config, filePath);
  286. break;
  287. case ".json":
  288. writeJSONConfigFile(config, filePath);
  289. break;
  290. case ".yaml":
  291. case ".yml":
  292. writeYAMLConfigFile(config, filePath);
  293. break;
  294. default:
  295. throw new Error("Can't write to unknown file type.");
  296. }
  297. }
  298. /**
  299. * Determines the base directory for node packages referenced in a config file.
  300. * This does not include node_modules in the path so it can be used for all
  301. * references relative to a config file.
  302. * @param {string} configFilePath The config file referencing the file.
  303. * @returns {string} The base directory for the file path.
  304. * @private
  305. */
  306. function getBaseDir(configFilePath) {
  307. // calculates the path of the project including ESLint as dependency
  308. const projectPath = path.resolve(__dirname, "../../../");
  309. if (configFilePath && pathIsInside(configFilePath, projectPath)) {
  310. // be careful of https://github.com/substack/node-resolve/issues/78
  311. return path.join(path.resolve(configFilePath));
  312. }
  313. /*
  314. * default to ESLint project path since it's unlikely that plugins will be
  315. * in this directory
  316. */
  317. return path.join(projectPath);
  318. }
  319. /**
  320. * Determines the lookup path, including node_modules, for package
  321. * references relative to a config file.
  322. * @param {string} configFilePath The config file referencing the file.
  323. * @returns {string} The lookup path for the file path.
  324. * @private
  325. */
  326. function getLookupPath(configFilePath) {
  327. const basedir = getBaseDir(configFilePath);
  328. return path.join(basedir, "node_modules");
  329. }
  330. /**
  331. * Resolves a eslint core config path
  332. * @param {string} name The eslint config name.
  333. * @returns {string} The resolved path of the config.
  334. * @private
  335. */
  336. function getEslintCoreConfigPath(name) {
  337. if (name === "eslint:recommended") {
  338. /*
  339. * Add an explicit substitution for eslint:recommended to
  340. * conf/eslint-recommended.js.
  341. */
  342. return path.resolve(__dirname, "../../conf/eslint-recommended.js");
  343. }
  344. if (name === "eslint:all") {
  345. /*
  346. * Add an explicit substitution for eslint:all to conf/eslint-all.js
  347. */
  348. return path.resolve(__dirname, "../../conf/eslint-all.js");
  349. }
  350. throw configMissingError(name);
  351. }
  352. /**
  353. * Applies values from the "extends" field in a configuration file.
  354. * @param {Object} config The configuration information.
  355. * @param {Config} configContext Plugin context for the config instance
  356. * @param {string} filePath The file path from which the configuration information
  357. * was loaded.
  358. * @param {string} [relativeTo] The path to resolve relative to.
  359. * @returns {Object} A new configuration object with all of the "extends" fields
  360. * loaded and merged.
  361. * @private
  362. */
  363. function applyExtends(config, configContext, filePath, relativeTo) {
  364. let configExtends = config.extends;
  365. // normalize into an array for easier handling
  366. if (!Array.isArray(config.extends)) {
  367. configExtends = [config.extends];
  368. }
  369. // Make the last element in an array take the highest precedence
  370. return configExtends.reduceRight((previousValue, parentPath) => {
  371. try {
  372. let extensionPath;
  373. if (parentPath.startsWith("eslint:")) {
  374. extensionPath = getEslintCoreConfigPath(parentPath);
  375. } else if (isFilePath(parentPath)) {
  376. /*
  377. * If the `extends` path is relative, use the directory of the current configuration
  378. * file as the reference point. Otherwise, use as-is.
  379. */
  380. extensionPath = (path.isAbsolute(parentPath)
  381. ? parentPath
  382. : path.join(relativeTo || path.dirname(filePath), parentPath)
  383. );
  384. } else {
  385. extensionPath = parentPath;
  386. }
  387. debug(`Loading ${extensionPath}`);
  388. // eslint-disable-next-line no-use-before-define
  389. return ConfigOps.merge(load(extensionPath, configContext, relativeTo), previousValue);
  390. } catch (e) {
  391. /*
  392. * If the file referenced by `extends` failed to load, add the path
  393. * to the configuration file that referenced it to the error
  394. * message so the user is able to see where it was referenced from,
  395. * then re-throw.
  396. */
  397. e.message += `\nReferenced from: ${filePath}`;
  398. throw e;
  399. }
  400. }, config);
  401. }
  402. /**
  403. * Resolves a configuration file path into the fully-formed path, whether filename
  404. * or package name.
  405. * @param {string} filePath The filepath to resolve.
  406. * @param {string} [relativeTo] The path to resolve relative to.
  407. * @returns {Object} An object containing 3 properties:
  408. * - 'filePath' (required) the resolved path that can be used directly to load the configuration.
  409. * - 'configName' the name of the configuration inside the plugin.
  410. * - 'configFullName' (required) the name of the configuration as used in the eslint config(e.g. 'plugin:node/recommended'),
  411. * or the absolute path to a config file. This should uniquely identify a config.
  412. * @private
  413. */
  414. function resolve(filePath, relativeTo) {
  415. if (isFilePath(filePath)) {
  416. const fullPath = path.resolve(relativeTo || "", filePath);
  417. return { filePath: fullPath, configFullName: fullPath };
  418. }
  419. let normalizedPackageName;
  420. if (filePath.startsWith("plugin:")) {
  421. const configFullName = filePath;
  422. const pluginName = filePath.slice(7, filePath.lastIndexOf("/"));
  423. const configName = filePath.slice(filePath.lastIndexOf("/") + 1);
  424. normalizedPackageName = naming.normalizePackageName(pluginName, "eslint-plugin");
  425. debug(`Attempting to resolve ${normalizedPackageName}`);
  426. return {
  427. filePath: require.resolve(normalizedPackageName),
  428. configName,
  429. configFullName
  430. };
  431. }
  432. normalizedPackageName = naming.normalizePackageName(filePath, "eslint-config");
  433. debug(`Attempting to resolve ${normalizedPackageName}`);
  434. return {
  435. filePath: resolver.resolve(normalizedPackageName, getLookupPath(relativeTo)),
  436. configFullName: filePath
  437. };
  438. }
  439. /**
  440. * Loads a configuration file from the given file path.
  441. * @param {Object} resolvedPath The value from calling resolve() on a filename or package name.
  442. * @param {Config} configContext Plugins context
  443. * @returns {Object} The configuration information.
  444. */
  445. function loadFromDisk(resolvedPath, configContext) {
  446. const dirname = path.dirname(resolvedPath.filePath),
  447. lookupPath = getLookupPath(dirname);
  448. let config = loadConfigFile(resolvedPath);
  449. if (config) {
  450. // ensure plugins are properly loaded first
  451. if (config.plugins) {
  452. configContext.plugins.loadAll(config.plugins);
  453. }
  454. // include full path of parser if present
  455. if (config.parser) {
  456. if (isFilePath(config.parser)) {
  457. config.parser = path.resolve(dirname || "", config.parser);
  458. } else {
  459. config.parser = resolver.resolve(config.parser, lookupPath);
  460. }
  461. }
  462. const ruleMap = configContext.linterContext.getRules();
  463. // validate the configuration before continuing
  464. validator.validate(config, ruleMap.get.bind(ruleMap), configContext.linterContext.environments, resolvedPath.configFullName);
  465. /*
  466. * If an `extends` property is defined, it represents a configuration file to use as
  467. * a "parent". Load the referenced file and merge the configuration recursively.
  468. */
  469. if (config.extends) {
  470. config = applyExtends(config, configContext, resolvedPath.filePath, dirname);
  471. }
  472. }
  473. return config;
  474. }
  475. /**
  476. * Loads a config object, applying extends if present.
  477. * @param {Object} configObject a config object to load
  478. * @param {Config} configContext Context for the config instance
  479. * @returns {Object} the config object with extends applied if present, or the passed config if not
  480. * @private
  481. */
  482. function loadObject(configObject, configContext) {
  483. return configObject.extends ? applyExtends(configObject, configContext, "") : configObject;
  484. }
  485. /**
  486. * Loads a config object from the config cache based on its filename, falling back to the disk if the file is not yet
  487. * cached.
  488. * @param {string} filePath the path to the config file
  489. * @param {Config} configContext Context for the config instance
  490. * @param {string} [relativeTo] The path to resolve relative to.
  491. * @returns {Object} the parsed config object (empty object if there was a parse error)
  492. * @private
  493. */
  494. function load(filePath, configContext, relativeTo) {
  495. const resolvedPath = resolve(filePath, relativeTo);
  496. const cachedConfig = configContext.configCache.getConfig(resolvedPath.configFullName);
  497. if (cachedConfig) {
  498. return cachedConfig;
  499. }
  500. const config = loadFromDisk(resolvedPath, configContext);
  501. if (config) {
  502. config.filePath = resolvedPath.filePath;
  503. config.baseDirectory = path.dirname(resolvedPath.filePath);
  504. configContext.configCache.setConfig(resolvedPath.configFullName, config);
  505. }
  506. return config;
  507. }
  508. /**
  509. * Checks whether the given filename points to a file
  510. * @param {string} filename A path to a file
  511. * @returns {boolean} `true` if a file exists at the given location
  512. */
  513. function isExistingFile(filename) {
  514. try {
  515. return fs.statSync(filename).isFile();
  516. } catch (err) {
  517. if (err.code === "ENOENT") {
  518. return false;
  519. }
  520. throw err;
  521. }
  522. }
  523. //------------------------------------------------------------------------------
  524. // Public Interface
  525. //------------------------------------------------------------------------------
  526. module.exports = {
  527. getBaseDir,
  528. getLookupPath,
  529. load,
  530. loadObject,
  531. resolve,
  532. write,
  533. applyExtends,
  534. CONFIG_FILES,
  535. /**
  536. * Retrieves the configuration filename for a given directory. It loops over all
  537. * of the valid configuration filenames in order to find the first one that exists.
  538. * @param {string} directory The directory to check for a config file.
  539. * @returns {?string} The filename of the configuration file for the directory
  540. * or null if there is no configuration file in the directory.
  541. */
  542. getFilenameForDirectory(directory) {
  543. return CONFIG_FILES.map(filename => path.join(directory, filename)).find(isExistingFile) || null;
  544. }
  545. };