1/** 2 * @fileoverview `FileEnumerator` class. 3 * 4 * `FileEnumerator` class has two responsibilities: 5 * 6 * 1. Find target files by processing glob patterns. 7 * 2. Tie each target file and appropriate configuration. 8 * 9 * It provides a method: 10 * 11 * - `iterateFiles(patterns)` 12 * Iterate files which are matched by given patterns together with the 13 * corresponded configuration. This is for `CLIEngine#executeOnFiles()`. 14 * While iterating files, it loads the configuration file of each directory 15 * before iterate files on the directory, so we can use the configuration 16 * files to determine target files. 17 * 18 * @example 19 * const enumerator = new FileEnumerator(); 20 * const linter = new Linter(); 21 * 22 * for (const { config, filePath } of enumerator.iterateFiles(["*.js"])) { 23 * const code = fs.readFileSync(filePath, "utf8"); 24 * const messages = linter.verify(code, config, filePath); 25 * 26 * console.log(messages); 27 * } 28 * 29 * @author Toru Nagashima <https://github.com/mysticatea> 30 */ 31"use strict"; 32 33//------------------------------------------------------------------------------ 34// Requirements 35//------------------------------------------------------------------------------ 36 37const fs = require("fs"); 38const path = require("path"); 39const getGlobParent = require("glob-parent"); 40const isGlob = require("is-glob"); 41const { escapeRegExp } = require("lodash"); 42const { Minimatch } = require("minimatch"); 43const { IgnorePattern } = require("./config-array"); 44const { CascadingConfigArrayFactory } = require("./cascading-config-array-factory"); 45const debug = require("debug")("eslint:file-enumerator"); 46 47//------------------------------------------------------------------------------ 48// Helpers 49//------------------------------------------------------------------------------ 50 51const minimatchOpts = { dot: true, matchBase: true }; 52const dotfilesPattern = /(?:(?:^\.)|(?:[/\\]\.))[^/\\.].*/u; 53const NONE = 0; 54const IGNORED_SILENTLY = 1; 55const IGNORED = 2; 56 57// For VSCode intellisense 58/** @typedef {ReturnType<CascadingConfigArrayFactory["getConfigArrayForFile"]>} ConfigArray */ 59 60/** 61 * @typedef {Object} FileEnumeratorOptions 62 * @property {CascadingConfigArrayFactory} [configArrayFactory] The factory for config arrays. 63 * @property {string} [cwd] The base directory to start lookup. 64 * @property {string[]} [extensions] The extensions to match files for directory patterns. 65 * @property {boolean} [globInputPaths] Set to false to skip glob resolution of input file paths to lint (default: true). If false, each input file paths is assumed to be a non-glob path to an existing file. 66 * @property {boolean} [ignore] The flag to check ignored files. 67 * @property {string[]} [rulePaths] The value of `--rulesdir` option. 68 */ 69 70/** 71 * @typedef {Object} FileAndConfig 72 * @property {string} filePath The path to a target file. 73 * @property {ConfigArray} config The config entries of that file. 74 * @property {boolean} ignored If `true` then this file should be ignored and warned because it was directly specified. 75 */ 76 77/** 78 * @typedef {Object} FileEntry 79 * @property {string} filePath The path to a target file. 80 * @property {ConfigArray} config The config entries of that file. 81 * @property {NONE|IGNORED_SILENTLY|IGNORED} flag The flag. 82 * - `NONE` means the file is a target file. 83 * - `IGNORED_SILENTLY` means the file should be ignored silently. 84 * - `IGNORED` means the file should be ignored and warned because it was directly specified. 85 */ 86 87/** 88 * @typedef {Object} FileEnumeratorInternalSlots 89 * @property {CascadingConfigArrayFactory} configArrayFactory The factory for config arrays. 90 * @property {string} cwd The base directory to start lookup. 91 * @property {RegExp|null} extensionRegExp The RegExp to test if a string ends with specific file extensions. 92 * @property {boolean} globInputPaths Set to false to skip glob resolution of input file paths to lint (default: true). If false, each input file paths is assumed to be a non-glob path to an existing file. 93 * @property {boolean} ignoreFlag The flag to check ignored files. 94 * @property {(filePath:string, dot:boolean) => boolean} defaultIgnores The default predicate function to ignore files. 95 */ 96 97/** @type {WeakMap<FileEnumerator, FileEnumeratorInternalSlots>} */ 98const internalSlotsMap = new WeakMap(); 99 100/** 101 * Check if a string is a glob pattern or not. 102 * @param {string} pattern A glob pattern. 103 * @returns {boolean} `true` if the string is a glob pattern. 104 */ 105function isGlobPattern(pattern) { 106 return isGlob(path.sep === "\\" ? pattern.replace(/\\/gu, "/") : pattern); 107} 108 109/** 110 * Get stats of a given path. 111 * @param {string} filePath The path to target file. 112 * @returns {fs.Stats|null} The stats. 113 * @private 114 */ 115function statSafeSync(filePath) { 116 try { 117 return fs.statSync(filePath); 118 } catch (error) { 119 /* istanbul ignore next */ 120 if (error.code !== "ENOENT") { 121 throw error; 122 } 123 return null; 124 } 125} 126 127/** 128 * Get filenames in a given path to a directory. 129 * @param {string} directoryPath The path to target directory. 130 * @returns {import("fs").Dirent[]} The filenames. 131 * @private 132 */ 133function readdirSafeSync(directoryPath) { 134 try { 135 return fs.readdirSync(directoryPath, { withFileTypes: true }); 136 } catch (error) { 137 /* istanbul ignore next */ 138 if (error.code !== "ENOENT") { 139 throw error; 140 } 141 return []; 142 } 143} 144 145/** 146 * Create a `RegExp` object to detect extensions. 147 * @param {string[] | null} extensions The extensions to create. 148 * @returns {RegExp | null} The created `RegExp` object or null. 149 */ 150function createExtensionRegExp(extensions) { 151 if (extensions) { 152 const normalizedExts = extensions.map(ext => escapeRegExp( 153 ext.startsWith(".") 154 ? ext.slice(1) 155 : ext 156 )); 157 158 return new RegExp( 159 `.\\.(?:${normalizedExts.join("|")})$`, 160 "u" 161 ); 162 } 163 return null; 164} 165 166/** 167 * The error type when no files match a glob. 168 */ 169class NoFilesFoundError extends Error { 170 171 // eslint-disable-next-line jsdoc/require-description 172 /** 173 * @param {string} pattern The glob pattern which was not found. 174 * @param {boolean} globDisabled If `true` then the pattern was a glob pattern, but glob was disabled. 175 */ 176 constructor(pattern, globDisabled) { 177 super(`No files matching '${pattern}' were found${globDisabled ? " (glob was disabled)" : ""}.`); 178 this.messageTemplate = "file-not-found"; 179 this.messageData = { pattern, globDisabled }; 180 } 181} 182 183/** 184 * The error type when there are files matched by a glob, but all of them have been ignored. 185 */ 186class AllFilesIgnoredError extends Error { 187 188 // eslint-disable-next-line jsdoc/require-description 189 /** 190 * @param {string} pattern The glob pattern which was not found. 191 */ 192 constructor(pattern) { 193 super(`All files matched by '${pattern}' are ignored.`); 194 this.messageTemplate = "all-files-ignored"; 195 this.messageData = { pattern }; 196 } 197} 198 199/** 200 * This class provides the functionality that enumerates every file which is 201 * matched by given glob patterns and that configuration. 202 */ 203class FileEnumerator { 204 205 /** 206 * Initialize this enumerator. 207 * @param {FileEnumeratorOptions} options The options. 208 */ 209 constructor({ 210 cwd = process.cwd(), 211 configArrayFactory = new CascadingConfigArrayFactory({ cwd }), 212 extensions = null, 213 globInputPaths = true, 214 errorOnUnmatchedPattern = true, 215 ignore = true 216 } = {}) { 217 internalSlotsMap.set(this, { 218 configArrayFactory, 219 cwd, 220 defaultIgnores: IgnorePattern.createDefaultIgnore(cwd), 221 extensionRegExp: createExtensionRegExp(extensions), 222 globInputPaths, 223 errorOnUnmatchedPattern, 224 ignoreFlag: ignore 225 }); 226 } 227 228 /** 229 * Check if a given file is target or not. 230 * @param {string} filePath The path to a candidate file. 231 * @param {ConfigArray} [providedConfig] Optional. The configuration for the file. 232 * @returns {boolean} `true` if the file is a target. 233 */ 234 isTargetPath(filePath, providedConfig) { 235 const { 236 configArrayFactory, 237 extensionRegExp 238 } = internalSlotsMap.get(this); 239 240 // If `--ext` option is present, use it. 241 if (extensionRegExp) { 242 return extensionRegExp.test(filePath); 243 } 244 245 // `.js` file is target by default. 246 if (filePath.endsWith(".js")) { 247 return true; 248 } 249 250 // use `overrides[].files` to check additional targets. 251 const config = 252 providedConfig || 253 configArrayFactory.getConfigArrayForFile( 254 filePath, 255 { ignoreNotFoundError: true } 256 ); 257 258 return config.isAdditionalTargetPath(filePath); 259 } 260 261 /** 262 * Iterate files which are matched by given glob patterns. 263 * @param {string|string[]} patternOrPatterns The glob patterns to iterate files. 264 * @returns {IterableIterator<FileAndConfig>} The found files. 265 */ 266 *iterateFiles(patternOrPatterns) { 267 const { globInputPaths, errorOnUnmatchedPattern } = internalSlotsMap.get(this); 268 const patterns = Array.isArray(patternOrPatterns) 269 ? patternOrPatterns 270 : [patternOrPatterns]; 271 272 debug("Start to iterate files: %o", patterns); 273 274 // The set of paths to remove duplicate. 275 const set = new Set(); 276 277 for (const pattern of patterns) { 278 let foundRegardlessOfIgnored = false; 279 let found = false; 280 281 // Skip empty string. 282 if (!pattern) { 283 continue; 284 } 285 286 // Iterate files of this pattern. 287 for (const { config, filePath, flag } of this._iterateFiles(pattern)) { 288 foundRegardlessOfIgnored = true; 289 if (flag === IGNORED_SILENTLY) { 290 continue; 291 } 292 found = true; 293 294 // Remove duplicate paths while yielding paths. 295 if (!set.has(filePath)) { 296 set.add(filePath); 297 yield { 298 config, 299 filePath, 300 ignored: flag === IGNORED 301 }; 302 } 303 } 304 305 // Raise an error if any files were not found. 306 if (errorOnUnmatchedPattern) { 307 if (!foundRegardlessOfIgnored) { 308 throw new NoFilesFoundError( 309 pattern, 310 !globInputPaths && isGlob(pattern) 311 ); 312 } 313 if (!found) { 314 throw new AllFilesIgnoredError(pattern); 315 } 316 } 317 } 318 319 debug(`Complete iterating files: ${JSON.stringify(patterns)}`); 320 } 321 322 /** 323 * Iterate files which are matched by a given glob pattern. 324 * @param {string} pattern The glob pattern to iterate files. 325 * @returns {IterableIterator<FileEntry>} The found files. 326 */ 327 _iterateFiles(pattern) { 328 const { cwd, globInputPaths } = internalSlotsMap.get(this); 329 const absolutePath = path.resolve(cwd, pattern); 330 const isDot = dotfilesPattern.test(pattern); 331 const stat = statSafeSync(absolutePath); 332 333 if (stat && stat.isDirectory()) { 334 return this._iterateFilesWithDirectory(absolutePath, isDot); 335 } 336 if (stat && stat.isFile()) { 337 return this._iterateFilesWithFile(absolutePath); 338 } 339 if (globInputPaths && isGlobPattern(pattern)) { 340 return this._iterateFilesWithGlob(absolutePath, isDot); 341 } 342 343 return []; 344 } 345 346 /** 347 * Iterate a file which is matched by a given path. 348 * @param {string} filePath The path to the target file. 349 * @returns {IterableIterator<FileEntry>} The found files. 350 * @private 351 */ 352 _iterateFilesWithFile(filePath) { 353 debug(`File: ${filePath}`); 354 355 const { configArrayFactory } = internalSlotsMap.get(this); 356 const config = configArrayFactory.getConfigArrayForFile(filePath); 357 const ignored = this._isIgnoredFile(filePath, { config, direct: true }); 358 const flag = ignored ? IGNORED : NONE; 359 360 return [{ config, filePath, flag }]; 361 } 362 363 /** 364 * Iterate files in a given path. 365 * @param {string} directoryPath The path to the target directory. 366 * @param {boolean} dotfiles If `true` then it doesn't skip dot files by default. 367 * @returns {IterableIterator<FileEntry>} The found files. 368 * @private 369 */ 370 _iterateFilesWithDirectory(directoryPath, dotfiles) { 371 debug(`Directory: ${directoryPath}`); 372 373 return this._iterateFilesRecursive( 374 directoryPath, 375 { dotfiles, recursive: true, selector: null } 376 ); 377 } 378 379 /** 380 * Iterate files which are matched by a given glob pattern. 381 * @param {string} pattern The glob pattern to iterate files. 382 * @param {boolean} dotfiles If `true` then it doesn't skip dot files by default. 383 * @returns {IterableIterator<FileEntry>} The found files. 384 * @private 385 */ 386 _iterateFilesWithGlob(pattern, dotfiles) { 387 debug(`Glob: ${pattern}`); 388 389 const directoryPath = path.resolve(getGlobParent(pattern)); 390 const globPart = pattern.slice(directoryPath.length + 1); 391 392 /* 393 * recursive if there are `**` or path separators in the glob part. 394 * Otherwise, patterns such as `src/*.js`, it doesn't need recursive. 395 */ 396 const recursive = /\*\*|\/|\\/u.test(globPart); 397 const selector = new Minimatch(pattern, minimatchOpts); 398 399 debug(`recursive? ${recursive}`); 400 401 return this._iterateFilesRecursive( 402 directoryPath, 403 { dotfiles, recursive, selector } 404 ); 405 } 406 407 /** 408 * Iterate files in a given path. 409 * @param {string} directoryPath The path to the target directory. 410 * @param {Object} options The options to iterate files. 411 * @param {boolean} [options.dotfiles] If `true` then it doesn't skip dot files by default. 412 * @param {boolean} [options.recursive] If `true` then it dives into sub directories. 413 * @param {InstanceType<Minimatch>} [options.selector] The matcher to choose files. 414 * @returns {IterableIterator<FileEntry>} The found files. 415 * @private 416 */ 417 *_iterateFilesRecursive(directoryPath, options) { 418 debug(`Enter the directory: ${directoryPath}`); 419 const { configArrayFactory } = internalSlotsMap.get(this); 420 421 /** @type {ConfigArray|null} */ 422 let config = null; 423 424 // Enumerate the files of this directory. 425 for (const entry of readdirSafeSync(directoryPath)) { 426 const filePath = path.join(directoryPath, entry.name); 427 428 // Check if the file is matched. 429 if (entry.isFile()) { 430 if (!config) { 431 config = configArrayFactory.getConfigArrayForFile( 432 filePath, 433 434 /* 435 * We must ignore `ConfigurationNotFoundError` at this 436 * point because we don't know if target files exist in 437 * this directory. 438 */ 439 { ignoreNotFoundError: true } 440 ); 441 } 442 const matched = options.selector 443 444 // Started with a glob pattern; choose by the pattern. 445 ? options.selector.match(filePath) 446 447 // Started with a directory path; choose by file extensions. 448 : this.isTargetPath(filePath, config); 449 450 if (matched) { 451 const ignored = this._isIgnoredFile(filePath, { ...options, config }); 452 const flag = ignored ? IGNORED_SILENTLY : NONE; 453 454 debug(`Yield: ${entry.name}${ignored ? " but ignored" : ""}`); 455 yield { 456 config: configArrayFactory.getConfigArrayForFile(filePath), 457 filePath, 458 flag 459 }; 460 } else { 461 debug(`Didn't match: ${entry.name}`); 462 } 463 464 // Dive into the sub directory. 465 } else if (options.recursive && entry.isDirectory()) { 466 if (!config) { 467 config = configArrayFactory.getConfigArrayForFile( 468 filePath, 469 { ignoreNotFoundError: true } 470 ); 471 } 472 const ignored = this._isIgnoredFile( 473 filePath + path.sep, 474 { ...options, config } 475 ); 476 477 if (!ignored) { 478 yield* this._iterateFilesRecursive(filePath, options); 479 } 480 } 481 } 482 483 debug(`Leave the directory: ${directoryPath}`); 484 } 485 486 /** 487 * Check if a given file should be ignored. 488 * @param {string} filePath The path to a file to check. 489 * @param {Object} options Options 490 * @param {ConfigArray} [options.config] The config for this file. 491 * @param {boolean} [options.dotfiles] If `true` then this is not ignore dot files by default. 492 * @param {boolean} [options.direct] If `true` then this is a direct specified file. 493 * @returns {boolean} `true` if the file should be ignored. 494 * @private 495 */ 496 _isIgnoredFile(filePath, { 497 config: providedConfig, 498 dotfiles = false, 499 direct = false 500 }) { 501 const { 502 configArrayFactory, 503 defaultIgnores, 504 ignoreFlag 505 } = internalSlotsMap.get(this); 506 507 if (ignoreFlag) { 508 const config = 509 providedConfig || 510 configArrayFactory.getConfigArrayForFile( 511 filePath, 512 { ignoreNotFoundError: true } 513 ); 514 const ignores = 515 config.extractConfig(filePath).ignores || defaultIgnores; 516 517 return ignores(filePath, dotfiles); 518 } 519 520 return !direct && defaultIgnores(filePath, dotfiles); 521 } 522} 523 524//------------------------------------------------------------------------------ 525// Public Interface 526//------------------------------------------------------------------------------ 527 528module.exports = { FileEnumerator }; 529