file-enumerator.js 16 KB

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