1 | /**
|
2 | * @fileoverview Helper to locate and load configuration files.
|
3 | * @author Nicholas C. Zakas
|
4 | */
|
5 |
|
6 | ;
|
7 |
|
8 | //------------------------------------------------------------------------------
|
9 | // Requirements
|
10 | //------------------------------------------------------------------------------
|
11 |
|
12 | const 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 |
|
23 | const 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 | */
|
38 | function sortByKey(a, b) {
|
39 | return a.key > b.key ? 1 : -1;
|
40 | }
|
41 |
|
42 | //------------------------------------------------------------------------------
|
43 | // Private
|
44 | //------------------------------------------------------------------------------
|
45 |
|
46 | const CONFIG_FILES = [
|
47 | ".eslintrc.js",
|
48 | ".eslintrc.yaml",
|
49 | ".eslintrc.yml",
|
50 | ".eslintrc.json",
|
51 | ".eslintrc",
|
52 | "package.json"
|
53 | ];
|
54 |
|
55 | const 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 | */
|
63 | function 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 | */
|
75 | function 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 | */
|
86 | function 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 | */
|
110 | function 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 | */
|
129 | function 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 | */
|
151 | function 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 | */
|
169 | function 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 | */
|
186 | function 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 | */
|
203 | function 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 | */
|
248 | function 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 | */
|
263 | function 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 | */
|
281 | function 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 | */
|
297 | function 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 | */
|
325 | function 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 | */
|
350 | function 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 | */
|
362 | function 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 | */
|
394 | function 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 | config = configExtends.reduceRight((previousValue, parentPath) => {
|
404 | try {
|
405 | if (parentPath.startsWith("eslint:")) {
|
406 | parentPath = getEslintCoreConfigPath(parentPath);
|
407 | } else if (isFilePath(parentPath)) {
|
408 |
|
409 | /*
|
410 | * If the `extends` path is relative, use the directory of the current configuration
|
411 | * file as the reference point. Otherwise, use as-is.
|
412 | */
|
413 | parentPath = (path.isAbsolute(parentPath)
|
414 | ? parentPath
|
415 | : path.join(relativeTo || path.dirname(filePath), parentPath)
|
416 | );
|
417 | }
|
418 | debug(`Loading ${parentPath}`);
|
419 |
|
420 | // eslint-disable-next-line no-use-before-define
|
421 | return ConfigOps.merge(load(parentPath, configContext, relativeTo), previousValue);
|
422 | } catch (e) {
|
423 |
|
424 | /*
|
425 | * If the file referenced by `extends` failed to load, add the path
|
426 | * to the configuration file that referenced it to the error
|
427 | * message so the user is able to see where it was referenced from,
|
428 | * then re-throw.
|
429 | */
|
430 | e.message += `\nReferenced from: ${filePath}`;
|
431 | throw e;
|
432 | }
|
433 |
|
434 | }, config);
|
435 |
|
436 | return config;
|
437 | }
|
438 |
|
439 | /**
|
440 | * Resolves a configuration file path into the fully-formed path, whether filename
|
441 | * or package name.
|
442 | * @param {string} filePath The filepath to resolve.
|
443 | * @param {string} [relativeTo] The path to resolve relative to.
|
444 | * @returns {Object} An object containing 3 properties:
|
445 | * - 'filePath' (required) the resolved path that can be used directly to load the configuration.
|
446 | * - 'configName' the name of the configuration inside the plugin.
|
447 | * - 'configFullName' (required) the name of the configuration as used in the eslint config(e.g. 'plugin:node/recommended'),
|
448 | * or the absolute path to a config file. This should uniquely identify a config.
|
449 | * @private
|
450 | */
|
451 | function resolve(filePath, relativeTo) {
|
452 | if (isFilePath(filePath)) {
|
453 | const fullPath = path.resolve(relativeTo || "", filePath);
|
454 |
|
455 | return { filePath: fullPath, configFullName: fullPath };
|
456 | }
|
457 | let normalizedPackageName;
|
458 |
|
459 | if (filePath.startsWith("plugin:")) {
|
460 | const configFullName = filePath;
|
461 | const pluginName = filePath.slice(7, filePath.lastIndexOf("/"));
|
462 | const configName = filePath.slice(filePath.lastIndexOf("/") + 1);
|
463 |
|
464 | normalizedPackageName = naming.normalizePackageName(pluginName, "eslint-plugin");
|
465 | debug(`Attempting to resolve ${normalizedPackageName}`);
|
466 | filePath = resolver.resolve(normalizedPackageName, getLookupPath(relativeTo));
|
467 | return { filePath, configName, configFullName };
|
468 | }
|
469 | normalizedPackageName = naming.normalizePackageName(filePath, "eslint-config");
|
470 | debug(`Attempting to resolve ${normalizedPackageName}`);
|
471 | filePath = resolver.resolve(normalizedPackageName, getLookupPath(relativeTo));
|
472 | return { filePath, configFullName: filePath };
|
473 |
|
474 |
|
475 | }
|
476 |
|
477 | /**
|
478 | * Loads a configuration file from the given file path.
|
479 | * @param {Object} resolvedPath The value from calling resolve() on a filename or package name.
|
480 | * @param {Config} configContext Plugins context
|
481 | * @returns {Object} The configuration information.
|
482 | */
|
483 | function loadFromDisk(resolvedPath, configContext) {
|
484 | const dirname = path.dirname(resolvedPath.filePath),
|
485 | lookupPath = getLookupPath(dirname);
|
486 | let config = loadConfigFile(resolvedPath);
|
487 |
|
488 | if (config) {
|
489 |
|
490 | // ensure plugins are properly loaded first
|
491 | if (config.plugins) {
|
492 | configContext.plugins.loadAll(config.plugins);
|
493 | }
|
494 |
|
495 | // include full path of parser if present
|
496 | if (config.parser) {
|
497 | if (isFilePath(config.parser)) {
|
498 | config.parser = path.resolve(dirname || "", config.parser);
|
499 | } else {
|
500 | config.parser = resolver.resolve(config.parser, lookupPath);
|
501 | }
|
502 | }
|
503 |
|
504 | const ruleMap = configContext.linterContext.getRules();
|
505 |
|
506 | // validate the configuration before continuing
|
507 | validator.validate(config, resolvedPath.configFullName, ruleMap.get.bind(ruleMap), configContext.linterContext.environments);
|
508 |
|
509 | /*
|
510 | * If an `extends` property is defined, it represents a configuration file to use as
|
511 | * a "parent". Load the referenced file and merge the configuration recursively.
|
512 | */
|
513 | if (config.extends) {
|
514 | config = applyExtends(config, configContext, resolvedPath.filePath, dirname);
|
515 | }
|
516 | }
|
517 |
|
518 | return config;
|
519 | }
|
520 |
|
521 | /**
|
522 | * Loads a config object, applying extends if present.
|
523 | * @param {Object} configObject a config object to load
|
524 | * @param {Config} configContext Context for the config instance
|
525 | * @returns {Object} the config object with extends applied if present, or the passed config if not
|
526 | * @private
|
527 | */
|
528 | function loadObject(configObject, configContext) {
|
529 | return configObject.extends ? applyExtends(configObject, configContext, "") : configObject;
|
530 | }
|
531 |
|
532 | /**
|
533 | * Loads a config object from the config cache based on its filename, falling back to the disk if the file is not yet
|
534 | * cached.
|
535 | * @param {string} filePath the path to the config file
|
536 | * @param {Config} configContext Context for the config instance
|
537 | * @param {string} [relativeTo] The path to resolve relative to.
|
538 | * @returns {Object} the parsed config object (empty object if there was a parse error)
|
539 | * @private
|
540 | */
|
541 | function load(filePath, configContext, relativeTo) {
|
542 | const resolvedPath = resolve(filePath, relativeTo);
|
543 |
|
544 | const cachedConfig = configContext.configCache.getConfig(resolvedPath.configFullName);
|
545 |
|
546 | if (cachedConfig) {
|
547 | return cachedConfig;
|
548 | }
|
549 |
|
550 | const config = loadFromDisk(resolvedPath, configContext);
|
551 |
|
552 | if (config) {
|
553 | config.filePath = resolvedPath.filePath;
|
554 | config.baseDirectory = path.dirname(resolvedPath.filePath);
|
555 | configContext.configCache.setConfig(resolvedPath.configFullName, config);
|
556 | }
|
557 |
|
558 | return config;
|
559 | }
|
560 |
|
561 |
|
562 | //------------------------------------------------------------------------------
|
563 | // Public Interface
|
564 | //------------------------------------------------------------------------------
|
565 |
|
566 | module.exports = {
|
567 |
|
568 | getBaseDir,
|
569 | getLookupPath,
|
570 | load,
|
571 | loadObject,
|
572 | resolve,
|
573 | write,
|
574 | applyExtends,
|
575 | CONFIG_FILES,
|
576 |
|
577 | /**
|
578 | * Retrieves the configuration filename for a given directory. It loops over all
|
579 | * of the valid configuration filenames in order to find the first one that exists.
|
580 | * @param {string} directory The directory to check for a config file.
|
581 | * @returns {?string} The filename of the configuration file for the directory
|
582 | * or null if there is no configuration file in the directory.
|
583 | */
|
584 | getFilenameForDirectory(directory) {
|
585 | for (let i = 0, len = CONFIG_FILES.length; i < len; i++) {
|
586 | const filename = path.join(directory, CONFIG_FILES[i]);
|
587 |
|
588 | if (fs.existsSync(filename) && fs.statSync(filename).isFile()) {
|
589 | return filename;
|
590 | }
|
591 | }
|
592 |
|
593 | return null;
|
594 | }
|
595 | };
|