/** * @fileoverview Main CLI object. * @author Nicholas C. Zakas */ "use strict"; /* * The CLI object should *not* call process.exit() directly. It should only return * exit codes. This allows other programs to use the CLI object and still control * when the program exits. */ //------------------------------------------------------------------------------ // Requirements //------------------------------------------------------------------------------ const fs = require("fs"), path = require("path"), defaultOptions = require("../conf/default-cli-options"), Linter = require("./linter"), IgnoredPaths = require("./ignored-paths"), Config = require("./config"), fileEntryCache = require("file-entry-cache"), globUtil = require("./util/glob-util"), validator = require("./config/config-validator"), stringify = require("json-stable-stringify-without-jsonify"), hash = require("./util/hash"), ModuleResolver = require("./util/module-resolver"), naming = require("./util/naming"), pkg = require("../package.json"); const debug = require("debug")("eslint:cli-engine"); const resolver = new ModuleResolver(); //------------------------------------------------------------------------------ // Typedefs //------------------------------------------------------------------------------ /** * The options to configure a CLI engine with. * @typedef {Object} CLIEngineOptions * @property {boolean} allowInlineConfig Enable or disable inline configuration comments. * @property {boolean|Object} baseConfig Base config object. True enables recommend rules and environments. * @property {boolean} cache Enable result caching. * @property {string} cacheLocation The cache file to use instead of .eslintcache. * @property {string} configFile The configuration file to use. * @property {string} cwd The value to use for the current working directory. * @property {string[]} envs An array of environments to load. * @property {string[]} extensions An array of file extensions to check. * @property {boolean|Function} fix Execute in autofix mode. If a function, should return a boolean. * @property {string[]} globals An array of global variables to declare. * @property {boolean} ignore False disables use of .eslintignore. * @property {string} ignorePath The ignore file to use instead of .eslintignore. * @property {string} ignorePattern A glob pattern of files to ignore. * @property {boolean} useEslintrc False disables looking for .eslintrc * @property {string} parser The name of the parser to use. * @property {Object} parserOptions An object of parserOption settings to use. * @property {string[]} plugins An array of plugins to load. * @property {Object<string,*>} rules An object of rules to use. * @property {string[]} rulePaths An array of directories to load custom rules from. * @property {boolean} reportUnusedDisableDirectives `true` adds reports for unused eslint-disable directives */ /** * A linting warning or error. * @typedef {Object} LintMessage * @property {string} message The message to display to the user. */ /** * A linting result. * @typedef {Object} LintResult * @property {string} filePath The path to the file that was linted. * @property {LintMessage[]} messages All of the messages for the result. * @property {number} errorCount Number of errors for the result. * @property {number} warningCount Number of warnings for the result. * @property {number} fixableErrorCount Number of fixable errors for the result. * @property {number} fixableWarningCount Number of fixable warnings for the result. * @property {string=} [source] The source code of the file that was linted. * @property {string=} [output] The source code of the file that was linted, with as many fixes applied as possible. */ //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ /** * It will calculate the error and warning count for collection of messages per file * @param {Object[]} messages - Collection of messages * @returns {Object} Contains the stats * @private */ function calculateStatsPerFile(messages) { return messages.reduce((stat, message) => { if (message.fatal || message.severity === 2) { stat.errorCount++; if (message.fix) { stat.fixableErrorCount++; } } else { stat.warningCount++; if (message.fix) { stat.fixableWarningCount++; } } return stat; }, { errorCount: 0, warningCount: 0, fixableErrorCount: 0, fixableWarningCount: 0 }); } /** * It will calculate the error and warning count for collection of results from all files * @param {Object[]} results - Collection of messages from all the files * @returns {Object} Contains the stats * @private */ function calculateStatsPerRun(results) { return results.reduce((stat, result) => { stat.errorCount += result.errorCount; stat.warningCount += result.warningCount; stat.fixableErrorCount += result.fixableErrorCount; stat.fixableWarningCount += result.fixableWarningCount; return stat; }, { errorCount: 0, warningCount: 0, fixableErrorCount: 0, fixableWarningCount: 0 }); } /** * Processes an source code using ESLint. * @param {string} text The source code to check. * @param {Object} configHelper The configuration options for ESLint. * @param {string} filename An optional string representing the texts filename. * @param {boolean|Function} fix Indicates if fixes should be processed. * @param {boolean} allowInlineConfig Allow/ignore comments that change config. * @param {boolean} reportUnusedDisableDirectives Allow/ignore comments that change config. * @param {Linter} linter Linter context * @returns {LintResult} The results for linting on this text. * @private */ function processText(text, configHelper, filename, fix, allowInlineConfig, reportUnusedDisableDirectives, linter) { let filePath, fileExtension, processor; if (filename) { filePath = path.resolve(filename); fileExtension = path.extname(filename); } filename = filename || "<text>"; debug(`Linting ${filename}`); const config = configHelper.getConfig(filePath); if (config.plugins) { configHelper.plugins.loadAll(config.plugins); } const loadedPlugins = configHelper.plugins.getAll(); for (const plugin in loadedPlugins) { if (loadedPlugins[plugin].processors && Object.keys(loadedPlugins[plugin].processors).indexOf(fileExtension) >= 0) { processor = loadedPlugins[plugin].processors[fileExtension]; break; } } const autofixingEnabled = typeof fix !== "undefined" && (!processor || processor.supportsAutofix); const fixedResult = linter.verifyAndFix(text, config, { filename, allowInlineConfig, reportUnusedDisableDirectives, fix: !!autofixingEnabled && fix, preprocess: processor && (rawText => processor.preprocess(rawText, filename)), postprocess: processor && (problemLists => processor.postprocess(problemLists, filename)) }); const stats = calculateStatsPerFile(fixedResult.messages); const result = { filePath: filename, messages: fixedResult.messages, errorCount: stats.errorCount, warningCount: stats.warningCount, fixableErrorCount: stats.fixableErrorCount, fixableWarningCount: stats.fixableWarningCount }; if (fixedResult.fixed) { result.output = fixedResult.output; } if (result.errorCount + result.warningCount > 0 && typeof result.output === "undefined") { result.source = text; } return result; } /** * Processes an individual file using ESLint. Files used here are known to * exist, so no need to check that here. * @param {string} filename The filename of the file being checked. * @param {Object} configHelper The configuration options for ESLint. * @param {Object} options The CLIEngine options object. * @param {Linter} linter Linter context * @returns {LintResult} The results for linting on this file. * @private */ function processFile(filename, configHelper, options, linter) { const text = fs.readFileSync(path.resolve(filename), "utf8"), result = processText( text, configHelper, filename, options.fix, options.allowInlineConfig, options.reportUnusedDisableDirectives, linter ); return result; } /** * Returns result with warning by ignore settings * @param {string} filePath - File path of checked code * @param {string} baseDir - Absolute path of base directory * @returns {LintResult} Result with single warning * @private */ function createIgnoreResult(filePath, baseDir) { let message; const isHidden = /^\./.test(path.basename(filePath)); const isInNodeModules = baseDir && path.relative(baseDir, filePath).startsWith("node_modules"); const isInBowerComponents = baseDir && path.relative(baseDir, filePath).startsWith("bower_components"); if (isHidden) { message = "File ignored by default. Use a negated ignore pattern (like \"--ignore-pattern '!<relative/path/to/filename>'\") to override."; } else if (isInNodeModules) { message = "File ignored by default. Use \"--ignore-pattern '!node_modules/*'\" to override."; } else if (isInBowerComponents) { message = "File ignored by default. Use \"--ignore-pattern '!bower_components/*'\" to override."; } else { message = "File ignored because of a matching ignore pattern. Use \"--no-ignore\" to override."; } return { filePath: path.resolve(filePath), messages: [ { fatal: false, severity: 1, message } ], errorCount: 0, warningCount: 1, fixableErrorCount: 0, fixableWarningCount: 0 }; } /** * Checks if the given message is an error message. * @param {Object} message The message to check. * @returns {boolean} Whether or not the message is an error message. * @private */ function isErrorMessage(message) { return message.severity === 2; } /** * return the cacheFile to be used by eslint, based on whether the provided parameter is * a directory or looks like a directory (ends in `path.sep`), in which case the file * name will be the `cacheFile/.cache_hashOfCWD` * * if cacheFile points to a file or looks like a file then in will just use that file * * @param {string} cacheFile The name of file to be used to store the cache * @param {string} cwd Current working directory * @returns {string} the resolved path to the cache file */ function getCacheFile(cacheFile, cwd) { /* * make sure the path separators are normalized for the environment/os * keeping the trailing path separator if present */ cacheFile = path.normalize(cacheFile); const resolvedCacheFile = path.resolve(cwd, cacheFile); const looksLikeADirectory = cacheFile[cacheFile.length - 1] === path.sep; /** * return the name for the cache file in case the provided parameter is a directory * @returns {string} the resolved path to the cacheFile */ function getCacheFileForDirectory() { return path.join(resolvedCacheFile, `.cache_${hash(cwd)}`); } let fileStats; try { fileStats = fs.lstatSync(resolvedCacheFile); } catch (ex) { fileStats = null; } /* * in case the file exists we need to verify if the provided path * is a directory or a file. If it is a directory we want to create a file * inside that directory */ if (fileStats) { /* * is a directory or is a file, but the original file the user provided * looks like a directory but `path.resolve` removed the `last path.sep` * so we need to still treat this like a directory */ if (fileStats.isDirectory() || looksLikeADirectory) { return getCacheFileForDirectory(); } // is file so just use that file return resolvedCacheFile; } /* * here we known the file or directory doesn't exist, * so we will try to infer if its a directory if it looks like a directory * for the current operating system. */ // if the last character passed is a path separator we assume is a directory if (looksLikeADirectory) { return getCacheFileForDirectory(); } return resolvedCacheFile; } const configHashCache = new WeakMap(); //------------------------------------------------------------------------------ // Public Interface //------------------------------------------------------------------------------ class CLIEngine { /** * Creates a new instance of the core CLI engine. * @param {CLIEngineOptions} options The options for this instance. * @constructor */ constructor(options) { options = Object.assign( Object.create(null), defaultOptions, { cwd: process.cwd() }, options ); /** * Stored options for this instance * @type {Object} */ this.options = options; this.linter = new Linter(); if (options.cache) { const cacheFile = getCacheFile(this.options.cacheLocation || this.options.cacheFile, this.options.cwd); /** * Cache used to avoid operating on files that haven't changed since the * last successful execution (e.g., file passed linting with no errors and * no warnings). * @type {Object} */ this._fileCache = fileEntryCache.create(cacheFile); } // load in additional rules if (this.options.rulePaths) { const cwd = this.options.cwd; this.options.rulePaths.forEach(rulesdir => { debug(`Loading rules from ${rulesdir}`); this.linter.rules.load(rulesdir, cwd); }); } if (this.options.rules && Object.keys(this.options.rules).length) { const loadedRules = this.linter.getRules(); Object.keys(this.options.rules).forEach(name => { validator.validateRuleOptions(loadedRules.get(name), name, this.options.rules[name], "CLI"); }); } this.config = new Config(this.options, this.linter); } getRules() { return this.linter.getRules(); } /** * Returns results that only contains errors. * @param {LintResult[]} results The results to filter. * @returns {LintResult[]} The filtered results. */ static getErrorResults(results) { const filtered = []; results.forEach(result => { const filteredMessages = result.messages.filter(isErrorMessage); if (filteredMessages.length > 0) { filtered.push( Object.assign(result, { messages: filteredMessages, errorCount: filteredMessages.length, warningCount: 0, fixableErrorCount: result.fixableErrorCount, fixableWarningCount: 0 }) ); } }); return filtered; } /** * Outputs fixes from the given results to files. * @param {Object} report The report object created by CLIEngine. * @returns {void} */ static outputFixes(report) { report.results.filter(result => result.hasOwnProperty("output")).forEach(result => { fs.writeFileSync(result.filePath, result.output); }); } /** * Add a plugin by passing its configuration * @param {string} name Name of the plugin. * @param {Object} pluginobject Plugin configuration object. * @returns {void} */ addPlugin(name, pluginobject) { this.config.plugins.define(name, pluginobject); } /** * Resolves the patterns passed into executeOnFiles() into glob-based patterns * for easier handling. * @param {string[]} patterns The file patterns passed on the command line. * @returns {string[]} The equivalent glob patterns. */ resolveFileGlobPatterns(patterns) { return globUtil.resolveFileGlobPatterns(patterns, this.options); } /** * Executes the current configuration on an array of file and directory names. * @param {string[]} patterns An array of file and directory names. * @returns {Object} The results for all files that were linted. */ executeOnFiles(patterns) { const options = this.options, fileCache = this._fileCache, configHelper = this.config; const cacheFile = getCacheFile(this.options.cacheLocation || this.options.cacheFile, this.options.cwd); if (!options.cache && fs.existsSync(cacheFile)) { fs.unlinkSync(cacheFile); } /** * Calculates the hash of the config file used to validate a given file * @param {string} filename The path of the file to retrieve a config object for to calculate the hash * @returns {string} the hash of the config */ function hashOfConfigFor(filename) { const config = configHelper.getConfig(filename); if (!configHashCache.has(config)) { configHashCache.set(config, hash(`${pkg.version}_${stringify(config)}`)); } return configHashCache.get(config); } const startTime = Date.now(); const fileList = globUtil.listFilesToProcess(this.resolveFileGlobPatterns(patterns), options); const results = fileList.map(fileInfo => { if (fileInfo.ignored) { return createIgnoreResult(fileInfo.filename, options.cwd); } if (options.cache) { /* * get the descriptor for this file * with the metadata and the flag that determines if * the file has changed */ const descriptor = fileCache.getFileDescriptor(fileInfo.filename); const hashOfConfig = hashOfConfigFor(fileInfo.filename); const changed = descriptor.changed || descriptor.meta.hashOfConfig !== hashOfConfig; if (!changed) { debug(`Skipping file since hasn't changed: ${fileInfo.filename}`); /* * Add the the cached results (always will be 0 error and * 0 warnings). We should not cache results for files that * failed, in order to guarantee that next execution will * process those files as well. */ return descriptor.meta.results; } } debug(`Processing ${fileInfo.filename}`); return processFile(fileInfo.filename, configHelper, options, this.linter); }); if (options.cache) { results.forEach(result => { if (result.messages.length) { /* * if a file contains errors or warnings we don't want to * store the file in the cache so we can guarantee that * next execution will also operate on this file */ fileCache.removeEntry(result.filePath); } else { /* * since the file passed we store the result here * TODO: it might not be necessary to store the results list in the cache, * since it should always be 0 errors/warnings */ const descriptor = fileCache.getFileDescriptor(result.filePath); descriptor.meta.hashOfConfig = hashOfConfigFor(result.filePath); descriptor.meta.results = result; } }); // persist the cache to disk fileCache.reconcile(); } const stats = calculateStatsPerRun(results); debug(`Linting complete in: ${Date.now() - startTime}ms`); return { results, errorCount: stats.errorCount, warningCount: stats.warningCount, fixableErrorCount: stats.fixableErrorCount, fixableWarningCount: stats.fixableWarningCount }; } /** * Executes the current configuration on text. * @param {string} text A string of JavaScript code to lint. * @param {string} filename An optional string representing the texts filename. * @param {boolean} warnIgnored Always warn when a file is ignored * @returns {Object} The results for the linting. */ executeOnText(text, filename, warnIgnored) { const results = [], options = this.options, configHelper = this.config, ignoredPaths = new IgnoredPaths(options); // resolve filename based on options.cwd (for reporting, ignoredPaths also resolves) if (filename && !path.isAbsolute(filename)) { filename = path.resolve(options.cwd, filename); } if (filename && ignoredPaths.contains(filename)) { if (warnIgnored) { results.push(createIgnoreResult(filename, options.cwd)); } } else { results.push( processText( text, configHelper, filename, options.fix, options.allowInlineConfig, options.reportUnusedDisableDirectives, this.linter ) ); } const stats = calculateStatsPerRun(results); return { results, errorCount: stats.errorCount, warningCount: stats.warningCount, fixableErrorCount: stats.fixableErrorCount, fixableWarningCount: stats.fixableWarningCount }; } /** * Returns a configuration object for the given file based on the CLI options. * This is the same logic used by the ESLint CLI executable to determine * configuration for each file it processes. * @param {string} filePath The path of the file to retrieve a config object for. * @returns {Object} A configuration object for the file. */ getConfigForFile(filePath) { const configHelper = this.config; return configHelper.getConfig(filePath); } /** * Checks if a given path is ignored by ESLint. * @param {string} filePath The path of the file to check. * @returns {boolean} Whether or not the given path is ignored. */ isPathIgnored(filePath) { const resolvedPath = path.resolve(this.options.cwd, filePath); const ignoredPaths = new IgnoredPaths(this.options); return ignoredPaths.contains(resolvedPath); } /** * Returns the formatter representing the given format or null if no formatter * with the given name can be found. * @param {string} [format] The name of the format to load or the path to a * custom formatter. * @returns {Function} The formatter function or null if not found. */ getFormatter(format) { // default is stylish format = format || "stylish"; // only strings are valid formatters if (typeof format === "string") { // replace \ with / for Windows compatibility format = format.replace(/\\/g, "/"); const cwd = this.options ? this.options.cwd : process.cwd(); const namespace = naming.getNamespaceFromTerm(format); let formatterPath; // if there's a slash, then it's a file if (!namespace && format.indexOf("/") > -1) { formatterPath = path.resolve(cwd, format); } else { try { const npmFormat = naming.normalizePackageName(format, "eslint-formatter"); formatterPath = resolver.resolve(npmFormat, `${cwd}/node_modules`); } catch (e) { formatterPath = `./formatters/${format}`; } } try { return require(formatterPath); } catch (ex) { ex.message = `There was a problem loading formatter: ${formatterPath}\nError: ${ex.message}`; throw ex; } } else { return null; } } } CLIEngine.version = pkg.version; CLIEngine.getFormatter = CLIEngine.prototype.getFormatter; module.exports = CLIEngine;