/** * @fileoverview Helper functions for ESLint class * @author Nicholas C. Zakas */ "use strict"; //----------------------------------------------------------------------------- // Requirements //----------------------------------------------------------------------------- const path = require("path"); const fs = require("fs"); const fsp = fs.promises; const isGlob = require("is-glob"); const hash = require("../cli-engine/hash"); const minimatch = require("minimatch"); const util = require("util"); const fswalk = require("@nodelib/fs.walk"); const globParent = require("glob-parent"); const isPathInside = require("is-path-inside"); //----------------------------------------------------------------------------- // Fixup references //----------------------------------------------------------------------------- const doFsWalk = util.promisify(fswalk.walk); const Minimatch = minimatch.Minimatch; const MINIMATCH_OPTIONS = { dot: true }; //----------------------------------------------------------------------------- // Types //----------------------------------------------------------------------------- /** * @typedef {Object} GlobSearch * @property {Array} patterns The normalized patterns to use for a search. * @property {Array} rawPatterns The patterns as entered by the user * before doing any normalization. */ //----------------------------------------------------------------------------- // Errors //----------------------------------------------------------------------------- /** * The error type when no files match a glob. */ class NoFilesFoundError extends Error { /** * @param {string} pattern The glob pattern which was not found. * @param {boolean} globEnabled If `false` then the pattern was a glob pattern, but glob was disabled. */ constructor(pattern, globEnabled) { super(`No files matching '${pattern}' were found${!globEnabled ? " (glob was disabled)" : ""}.`); this.messageTemplate = "file-not-found"; this.messageData = { pattern, globDisabled: !globEnabled }; } } /** * The error type when a search fails to match multiple patterns. */ class UnmatchedSearchPatternsError extends Error { /** * @param {Object} options The options for the error. * @param {string} options.basePath The directory that was searched. * @param {Array} options.unmatchedPatterns The glob patterns * which were not found. * @param {Array} options.patterns The glob patterns that were * searched. * @param {Array} options.rawPatterns The raw glob patterns that * were searched. */ constructor({ basePath, unmatchedPatterns, patterns, rawPatterns }) { super(`No files matching '${rawPatterns}' in '${basePath}' were found.`); this.basePath = basePath; this.unmatchedPatterns = unmatchedPatterns; this.patterns = patterns; this.rawPatterns = rawPatterns; } } /** * The error type when there are files matched by a glob, but all of them have been ignored. */ class AllFilesIgnoredError extends Error { /** * @param {string} pattern The glob pattern which was not found. */ constructor(pattern) { super(`All files matched by '${pattern}' are ignored.`); this.messageTemplate = "all-files-ignored"; this.messageData = { pattern }; } } //----------------------------------------------------------------------------- // General Helpers //----------------------------------------------------------------------------- /** * Check if a given value is a non-empty string or not. * @param {any} x The value to check. * @returns {boolean} `true` if `x` is a non-empty string. */ function isNonEmptyString(x) { return typeof x === "string" && x.trim() !== ""; } /** * Check if a given value is an array of non-empty strings or not. * @param {any} x The value to check. * @returns {boolean} `true` if `x` is an array of non-empty strings. */ function isArrayOfNonEmptyString(x) { return Array.isArray(x) && x.every(isNonEmptyString); } //----------------------------------------------------------------------------- // File-related Helpers //----------------------------------------------------------------------------- /** * Normalizes slashes in a file pattern to posix-style. * @param {string} pattern The pattern to replace slashes in. * @returns {string} The pattern with slashes normalized. */ function normalizeToPosix(pattern) { return pattern.replace(/\\/gu, "/"); } /** * Check if a string is a glob pattern or not. * @param {string} pattern A glob pattern. * @returns {boolean} `true` if the string is a glob pattern. */ function isGlobPattern(pattern) { return isGlob(path.sep === "\\" ? normalizeToPosix(pattern) : pattern); } /** * Determines if a given glob pattern will return any results. * Used primarily to help with useful error messages. * @param {Object} options The options for the function. * @param {string} options.basePath The directory to search. * @param {string} options.pattern A glob pattern to match. * @returns {Promise} True if there is a glob match, false if not. */ function globMatch({ basePath, pattern }) { let found = false; const patternToUse = path.isAbsolute(pattern) ? normalizeToPosix(path.relative(basePath, pattern)) : pattern; const matcher = new Minimatch(patternToUse, MINIMATCH_OPTIONS); const fsWalkSettings = { deepFilter(entry) { const relativePath = normalizeToPosix(path.relative(basePath, entry.path)); return !found && matcher.match(relativePath, true); }, entryFilter(entry) { if (found || entry.dirent.isDirectory()) { return false; } const relativePath = normalizeToPosix(path.relative(basePath, entry.path)); if (matcher.match(relativePath)) { found = true; return true; } return false; } }; return new Promise(resolve => { // using a stream so we can exit early because we just need one match const globStream = fswalk.walkStream(basePath, fsWalkSettings); globStream.on("data", () => { globStream.destroy(); resolve(true); }); // swallow errors as they're not important here globStream.on("error", () => { }); globStream.on("end", () => { resolve(false); }); globStream.read(); }); } /** * Searches a directory looking for matching glob patterns. This uses * the config array's logic to determine if a directory or file should * be ignored, so it is consistent with how ignoring works throughout * ESLint. * @param {Object} options The options for this function. * @param {string} options.basePath The directory to search. * @param {Array} options.patterns An array of glob patterns * to match. * @param {Array} options.rawPatterns An array of glob patterns * as the user inputted them. Used for errors. * @param {FlatConfigArray} options.configs The config array to use for * determining what to ignore. * @param {boolean} options.errorOnUnmatchedPattern Determines if an error * should be thrown when a pattern is unmatched. * @returns {Promise>} An array of matching file paths * or an empty array if there are no matches. * @throws {UnmatchedSearchPatternsError} If there is a pattern that doesn't * match any files. */ async function globSearch({ basePath, patterns, rawPatterns, configs, errorOnUnmatchedPattern }) { if (patterns.length === 0) { return []; } /* * In this section we are converting the patterns into Minimatch * instances for performance reasons. Because we are doing the same * matches repeatedly, it's best to compile those patterns once and * reuse them multiple times. * * To do that, we convert any patterns with an absolute path into a * relative path and normalize it to Posix-style slashes. We also keep * track of the relative patterns to map them back to the original * patterns, which we need in order to throw an error if there are any * unmatched patterns. */ const relativeToPatterns = new Map(); const matchers = patterns.map((pattern, i) => { const patternToUse = path.isAbsolute(pattern) ? normalizeToPosix(path.relative(basePath, pattern)) : pattern; relativeToPatterns.set(patternToUse, patterns[i]); return new Minimatch(patternToUse, MINIMATCH_OPTIONS); }); /* * We track unmatched patterns because we may want to throw an error when * they occur. To start, this set is initialized with all of the patterns. * Every time a match occurs, the pattern is removed from the set, making * it easy to tell if we have any unmatched patterns left at the end of * search. */ const unmatchedPatterns = new Set([...relativeToPatterns.keys()]); const filePaths = (await doFsWalk(basePath, { deepFilter(entry) { const relativePath = normalizeToPosix(path.relative(basePath, entry.path)); const matchesPattern = matchers.some(matcher => matcher.match(relativePath, true)); return matchesPattern && !configs.isDirectoryIgnored(entry.path); }, entryFilter(entry) { const relativePath = normalizeToPosix(path.relative(basePath, entry.path)); // entries may be directories or files so filter out directories if (entry.dirent.isDirectory()) { return false; } /* * Optimization: We need to track when patterns are left unmatched * and so we use `unmatchedPatterns` to do that. There is a bit of * complexity here because the same file can be matched by more than * one pattern. So, when we start, we actually need to test every * pattern against every file. Once we know there are no remaining * unmatched patterns, then we can switch to just looking for the * first matching pattern for improved speed. */ const matchesPattern = unmatchedPatterns.size > 0 ? matchers.reduce((previousValue, matcher) => { const pathMatches = matcher.match(relativePath); /* * We updated the unmatched patterns set only if the path * matches and the file isn't ignored. If the file is * ignored, that means there wasn't a match for the * pattern so it should not be removed. * * Performance note: isFileIgnored() aggressively caches * results so there is no performance penalty for calling * it twice with the same argument. */ if (pathMatches && !configs.isFileIgnored(entry.path)) { unmatchedPatterns.delete(matcher.pattern); } return pathMatches || previousValue; }, false) : matchers.some(matcher => matcher.match(relativePath)); return matchesPattern && !configs.isFileIgnored(entry.path); } })).map(entry => entry.path); // now check to see if we have any unmatched patterns if (errorOnUnmatchedPattern && unmatchedPatterns.size > 0) { throw new UnmatchedSearchPatternsError({ basePath, unmatchedPatterns: [...unmatchedPatterns].map( pattern => relativeToPatterns.get(pattern) ), patterns, rawPatterns }); } return filePaths; } /** * Throws an error for unmatched patterns. The error will only contain information about the first one. * Checks to see if there are any ignored results for a given search. * @param {Object} options The options for this function. * @param {string} options.basePath The directory to search. * @param {Array} options.patterns An array of glob patterns * that were used in the original search. * @param {Array} options.rawPatterns An array of glob patterns * as the user inputted them. Used for errors. * @param {Array} options.unmatchedPatterns A non-empty array of glob patterns * that were unmatched in the original search. * @returns {void} Always throws an error. * @throws {NoFilesFoundError} If the first unmatched pattern * doesn't match any files even when there are no ignores. * @throws {AllFilesIgnoredError} If the first unmatched pattern * matches some files when there are no ignores. */ async function throwErrorForUnmatchedPatterns({ basePath, patterns, rawPatterns, unmatchedPatterns }) { const pattern = unmatchedPatterns[0]; const rawPattern = rawPatterns[patterns.indexOf(pattern)]; const patternHasMatch = await globMatch({ basePath, pattern }); if (patternHasMatch) { throw new AllFilesIgnoredError(rawPattern); } // if we get here there are truly no matches throw new NoFilesFoundError(rawPattern, true); } /** * Performs multiple glob searches in parallel. * @param {Object} options The options for this function. * @param {Map} options.searches * An array of glob patterns to match. * @param {FlatConfigArray} options.configs The config array to use for * determining what to ignore. * @param {boolean} options.errorOnUnmatchedPattern Determines if an * unmatched glob pattern should throw an error. * @returns {Promise>} An array of matching file paths * or an empty array if there are no matches. */ async function globMultiSearch({ searches, configs, errorOnUnmatchedPattern }) { /* * For convenience, we normalized the search map into an array of objects. * Next, we filter out all searches that have no patterns. This happens * primarily for the cwd, which is prepopulated in the searches map as an * optimization. However, if it has no patterns, it means all patterns * occur outside of the cwd and we can safely filter out that search. */ const normalizedSearches = [...searches].map( ([basePath, { patterns, rawPatterns }]) => ({ basePath, patterns, rawPatterns }) ).filter(({ patterns }) => patterns.length > 0); const results = await Promise.allSettled( normalizedSearches.map( ({ basePath, patterns, rawPatterns }) => globSearch({ basePath, patterns, rawPatterns, configs, errorOnUnmatchedPattern }) ) ); const filePaths = []; for (let i = 0; i < results.length; i++) { const result = results[i]; const currentSearch = normalizedSearches[i]; if (result.status === "fulfilled") { // if the search was successful just add the results if (result.value.length > 0) { filePaths.push(...result.value); } continue; } // if we make it here then there was an error const error = result.reason; // unexpected errors should be re-thrown if (!error.basePath) { throw error; } if (errorOnUnmatchedPattern) { await throwErrorForUnmatchedPatterns({ ...currentSearch, unmatchedPatterns: error.unmatchedPatterns }); } } return [...new Set(filePaths)]; } /** * Finds all files matching the options specified. * @param {Object} args The arguments objects. * @param {Array} args.patterns An array of glob patterns. * @param {boolean} args.globInputPaths true to interpret glob patterns, * false to not interpret glob patterns. * @param {string} args.cwd The current working directory to find from. * @param {FlatConfigArray} args.configs The configs for the current run. * @param {boolean} args.errorOnUnmatchedPattern Determines if an unmatched pattern * should throw an error. * @returns {Promise>} The fully resolved file paths. * @throws {AllFilesIgnoredError} If there are no results due to an ignore pattern. * @throws {NoFilesFoundError} If no files matched the given patterns. */ async function findFiles({ patterns, globInputPaths, cwd, configs, errorOnUnmatchedPattern }) { const results = []; const missingPatterns = []; let globbyPatterns = []; let rawPatterns = []; const searches = new Map([[cwd, { patterns: globbyPatterns, rawPatterns: [] }]]); // check to see if we have explicit files and directories const filePaths = patterns.map(filePath => path.resolve(cwd, filePath)); const stats = await Promise.all( filePaths.map( filePath => fsp.stat(filePath).catch(() => { }) ) ); stats.forEach((stat, index) => { const filePath = filePaths[index]; const pattern = normalizeToPosix(patterns[index]); if (stat) { // files are added directly to the list if (stat.isFile()) { results.push({ filePath, ignored: configs.isFileIgnored(filePath) }); } // directories need extensions attached if (stat.isDirectory()) { // group everything in cwd together and split out others if (isPathInside(filePath, cwd)) { ({ patterns: globbyPatterns, rawPatterns } = searches.get(cwd)); } else { if (!searches.has(filePath)) { searches.set(filePath, { patterns: [], rawPatterns: [] }); } ({ patterns: globbyPatterns, rawPatterns } = searches.get(filePath)); } globbyPatterns.push(`${normalizeToPosix(filePath)}/**`); rawPatterns.push(pattern); } return; } // save patterns for later use based on whether globs are enabled if (globInputPaths && isGlobPattern(pattern)) { const basePath = path.resolve(cwd, globParent(pattern)); // group in cwd if possible and split out others if (isPathInside(basePath, cwd)) { ({ patterns: globbyPatterns, rawPatterns } = searches.get(cwd)); } else { if (!searches.has(basePath)) { searches.set(basePath, { patterns: [], rawPatterns: [] }); } ({ patterns: globbyPatterns, rawPatterns } = searches.get(basePath)); } globbyPatterns.push(filePath); rawPatterns.push(pattern); } else { missingPatterns.push(pattern); } }); // there were patterns that didn't match anything, tell the user if (errorOnUnmatchedPattern && missingPatterns.length) { throw new NoFilesFoundError(missingPatterns[0], globInputPaths); } // now we are safe to do the search const globbyResults = await globMultiSearch({ searches, configs, errorOnUnmatchedPattern }); return [ ...results, ...globbyResults.map(filePath => ({ filePath: path.resolve(filePath), ignored: false })) ]; } //----------------------------------------------------------------------------- // Results-related Helpers //----------------------------------------------------------------------------- /** * Checks if the given message is an error message. * @param {LintMessage} message The message to check. * @returns {boolean} Whether or not the message is an error message. * @private */ function isErrorMessage(message) { return message.severity === 2; } /** * 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 isInNodeModules = baseDir && path.dirname(path.relative(baseDir, filePath)).split(path.sep).includes("node_modules"); if (isInNodeModules) { message = "File ignored by default because it is located under the node_modules directory. Use ignore pattern \"!**/node_modules/\" to disable file ignore settings or use \"--no-warn-ignored\" to suppress this warning."; } else { message = "File ignored because of a matching ignore pattern. Use \"--no-ignore\" to disable file ignore settings or use \"--no-warn-ignored\" to suppress this warning."; } return { filePath: path.resolve(filePath), messages: [ { ruleId: null, fatal: false, severity: 1, message, nodeType: null } ], suppressedMessages: [], errorCount: 0, warningCount: 1, fatalErrorCount: 0, fixableErrorCount: 0, fixableWarningCount: 0 }; } //----------------------------------------------------------------------------- // Options-related Helpers //----------------------------------------------------------------------------- /** * Check if a given value is a valid fix type or not. * @param {any} x The value to check. * @returns {boolean} `true` if `x` is valid fix type. */ function isFixType(x) { return x === "directive" || x === "problem" || x === "suggestion" || x === "layout"; } /** * Check if a given value is an array of fix types or not. * @param {any} x The value to check. * @returns {boolean} `true` if `x` is an array of fix types. */ function isFixTypeArray(x) { return Array.isArray(x) && x.every(isFixType); } /** * The error for invalid options. */ class ESLintInvalidOptionsError extends Error { constructor(messages) { super(`Invalid Options:\n- ${messages.join("\n- ")}`); this.code = "ESLINT_INVALID_OPTIONS"; Error.captureStackTrace(this, ESLintInvalidOptionsError); } } /** * Validates and normalizes options for the wrapped CLIEngine instance. * @param {FlatESLintOptions} options The options to process. * @throws {ESLintInvalidOptionsError} If of any of a variety of type errors. * @returns {FlatESLintOptions} The normalized options. */ function processOptions({ allowInlineConfig = true, // ← we cannot use `overrideConfig.noInlineConfig` instead because `allowInlineConfig` has side-effect that suppress warnings that show inline configs are ignored. baseConfig = null, cache = false, cacheLocation = ".eslintcache", cacheStrategy = "metadata", cwd = process.cwd(), errorOnUnmatchedPattern = true, fix = false, fixTypes = null, // ← should be null by default because if it's an array then it suppresses rules that don't have the `meta.type` property. globInputPaths = true, ignore = true, ignorePatterns = null, overrideConfig = null, overrideConfigFile = null, plugins = {}, warnIgnored = true, ...unknownOptions }) { const errors = []; const unknownOptionKeys = Object.keys(unknownOptions); if (unknownOptionKeys.length >= 1) { errors.push(`Unknown options: ${unknownOptionKeys.join(", ")}`); if (unknownOptionKeys.includes("cacheFile")) { errors.push("'cacheFile' has been removed. Please use the 'cacheLocation' option instead."); } if (unknownOptionKeys.includes("configFile")) { errors.push("'configFile' has been removed. Please use the 'overrideConfigFile' option instead."); } if (unknownOptionKeys.includes("envs")) { errors.push("'envs' has been removed."); } if (unknownOptionKeys.includes("extensions")) { errors.push("'extensions' has been removed."); } if (unknownOptionKeys.includes("resolvePluginsRelativeTo")) { errors.push("'resolvePluginsRelativeTo' has been removed."); } if (unknownOptionKeys.includes("globals")) { errors.push("'globals' has been removed. Please use the 'overrideConfig.languageOptions.globals' option instead."); } if (unknownOptionKeys.includes("ignorePath")) { errors.push("'ignorePath' has been removed."); } if (unknownOptionKeys.includes("ignorePattern")) { errors.push("'ignorePattern' has been removed. Please use the 'overrideConfig.ignorePatterns' option instead."); } if (unknownOptionKeys.includes("parser")) { errors.push("'parser' has been removed. Please use the 'overrideConfig.languageOptions.parser' option instead."); } if (unknownOptionKeys.includes("parserOptions")) { errors.push("'parserOptions' has been removed. Please use the 'overrideConfig.languageOptions.parserOptions' option instead."); } if (unknownOptionKeys.includes("rules")) { errors.push("'rules' has been removed. Please use the 'overrideConfig.rules' option instead."); } if (unknownOptionKeys.includes("rulePaths")) { errors.push("'rulePaths' has been removed. Please define your rules using plugins."); } if (unknownOptionKeys.includes("reportUnusedDisableDirectives")) { errors.push("'reportUnusedDisableDirectives' has been removed. Please use the 'overrideConfig.linterOptions.reportUnusedDisableDirectives' option instead."); } } if (typeof allowInlineConfig !== "boolean") { errors.push("'allowInlineConfig' must be a boolean."); } if (typeof baseConfig !== "object") { errors.push("'baseConfig' must be an object or null."); } if (typeof cache !== "boolean") { errors.push("'cache' must be a boolean."); } if (!isNonEmptyString(cacheLocation)) { errors.push("'cacheLocation' must be a non-empty string."); } if ( cacheStrategy !== "metadata" && cacheStrategy !== "content" ) { errors.push("'cacheStrategy' must be any of \"metadata\", \"content\"."); } if (!isNonEmptyString(cwd) || !path.isAbsolute(cwd)) { errors.push("'cwd' must be an absolute path."); } if (typeof errorOnUnmatchedPattern !== "boolean") { errors.push("'errorOnUnmatchedPattern' must be a boolean."); } if (typeof fix !== "boolean" && typeof fix !== "function") { errors.push("'fix' must be a boolean or a function."); } if (fixTypes !== null && !isFixTypeArray(fixTypes)) { errors.push("'fixTypes' must be an array of any of \"directive\", \"problem\", \"suggestion\", and \"layout\"."); } if (typeof globInputPaths !== "boolean") { errors.push("'globInputPaths' must be a boolean."); } if (typeof ignore !== "boolean") { errors.push("'ignore' must be a boolean."); } if (!isArrayOfNonEmptyString(ignorePatterns) && ignorePatterns !== null) { errors.push("'ignorePatterns' must be an array of non-empty strings or null."); } if (typeof overrideConfig !== "object") { errors.push("'overrideConfig' must be an object or null."); } if (!isNonEmptyString(overrideConfigFile) && overrideConfigFile !== null && overrideConfigFile !== true) { errors.push("'overrideConfigFile' must be a non-empty string, null, or true."); } if (typeof plugins !== "object") { errors.push("'plugins' must be an object or null."); } else if (plugins !== null && Object.keys(plugins).includes("")) { errors.push("'plugins' must not include an empty string."); } if (Array.isArray(plugins)) { errors.push("'plugins' doesn't add plugins to configuration to load. Please use the 'overrideConfig.plugins' option instead."); } if (typeof warnIgnored !== "boolean") { errors.push("'warnIgnored' must be a boolean."); } if (errors.length > 0) { throw new ESLintInvalidOptionsError(errors); } return { allowInlineConfig, baseConfig, cache, cacheLocation, cacheStrategy, // when overrideConfigFile is true that means don't do config file lookup configFile: overrideConfigFile === true ? false : overrideConfigFile, overrideConfig, cwd: path.normalize(cwd), errorOnUnmatchedPattern, fix, fixTypes, globInputPaths, ignore, ignorePatterns, warnIgnored }; } //----------------------------------------------------------------------------- // Cache-related helpers //----------------------------------------------------------------------------- /** * 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 */ const normalizedCacheFile = path.normalize(cacheFile); const resolvedCacheFile = path.resolve(cwd, normalizedCacheFile); const looksLikeADirectory = normalizedCacheFile.slice(-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 { 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; } //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- module.exports = { isGlobPattern, findFiles, isNonEmptyString, isArrayOfNonEmptyString, createIgnoreResult, isErrorMessage, processOptions, getCacheFile };