/** * @fileoverview Utility to load config files * @author Nicholas C. Zakas */ "use strict"; //------------------------------------------------------------------------------ // Requirements //------------------------------------------------------------------------------ const path = require("node:path"); const fs = require("node:fs/promises"); const findUp = require("find-up"); const { pathToFileURL } = require("node:url"); const debug = require("debug")("eslint:config-loader"); const { FlatConfigArray } = require("../config/flat-config-array"); //----------------------------------------------------------------------------- // Types //----------------------------------------------------------------------------- /** * @typedef {import("../shared/types").FlatConfigObject} FlatConfigObject * @typedef {import("../shared/types").FlatConfigArray} FlatConfigArray * @typedef {Object} ConfigLoaderOptions * @property {string|false|undefined} configFile The path to the config file to use. * @property {string} cwd The current working directory. * @property {boolean} ignoreEnabled Indicates if ignore patterns should be honored. * @property {FlatConfigArray} [baseConfig] The base config to use. * @property {Array} [defaultConfigs] The default configs to use. * @property {Array} [ignorePatterns] The ignore patterns to use. * @property {FlatConfigObject|Array} overrideConfig The override config to use. * @property {boolean} allowTS Indicates if TypeScript configuration files are allowed. */ //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ const FLAT_CONFIG_FILENAMES = [ "eslint.config.js", "eslint.config.mjs", "eslint.config.cjs" ]; const TS_FLAT_CONFIG_FILENAMES = [ "eslint.config.ts", "eslint.config.mts", "eslint.config.cts" ]; const importedConfigFileModificationTime = new Map(); /** * Asserts that the given file path is valid. * @param {string} filePath The file path to check. * @returns {void} * @throws {Error} If `filePath` is not a non-empty string. */ function assertValidFilePath(filePath) { if (!filePath || typeof filePath !== "string") { throw new Error("'filePath' must be a non-empty string"); } } /** * Asserts that a configuration exists. A configuration exists if any * of the following are true: * - `configFilePath` is defined. * - `useConfigFile` is `false`. * @param {string|undefined} configFilePath The path to the config file. * @param {ConfigLoaderOptions} loaderOptions The options to use when loading configuration files. * @returns {void} * @throws {Error} If no configuration exists. */ function assertConfigurationExists(configFilePath, loaderOptions) { const { configFile: useConfigFile } = loaderOptions; if (!configFilePath && useConfigFile !== false) { const error = new Error("Could not find config file."); error.messageTemplate = "config-file-missing"; throw error; } } /** * Check if the file is a TypeScript file. * @param {string} filePath The file path to check. * @returns {boolean} `true` if the file is a TypeScript file, `false` if it's not. */ function isFileTS(filePath) { const fileExtension = path.extname(filePath); return /^\.[mc]?ts$/u.test(fileExtension); } /** * Check if ESLint is running in Bun. * @returns {boolean} `true` if the ESLint is running Bun, `false` if it's not. */ function isRunningInBun() { return !!globalThis.Bun; } /** * Check if ESLint is running in Deno. * @returns {boolean} `true` if the ESLint is running in Deno, `false` if it's not. */ function isRunningInDeno() { return !!globalThis.Deno; } /** * Load the config array from the given filename. * @param {string} filePath The filename to load from. * @param {boolean} allowTS Indicates if TypeScript configuration files are allowed. * @returns {Promise} The config loaded from the config file. */ async function loadConfigFile(filePath, allowTS) { debug(`Loading config from ${filePath}`); const fileURL = pathToFileURL(filePath); debug(`Config file URL is ${fileURL}`); const mtime = (await fs.stat(filePath)).mtime.getTime(); /* * Append a query with the config file's modification time (`mtime`) in order * to import the current version of the config file. Without the query, `import()` would * cache the config file module by the pathname only, and then always return * the same version (the one that was actual when the module was imported for the first time). * * This ensures that the config file module is loaded and executed again * if it has been changed since the last time it was imported. * If it hasn't been changed, `import()` will just return the cached version. * * Note that we should not overuse queries (e.g., by appending the current time * to always reload the config file module) as that could cause memory leaks * because entries are never removed from the import cache. */ fileURL.searchParams.append("mtime", mtime); /* * With queries, we can bypass the import cache. However, when import-ing a CJS module, * Node.js uses the require infrastructure under the hood. That includes the require cache, * which caches the config file module by its file path (queries have no effect). * Therefore, we also need to clear the require cache before importing the config file module. * In order to get the same behavior with ESM and CJS config files, in particular - to reload * the config file only if it has been changed, we track file modification times and clear * the require cache only if the file has been changed. */ if (importedConfigFileModificationTime.get(filePath) !== mtime) { delete require.cache[filePath]; } const isTS = isFileTS(filePath); const isBun = isRunningInBun(); const isDeno = isRunningInDeno(); /* * If we are dealing with a TypeScript file, then we need to use `jiti` to load it * in Node.js. Deno and Bun both allow native importing of TypeScript files. * * When Node.js supports native TypeScript imports, we can remove this check. */ if (allowTS && isTS && !isDeno && !isBun) { // eslint-disable-next-line no-use-before-define -- `ConfigLoader.loadJiti` can be overwritten for testing const { createJiti } = await ConfigLoader.loadJiti().catch(() => { throw new Error("The 'jiti' library is required for loading TypeScript configuration files. Make sure to install it."); }); // `createJiti` was added in jiti v2. if (typeof createJiti !== "function") { throw new Error("You are using an outdated version of the 'jiti' library. Please update to the latest version of 'jiti' to ensure compatibility and access to the latest features."); } /* * Disabling `moduleCache` allows us to reload a * config file when the last modified timestamp changes. */ const jiti = createJiti(__filename, { moduleCache: false, interopDefault: false }); const config = await jiti.import(fileURL.href); importedConfigFileModificationTime.set(filePath, mtime); return config?.default ?? config; } // fallback to normal runtime behavior const config = (await import(fileURL)).default; importedConfigFileModificationTime.set(filePath, mtime); return config; } /** * Calculates the config array for this run based on inputs. * @param {string} configFilePath The absolute path to the config file to use if not overridden. * @param {string} basePath The base path to use for relative paths in the config file. * @param {ConfigLoaderOptions} options The options to use when loading configuration files. * @returns {Promise} The config array for `eslint`. */ async function calculateConfigArray(configFilePath, basePath, options) { const { cwd, baseConfig, ignoreEnabled, ignorePatterns, overrideConfig, defaultConfigs = [], allowTS } = options; debug(`Calculating config array from config file ${configFilePath} and base path ${basePath}`); const configs = new FlatConfigArray(baseConfig || [], { basePath, shouldIgnore: ignoreEnabled }); // load config file if (configFilePath) { debug(`Loading config file ${configFilePath}`); const fileConfig = await loadConfigFile(configFilePath, allowTS); if (Array.isArray(fileConfig)) { configs.push(...fileConfig); } else { configs.push(fileConfig); } } // add in any configured defaults configs.push(...defaultConfigs); // append command line ignore patterns if (ignorePatterns && ignorePatterns.length > 0) { let relativeIgnorePatterns; /* * If the config file basePath is different than the cwd, then * the ignore patterns won't work correctly. Here, we adjust the * ignore pattern to include the correct relative path. Patterns * passed as `ignorePatterns` are relative to the cwd, whereas * the config file basePath can be an ancestor of the cwd. */ if (basePath === cwd) { relativeIgnorePatterns = ignorePatterns; } else { // relative path must only have Unix-style separators const relativeIgnorePath = path.relative(basePath, cwd).replace(/\\/gu, "/"); relativeIgnorePatterns = ignorePatterns.map(pattern => { const negated = pattern.startsWith("!"); const basePattern = negated ? pattern.slice(1) : pattern; return (negated ? "!" : "") + path.posix.join(relativeIgnorePath, basePattern); }); } /* * Ignore patterns are added to the end of the config array * so they can override default ignores. */ configs.push({ ignores: relativeIgnorePatterns }); } if (overrideConfig) { if (Array.isArray(overrideConfig)) { configs.push(...overrideConfig); } else { configs.push(overrideConfig); } } await configs.normalize(); return configs; } //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- /** * Encapsulates the loading and caching of configuration files when looking up * from the file being linted. */ class ConfigLoader { /** * Map of config file paths to the config arrays for those directories. * @type {Map} */ #configArrays = new Map(); /** * Map of absolute directory names to the config file paths for those directories. * @type {Map} */ #configFilePaths = new Map(); /** * The options to use when loading configuration files. * @type {ConfigLoaderOptions} */ #options; /** * Creates a new instance. * @param {ConfigLoaderOptions} options The options to use when loading configuration files. */ constructor(options) { this.#options = options; } /** * Determines which config file to use. This is determined by seeing if an * override config file was specified, and if so, using it; otherwise, as long * as override config file is not explicitly set to `false`, it will search * upwards from `fromDirectory` for a file named `eslint.config.js`. * @param {string} fromDirectory The directory from which to start searching. * @returns {Promise<{configFilePath:string|undefined,basePath:string}>} Location information for * the config file. */ async #locateConfigFileToUse(fromDirectory) { // check cache first if (this.#configFilePaths.has(fromDirectory)) { return this.#configFilePaths.get(fromDirectory); } const configFilenames = this.#options.allowTS ? [...FLAT_CONFIG_FILENAMES, ...TS_FLAT_CONFIG_FILENAMES] : FLAT_CONFIG_FILENAMES; // determine where to load config file from let configFilePath; const { cwd, configFile: useConfigFile } = this.#options; let basePath = cwd; if (typeof useConfigFile === "string") { debug(`Override config file path is ${useConfigFile}`); configFilePath = path.resolve(cwd, useConfigFile); basePath = cwd; } else if (useConfigFile !== false) { debug("Searching for eslint.config.js"); configFilePath = await findUp( configFilenames, { cwd: fromDirectory } ); if (configFilePath) { basePath = path.dirname(configFilePath); } } // cache the result this.#configFilePaths.set(fromDirectory, { configFilePath, basePath }); return { configFilePath, basePath }; } /** * Calculates the config array for this run based on inputs. * @param {string} configFilePath The absolute path to the config file to use if not overridden. * @param {string} basePath The base path to use for relative paths in the config file. * @returns {Promise} The config array for `eslint`. */ async #calculateConfigArray(configFilePath, basePath) { // check for cached version first if (this.#configArrays.has(configFilePath)) { return this.#configArrays.get(configFilePath); } const configs = await calculateConfigArray(configFilePath, basePath, this.#options); // cache the config array for this instance this.#configArrays.set(configFilePath, configs); return configs; } /** * Returns the config file path for the given directory or file. This will either use * the override config file that was specified in the constructor options or * search for a config file from the directory. * @param {string} fileOrDirPath The file or directory path to get the config file path for. * @returns {Promise} The config file path or `undefined` if not found. * @throws {Error} If `fileOrDirPath` is not a non-empty string. * @throws {Error} If `fileOrDirPath` is not an absolute path. */ async findConfigFileForPath(fileOrDirPath) { assertValidFilePath(fileOrDirPath); const absoluteDirPath = path.resolve(this.#options.cwd, path.dirname(fileOrDirPath)); const { configFilePath } = await this.#locateConfigFileToUse(absoluteDirPath); return configFilePath; } /** * Returns a configuration object for the given file based on the CLI options. * This is the same logic used by the ESLint CLI executable to determine * configuration for each file it processes. * @param {string} filePath The path of the file or directory to retrieve config for. * @returns {Promise} A configuration object for the file * or `undefined` if there is no configuration data for the file. * @throws {Error} If no configuration for `filePath` exists. */ async loadConfigArrayForFile(filePath) { assertValidFilePath(filePath); debug(`Calculating config for file ${filePath}`); const configFilePath = await this.findConfigFileForPath(filePath); assertConfigurationExists(configFilePath, this.#options); return this.loadConfigArrayForDirectory(filePath); } /** * Returns a configuration object for the given directory based on the CLI options. * This is the same logic used by the ESLint CLI executable to determine * configuration for each file it processes. * @param {string} dirPath The path of the directory to retrieve config for. * @returns {Promise} A configuration object for the directory * or `undefined` if there is no configuration data for the directory. */ async loadConfigArrayForDirectory(dirPath) { assertValidFilePath(dirPath); debug(`Calculating config for directory ${dirPath}`); const absoluteDirPath = path.resolve(this.#options.cwd, path.dirname(dirPath)); const { configFilePath, basePath } = await this.#locateConfigFileToUse(absoluteDirPath); debug(`Using config file ${configFilePath} and base path ${basePath}`); return this.#calculateConfigArray(configFilePath, basePath); } /** * Returns a configuration array for the given file based on the CLI options. * This is a synchronous operation and does not read any files from disk. It's * intended to be used in locations where we know the config file has already * been loaded and we just need to get the configuration for a file. * @param {string} filePath The path of the file to retrieve a config object for. * @returns {ConfigData|undefined} A configuration object for the file * or `undefined` if there is no configuration data for the file. * @throws {Error} If `filePath` is not a non-empty string. * @throws {Error} If `filePath` is not an absolute path. * @throws {Error} If the config file was not already loaded. */ getCachedConfigArrayForFile(filePath) { assertValidFilePath(filePath); debug(`Looking up cached config for ${filePath}`); return this.getCachedConfigArrayForPath(path.dirname(filePath)); } /** * Returns a configuration array for the given directory based on the CLI options. * This is a synchronous operation and does not read any files from disk. It's * intended to be used in locations where we know the config file has already * been loaded and we just need to get the configuration for a file. * @param {string} fileOrDirPath The path of the directory to retrieve a config object for. * @returns {ConfigData|undefined} A configuration object for the directory * or `undefined` if there is no configuration data for the directory. * @throws {Error} If `dirPath` is not a non-empty string. * @throws {Error} If `dirPath` is not an absolute path. * @throws {Error} If the config file was not already loaded. */ getCachedConfigArrayForPath(fileOrDirPath) { assertValidFilePath(fileOrDirPath); debug(`Looking up cached config for ${fileOrDirPath}`); const absoluteDirPath = path.resolve(this.#options.cwd, fileOrDirPath); if (!this.#configFilePaths.has(absoluteDirPath)) { throw new Error(`Could not find config file for ${fileOrDirPath}`); } const { configFilePath } = this.#configFilePaths.get(absoluteDirPath); return this.#configArrays.get(configFilePath); } /** * Used to import the jiti dependency. This method is exposed internally for testing purposes. * @returns {Promise>} A promise that fulfills with a module object * or rejects with an error if jiti is not found. */ static loadJiti() { return import("jiti"); } } /** * Encapsulates the loading and caching of configuration files when looking up * from the current working directory. */ class LegacyConfigLoader extends ConfigLoader { /** * The options to use when loading configuration files. * @type {ConfigLoaderOptions} */ #options; /** * The cached config file path for this instance. * @type {{configFilePath:string,basePath:string}|undefined} */ #configFilePath; /** * The cached config array for this instance. * @type {FlatConfigArray} */ #configArray; /** * Creates a new instance. * @param {ConfigLoaderOptions} options The options to use when loading configuration files. */ constructor(options) { super(options); this.#options = options; } /** * Determines which config file to use. This is determined by seeing if an * override config file was specified, and if so, using it; otherwise, as long * as override config file is not explicitly set to `false`, it will search * upwards from the cwd for a file named `eslint.config.js`. * @returns {Promise<{configFilePath:string|undefined,basePath:string}>} Location information for * the config file. */ async #locateConfigFileToUse() { // check cache first if (this.#configFilePath) { return this.#configFilePath; } const configFilenames = this.#options.allowTS ? [...FLAT_CONFIG_FILENAMES, ...TS_FLAT_CONFIG_FILENAMES] : FLAT_CONFIG_FILENAMES; // determine where to load config file from let configFilePath; const { cwd, configFile: useConfigFile } = this.#options; let basePath = cwd; if (typeof useConfigFile === "string") { debug(`[Legacy]: Override config file path is ${useConfigFile}`); configFilePath = path.resolve(cwd, useConfigFile); basePath = cwd; } else if (useConfigFile !== false) { debug("[Legacy]: Searching for eslint.config.js"); configFilePath = await findUp( configFilenames, { cwd } ); if (configFilePath) { basePath = path.dirname(configFilePath); } } // cache the result this.#configFilePath = { configFilePath, basePath }; return { configFilePath, basePath }; } /** * Calculates the config array for this run based on inputs. * @param {string} configFilePath The absolute path to the config file to use if not overridden. * @param {string} basePath The base path to use for relative paths in the config file. * @returns {Promise} The config array for `eslint`. */ async #calculateConfigArray(configFilePath, basePath) { // check for cached version first if (this.#configArray) { return this.#configArray; } const configs = await calculateConfigArray(configFilePath, basePath, this.#options); // cache the config array for this instance this.#configArray = configs; return configs; } /** * Returns the config file path for the given directory. This will either use * the override config file that was specified in the constructor options or * search for a config file from the directory of the file being linted. * @param {string} dirPath The directory path to get the config file path for. * @returns {Promise} The config file path or `undefined` if not found. * @throws {Error} If `fileOrDirPath` is not a non-empty string. * @throws {Error} If `fileOrDirPath` is not an absolute path. */ async findConfigFileForPath(dirPath) { assertValidFilePath(dirPath); const { configFilePath } = await this.#locateConfigFileToUse(); return configFilePath; } /** * Returns a configuration object for the given file based on the CLI options. * This is the same logic used by the ESLint CLI executable to determine * configuration for each file it processes. * @param {string} dirPath The path of the directory to retrieve config for. * @returns {Promise} A configuration object for the file * or `undefined` if there is no configuration data for the file. */ async loadConfigArrayForDirectory(dirPath) { assertValidFilePath(dirPath); debug(`[Legacy]: Calculating config for ${dirPath}`); const { configFilePath, basePath } = await this.#locateConfigFileToUse(); debug(`[Legacy]: Using config file ${configFilePath} and base path ${basePath}`); return this.#calculateConfigArray(configFilePath, basePath); } /** * Returns a configuration array for the given directory based on the CLI options. * This is a synchronous operation and does not read any files from disk. It's * intended to be used in locations where we know the config file has already * been loaded and we just need to get the configuration for a file. * @param {string} dirPath The path of the directory to retrieve a config object for. * @returns {ConfigData|undefined} A configuration object for the file * or `undefined` if there is no configuration data for the file. * @throws {Error} If `dirPath` is not a non-empty string. * @throws {Error} If `dirPath` is not an absolute path. * @throws {Error} If the config file was not already loaded. */ getCachedConfigArrayForPath(dirPath) { assertValidFilePath(dirPath); debug(`[Legacy]: Looking up cached config for ${dirPath}`); if (!this.#configArray) { throw new Error(`Could not find config file for ${dirPath}`); } return this.#configArray; } } module.exports = { ConfigLoader, LegacyConfigLoader };