• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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