file-enumerator.js 19 KB

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