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 | importFresh = require("import-fresh");
|
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/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 | */
|
75 | function 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 | */
|
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 | 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 | */
|
134 | function 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 | */
|
156 | function 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 | */
|
174 | function 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 | */
|
191 | function 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 | */
|
208 | function 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 | */
|
253 | function 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 | */
|
268 | function 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 | */
|
287 | function 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 | */
|
325 | function 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 | */
|
353 | function 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 | */
|
378 | function 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 | */
|
390 | function 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 | */
|
422 | function 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 | */
|
481 | function 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 | */
|
520 | function 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 | */
|
565 | function 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 | */
|
578 | function 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 | */
|
603 | function 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 |
|
619 | module.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 | };
|