UNPKG

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