UNPKG

25.8 kBJavaScriptView Raw
1/**
2 * @fileoverview Main CLI object.
3 * @author Nicholas C. Zakas
4 */
5
6"use strict";
7
8/*
9 * The CLI object should *not* call process.exit() directly. It should only return
10 * exit codes. This allows other programs to use the CLI object and still control
11 * when the program exits.
12 */
13
14//------------------------------------------------------------------------------
15// Requirements
16//------------------------------------------------------------------------------
17
18const fs = require("fs"),
19 path = require("path"),
20 rules = require("./rules"),
21 eslint = require("./eslint"),
22 defaultOptions = require("../conf/cli-options"),
23 IgnoredPaths = require("./ignored-paths"),
24 Config = require("./config"),
25 Plugins = require("./config/plugins"),
26 fileEntryCache = require("file-entry-cache"),
27 globUtil = require("./util/glob-util"),
28 SourceCodeFixer = require("./util/source-code-fixer"),
29 validator = require("./config/config-validator"),
30 stringify = require("json-stable-stringify"),
31 hash = require("./util/hash"),
32
33 pkg = require("../package.json");
34
35const debug = require("debug")("eslint:cli-engine");
36
37//------------------------------------------------------------------------------
38// Typedefs
39//------------------------------------------------------------------------------
40
41/**
42 * The options to configure a CLI engine with.
43 * @typedef {Object} CLIEngineOptions
44 * @property {boolean} allowInlineConfig Enable or disable inline configuration comments.
45 * @property {boolean|Object} baseConfig Base config object. True enables recommend rules and environments.
46 * @property {boolean} cache Enable result caching.
47 * @property {string} cacheLocation The cache file to use instead of .eslintcache.
48 * @property {string} configFile The configuration file to use.
49 * @property {string} cwd The value to use for the current working directory.
50 * @property {string[]} envs An array of environments to load.
51 * @property {string[]} extensions An array of file extensions to check.
52 * @property {boolean} fix Execute in autofix mode.
53 * @property {string[]} globals An array of global variables to declare.
54 * @property {boolean} ignore False disables use of .eslintignore.
55 * @property {string} ignorePath The ignore file to use instead of .eslintignore.
56 * @property {string} ignorePattern A glob pattern of files to ignore.
57 * @property {boolean} useEslintrc False disables looking for .eslintrc
58 * @property {string} parser The name of the parser to use.
59 * @property {Object} parserOptions An object of parserOption settings to use.
60 * @property {string[]} plugins An array of plugins to load.
61 * @property {Object<string,*>} rules An object of rules to use.
62 * @property {string[]} rulePaths An array of directories to load custom rules from.
63 */
64
65/**
66 * A linting warning or error.
67 * @typedef {Object} LintMessage
68 * @property {string} message The message to display to the user.
69 */
70
71/**
72 * A linting result.
73 * @typedef {Object} LintResult
74 * @property {string} filePath The path to the file that was linted.
75 * @property {LintMessage[]} messages All of the messages for the result.
76 * @property {number} errorCount Number or errors for the result.
77 * @property {number} warningCount Number or warnings for the result.
78 */
79
80//------------------------------------------------------------------------------
81// Helpers
82//------------------------------------------------------------------------------
83
84/**
85 * It will calculate the error and warning count for collection of messages per file
86 * @param {Object[]} messages - Collection of messages
87 * @returns {Object} Contains the stats
88 * @private
89 */
90function calculateStatsPerFile(messages) {
91 return messages.reduce(function(stat, message) {
92 if (message.fatal || message.severity === 2) {
93 stat.errorCount++;
94 } else {
95 stat.warningCount++;
96 }
97 return stat;
98 }, {
99 errorCount: 0,
100 warningCount: 0
101 });
102}
103
104/**
105 * It will calculate the error and warning count for collection of results from all files
106 * @param {Object[]} results - Collection of messages from all the files
107 * @returns {Object} Contains the stats
108 * @private
109 */
110function calculateStatsPerRun(results) {
111 return results.reduce(function(stat, result) {
112 stat.errorCount += result.errorCount;
113 stat.warningCount += result.warningCount;
114 return stat;
115 }, {
116 errorCount: 0,
117 warningCount: 0
118 });
119}
120
121/**
122 * Performs multiple autofix passes over the text until as many fixes as possible
123 * have been applied.
124 * @param {string} text The source text to apply fixes to.
125 * @param {Object} config The ESLint config object to use.
126 * @param {Object} options The ESLint options object to use.
127 * @param {string} options.filename The filename from which the text was read.
128 * @param {boolean} options.allowInlineConfig Flag indicating if inline comments
129 * should be allowed.
130 * @returns {Object} The result of the fix operation as returned from the
131 * SourceCodeFixer.
132 * @private
133 */
134function multipassFix(text, config, options) {
135 const MAX_PASSES = 10;
136 let messages = [],
137 fixedResult,
138 fixed = false,
139 passNumber = 0;
140
141 /**
142 * This loop continues until one of the following is true:
143 *
144 * 1. No more fixes have been applied.
145 * 2. Ten passes have been made.
146 *
147 * That means anytime a fix is successfully applied, there will be another pass.
148 * Essentially, guaranteeing a minimum of two passes.
149 */
150 do {
151 passNumber++;
152
153 debug("Linting code for " + options.filename + " (pass " + passNumber + ")");
154 messages = eslint.verify(text, config, options);
155
156 debug("Generating fixed text for " + options.filename + " (pass " + passNumber + ")");
157 fixedResult = SourceCodeFixer.applyFixes(eslint.getSourceCode(), messages);
158
159 // stop if there are any syntax errors.
160 // 'fixedResult.output' is a empty string.
161 if (messages.length === 1 && messages[0].fatal) {
162 break;
163 }
164
165 // keep track if any fixes were ever applied - important for return value
166 fixed = fixed || fixedResult.fixed;
167
168 // update to use the fixed output instead of the original text
169 text = fixedResult.output;
170
171 } while (
172 fixedResult.fixed &&
173 passNumber < MAX_PASSES
174 );
175
176
177 /*
178 * If the last result had fixes, we need to lint again to me sure we have
179 * the most up-to-date information.
180 */
181 if (fixedResult.fixed) {
182 fixedResult.messages = eslint.verify(text, config, options);
183 }
184
185
186 // ensure the last result properly reflects if fixes were done
187 fixedResult.fixed = fixed;
188 fixedResult.output = text;
189
190 return fixedResult;
191
192}
193
194/**
195 * Processes an source code using ESLint.
196 * @param {string} text The source code to check.
197 * @param {Object} configHelper The configuration options for ESLint.
198 * @param {string} filename An optional string representing the texts filename.
199 * @param {boolean} fix Indicates if fixes should be processed.
200 * @param {boolean} allowInlineConfig Allow/ignore comments that change config.
201 * @returns {Result} The results for linting on this text.
202 * @private
203 */
204function processText(text, configHelper, filename, fix, allowInlineConfig) {
205
206 // clear all existing settings for a new file
207 eslint.reset();
208
209 let filePath,
210 messages,
211 fileExtension,
212 processor,
213 fixedResult;
214
215 if (filename) {
216 filePath = path.resolve(filename);
217 fileExtension = path.extname(filename);
218 }
219
220 filename = filename || "<text>";
221 debug("Linting " + filename);
222 const config = configHelper.getConfig(filePath);
223
224 if (config.plugins) {
225 Plugins.loadAll(config.plugins);
226 }
227
228 const loadedPlugins = Plugins.getAll();
229
230 for (const plugin in loadedPlugins) {
231 if (loadedPlugins[plugin].processors && Object.keys(loadedPlugins[plugin].processors).indexOf(fileExtension) >= 0) {
232 processor = loadedPlugins[plugin].processors[fileExtension];
233 break;
234 }
235 }
236
237 if (processor) {
238 debug("Using processor");
239 const parsedBlocks = processor.preprocess(text, filename);
240 const unprocessedMessages = [];
241
242 parsedBlocks.forEach(function(block) {
243 unprocessedMessages.push(eslint.verify(block, config, {
244 filename,
245 allowInlineConfig
246 }));
247 });
248
249 // TODO(nzakas): Figure out how fixes might work for processors
250
251 messages = processor.postprocess(unprocessedMessages, filename);
252
253 } else {
254
255 if (fix) {
256 fixedResult = multipassFix(text, config, {
257 filename,
258 allowInlineConfig
259 });
260 messages = fixedResult.messages;
261 } else {
262 messages = eslint.verify(text, config, {
263 filename,
264 allowInlineConfig
265 });
266 }
267 }
268
269 const stats = calculateStatsPerFile(messages);
270
271 const result = {
272 filePath: filename,
273 messages,
274 errorCount: stats.errorCount,
275 warningCount: stats.warningCount
276 };
277
278 if (fixedResult && fixedResult.fixed) {
279 result.output = fixedResult.output;
280 }
281
282 return result;
283}
284
285/**
286 * Processes an individual file using ESLint. Files used here are known to
287 * exist, so no need to check that here.
288 * @param {string} filename The filename of the file being checked.
289 * @param {Object} configHelper The configuration options for ESLint.
290 * @param {Object} options The CLIEngine options object.
291 * @returns {Result} The results for linting on this file.
292 * @private
293 */
294function processFile(filename, configHelper, options) {
295
296 const text = fs.readFileSync(path.resolve(filename), "utf8"),
297 result = processText(text, configHelper, filename, options.fix, options.allowInlineConfig);
298
299 return result;
300
301}
302
303/**
304 * Returns result with warning by ignore settings
305 * @param {string} filePath - File path of checked code
306 * @param {string} baseDir - Absolute path of base directory
307 * @returns {Result} Result with single warning
308 * @private
309 */
310function createIgnoreResult(filePath, baseDir) {
311 let message;
312 const isHidden = /^\./.test(path.basename(filePath));
313 const isInNodeModules = baseDir && /^node_modules/.test(path.relative(baseDir, filePath));
314 const isInBowerComponents = baseDir && /^bower_components/.test(path.relative(baseDir, filePath));
315
316 if (isHidden) {
317 message = "File ignored by default. Use a negated ignore pattern (like \"--ignore-pattern \'!<relative/path/to/filename>\'\") to override.";
318 } else if (isInNodeModules) {
319 message = "File ignored by default. Use \"--ignore-pattern \'!node_modules/*\'\" to override.";
320 } else if (isInBowerComponents) {
321 message = "File ignored by default. Use \"--ignore-pattern \'!bower_components/*\'\" to override.";
322 } else {
323 message = "File ignored because of a matching ignore pattern. Use \"--no-ignore\" to override.";
324 }
325
326 return {
327 filePath: path.resolve(filePath),
328 messages: [
329 {
330 fatal: false,
331 severity: 1,
332 message
333 }
334 ],
335 errorCount: 0,
336 warningCount: 1
337 };
338}
339
340
341/**
342 * Checks if the given message is an error message.
343 * @param {Object} message The message to check.
344 * @returns {boolean} Whether or not the message is an error message.
345 * @private
346 */
347function isErrorMessage(message) {
348 return message.severity === 2;
349}
350
351
352/**
353 * return the cacheFile to be used by eslint, based on whether the provided parameter is
354 * a directory or looks like a directory (ends in `path.sep`), in which case the file
355 * name will be the `cacheFile/.cache_hashOfCWD`
356 *
357 * if cacheFile points to a file or looks like a file then in will just use that file
358 *
359 * @param {string} cacheFile The name of file to be used to store the cache
360 * @param {string} cwd Current working directory
361 * @returns {string} the resolved path to the cache file
362 */
363function getCacheFile(cacheFile, cwd) {
364
365 /*
366 * make sure the path separators are normalized for the environment/os
367 * keeping the trailing path separator if present
368 */
369 cacheFile = path.normalize(cacheFile);
370
371 const resolvedCacheFile = path.resolve(cwd, cacheFile);
372 const looksLikeADirectory = cacheFile[cacheFile.length - 1 ] === path.sep;
373
374 /**
375 * return the name for the cache file in case the provided parameter is a directory
376 * @returns {string} the resolved path to the cacheFile
377 */
378 function getCacheFileForDirectory() {
379 return path.join(resolvedCacheFile, ".cache_" + hash(cwd));
380 }
381
382 let fileStats;
383
384 try {
385 fileStats = fs.lstatSync(resolvedCacheFile);
386 } catch (ex) {
387 fileStats = null;
388 }
389
390
391 /*
392 * in case the file exists we need to verify if the provided path
393 * is a directory or a file. If it is a directory we want to create a file
394 * inside that directory
395 */
396 if (fileStats) {
397
398 /*
399 * is a directory or is a file, but the original file the user provided
400 * looks like a directory but `path.resolve` removed the `last path.sep`
401 * so we need to still treat this like a directory
402 */
403 if (fileStats.isDirectory() || looksLikeADirectory) {
404 return getCacheFileForDirectory();
405 }
406
407 // is file so just use that file
408 return resolvedCacheFile;
409 }
410
411 /*
412 * here we known the file or directory doesn't exist,
413 * so we will try to infer if its a directory if it looks like a directory
414 * for the current operating system.
415 */
416
417 // if the last character passed is a path separator we assume is a directory
418 if (looksLikeADirectory) {
419 return getCacheFileForDirectory();
420 }
421
422 return resolvedCacheFile;
423}
424
425//------------------------------------------------------------------------------
426// Public Interface
427//------------------------------------------------------------------------------
428
429/**
430 * Creates a new instance of the core CLI engine.
431 * @param {CLIEngineOptions} options The options for this instance.
432 * @constructor
433 */
434function CLIEngine(options) {
435
436 options = Object.assign(
437 Object.create(null),
438 defaultOptions,
439 {cwd: process.cwd()},
440 options
441 );
442
443 /**
444 * Stored options for this instance
445 * @type {Object}
446 */
447 this.options = options;
448
449 const cacheFile = getCacheFile(this.options.cacheLocation || this.options.cacheFile, this.options.cwd);
450
451 /**
452 * Cache used to avoid operating on files that haven't changed since the
453 * last successful execution (e.g., file passed linting with no errors and
454 * no warnings).
455 * @type {Object}
456 */
457 this._fileCache = fileEntryCache.create(cacheFile);
458
459 // load in additional rules
460 if (this.options.rulePaths) {
461 const cwd = this.options.cwd;
462
463 this.options.rulePaths.forEach(function(rulesdir) {
464 debug("Loading rules from " + rulesdir);
465 rules.load(rulesdir, cwd);
466 });
467 }
468
469 Object.keys(this.options.rules || {}).forEach(function(name) {
470 validator.validateRuleOptions(name, this.options.rules[name], "CLI");
471 }.bind(this));
472}
473
474/**
475 * Returns the formatter representing the given format or null if no formatter
476 * with the given name can be found.
477 * @param {string} [format] The name of the format to load or the path to a
478 * custom formatter.
479 * @returns {Function} The formatter function or null if not found.
480 */
481CLIEngine.getFormatter = function(format) {
482
483 let formatterPath;
484
485 // default is stylish
486 format = format || "stylish";
487
488 // only strings are valid formatters
489 if (typeof format === "string") {
490
491 // replace \ with / for Windows compatibility
492 format = format.replace(/\\/g, "/");
493
494 // if there's a slash, then it's a file
495 if (format.indexOf("/") > -1) {
496 const cwd = this.options ? this.options.cwd : process.cwd();
497
498 formatterPath = path.resolve(cwd, format);
499 } else {
500 formatterPath = "./formatters/" + format;
501 }
502
503 try {
504 return require(formatterPath);
505 } catch (ex) {
506 ex.message = "There was a problem loading formatter: " + formatterPath + "\nError: " + ex.message;
507 throw ex;
508 }
509
510 } else {
511 return null;
512 }
513};
514
515/**
516 * Returns results that only contains errors.
517 * @param {LintResult[]} results The results to filter.
518 * @returns {LintResult[]} The filtered results.
519 */
520CLIEngine.getErrorResults = function(results) {
521 const filtered = [];
522
523 results.forEach(function(result) {
524 const filteredMessages = result.messages.filter(isErrorMessage);
525
526 if (filteredMessages.length > 0) {
527 filtered.push({
528 filePath: result.filePath,
529 messages: filteredMessages,
530 errorCount: filteredMessages.length,
531 warningCount: 0
532 });
533 }
534 });
535
536 return filtered;
537};
538
539/**
540 * Outputs fixes from the given results to files.
541 * @param {Object} report The report object created by CLIEngine.
542 * @returns {void}
543 */
544CLIEngine.outputFixes = function(report) {
545 report.results.filter(function(result) {
546 return result.hasOwnProperty("output");
547 }).forEach(function(result) {
548 fs.writeFileSync(result.filePath, result.output);
549 });
550};
551
552CLIEngine.prototype = {
553
554 constructor: CLIEngine,
555
556 /**
557 * Add a plugin by passing it's configuration
558 * @param {string} name Name of the plugin.
559 * @param {Object} pluginobject Plugin configuration object.
560 * @returns {void}
561 */
562 addPlugin(name, pluginobject) {
563 Plugins.define(name, pluginobject);
564 },
565
566 /**
567 * Resolves the patterns passed into executeOnFiles() into glob-based patterns
568 * for easier handling.
569 * @param {string[]} patterns The file patterns passed on the command line.
570 * @returns {string[]} The equivalent glob patterns.
571 */
572 resolveFileGlobPatterns(patterns) {
573 return globUtil.resolveFileGlobPatterns(patterns, this.options);
574 },
575
576 /**
577 * Executes the current configuration on an array of file and directory names.
578 * @param {string[]} patterns An array of file and directory names.
579 * @returns {Object} The results for all files that were linted.
580 */
581 executeOnFiles(patterns) {
582 const results = [],
583 options = this.options,
584 fileCache = this._fileCache,
585 configHelper = new Config(options);
586 let prevConfig; // the previous configuration used
587
588 /**
589 * Calculates the hash of the config file used to validate a given file
590 * @param {string} filename The path of the file to retrieve a config object for to calculate the hash
591 * @returns {string} the hash of the config
592 */
593 function hashOfConfigFor(filename) {
594 const config = configHelper.getConfig(filename);
595
596 if (!prevConfig) {
597 prevConfig = {};
598 }
599
600 // reuse the previously hashed config if the config hasn't changed
601 if (prevConfig.config !== config) {
602
603 /*
604 * config changed so we need to calculate the hash of the config
605 * and the hash of the plugins being used
606 */
607 prevConfig.config = config;
608
609 const eslintVersion = pkg.version;
610
611 prevConfig.hash = hash(eslintVersion + "_" + stringify(config));
612 }
613
614 return prevConfig.hash;
615 }
616
617 /**
618 * Executes the linter on a file defined by the `filename`. Skips
619 * unsupported file extensions and any files that are already linted.
620 * @param {string} filename The resolved filename of the file to be linted
621 * @param {boolean} warnIgnored always warn when a file is ignored
622 * @returns {void}
623 */
624 function executeOnFile(filename, warnIgnored) {
625 let hashOfConfig,
626 descriptor;
627
628 if (warnIgnored) {
629 results.push(createIgnoreResult(filename, options.cwd));
630 return;
631 }
632
633 if (options.cache) {
634
635 /*
636 * get the descriptor for this file
637 * with the metadata and the flag that determines if
638 * the file has changed
639 */
640 descriptor = fileCache.getFileDescriptor(filename);
641 const meta = descriptor.meta || {};
642
643 hashOfConfig = hashOfConfigFor(filename);
644
645 const changed = descriptor.changed || meta.hashOfConfig !== hashOfConfig;
646
647 if (!changed) {
648 debug("Skipping file since hasn't changed: " + filename);
649
650 /*
651 * Add the the cached results (always will be 0 error and
652 * 0 warnings). We should not cache results for files that
653 * failed, in order to guarantee that next execution will
654 * process those files as well.
655 */
656 results.push(descriptor.meta.results);
657
658 // move to the next file
659 return;
660 }
661 } else {
662 fileCache.destroy();
663 }
664
665 debug("Processing " + filename);
666
667 const res = processFile(filename, configHelper, options);
668
669 if (options.cache) {
670
671 /*
672 * if a file contains errors or warnings we don't want to
673 * store the file in the cache so we can guarantee that
674 * next execution will also operate on this file
675 */
676 if (res.errorCount > 0 || res.warningCount > 0) {
677 debug("File has problems, skipping it: " + filename);
678
679 // remove the entry from the cache
680 fileCache.removeEntry(filename);
681 } else {
682
683 /*
684 * since the file passed we store the result here
685 * TODO: check this as we might not need to store the
686 * successful runs as it will always should be 0 errors and
687 * 0 warnings.
688 */
689 descriptor.meta.hashOfConfig = hashOfConfig;
690 descriptor.meta.results = res;
691 }
692 }
693
694 results.push(res);
695 }
696
697 const startTime = Date.now();
698
699
700
701 patterns = this.resolveFileGlobPatterns(patterns);
702 const fileList = globUtil.listFilesToProcess(patterns, options);
703
704 fileList.forEach(function(fileInfo) {
705 executeOnFile(fileInfo.filename, fileInfo.ignored);
706 });
707
708 const stats = calculateStatsPerRun(results);
709
710 if (options.cache) {
711
712 // persist the cache to disk
713 fileCache.reconcile();
714 }
715
716 debug("Linting complete in: " + (Date.now() - startTime) + "ms");
717
718 return {
719 results,
720 errorCount: stats.errorCount,
721 warningCount: stats.warningCount
722 };
723 },
724
725 /**
726 * Executes the current configuration on text.
727 * @param {string} text A string of JavaScript code to lint.
728 * @param {string} filename An optional string representing the texts filename.
729 * @param {boolean} warnIgnored Always warn when a file is ignored
730 * @returns {Object} The results for the linting.
731 */
732 executeOnText(text, filename, warnIgnored) {
733
734 const results = [],
735 options = this.options,
736 configHelper = new Config(options),
737 ignoredPaths = new IgnoredPaths(options);
738
739 // resolve filename based on options.cwd (for reporting, ignoredPaths also resolves)
740 if (filename && !path.isAbsolute(filename)) {
741 filename = path.resolve(options.cwd, filename);
742 }
743
744 if (filename && ignoredPaths.contains(filename)) {
745 if (warnIgnored) {
746 results.push(createIgnoreResult(filename, options.cwd));
747 }
748 } else {
749 results.push(processText(text, configHelper, filename, options.fix, options.allowInlineConfig));
750 }
751
752 const stats = calculateStatsPerRun(results);
753
754 return {
755 results,
756 errorCount: stats.errorCount,
757 warningCount: stats.warningCount
758 };
759 },
760
761 /**
762 * Returns a configuration object for the given file based on the CLI options.
763 * This is the same logic used by the ESLint CLI executable to determine
764 * configuration for each file it processes.
765 * @param {string} filePath The path of the file to retrieve a config object for.
766 * @returns {Object} A configuration object for the file.
767 */
768 getConfigForFile(filePath) {
769 const configHelper = new Config(this.options);
770
771 return configHelper.getConfig(filePath);
772 },
773
774 /**
775 * Checks if a given path is ignored by ESLint.
776 * @param {string} filePath The path of the file to check.
777 * @returns {boolean} Whether or not the given path is ignored.
778 */
779 isPathIgnored(filePath) {
780 const resolvedPath = path.resolve(this.options.cwd, filePath);
781 const ignoredPaths = new IgnoredPaths(this.options);
782
783 return ignoredPaths.contains(resolvedPath);
784 },
785
786 getFormatter: CLIEngine.getFormatter
787
788};
789
790module.exports = CLIEngine;