UNPKG

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