Spaces:
Sleeping
Sleeping
| /** | |
| * @fileoverview Utility to load config files | |
| * @author Nicholas C. Zakas | |
| */ | |
| ; | |
| //------------------------------------------------------------------------------ | |
| // 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("./flat-config-array"); | |
| const { WarningService } = require("../services/warning-service"); | |
| //----------------------------------------------------------------------------- | |
| // Types | |
| //----------------------------------------------------------------------------- | |
| /** @typedef {import("../types").Linter.Config} Config */ | |
| /** | |
| * @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 {Config|Array<Config>} [baseConfig] The base config to use. | |
| * @property {Array<Config>} [defaultConfigs] The default configs to use. | |
| * @property {Array<string>} [ignorePatterns] The ignore patterns to use. | |
| * @property {Config|Array<Config>} [overrideConfig] The override config to use. | |
| * @property {boolean} [hasUnstableNativeNodeJsTSConfigFlag] The flag to indicate whether the `unstable_native_nodejs_ts_config` flag is enabled. | |
| * @property {WarningService} [warningService] The warning service to use. | |
| */ | |
| //------------------------------------------------------------------------------ | |
| // Helpers | |
| //------------------------------------------------------------------------------ | |
| const FLAT_CONFIG_FILENAMES = [ | |
| "eslint.config.js", | |
| "eslint.config.mjs", | |
| "eslint.config.cjs", | |
| "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; | |
| } | |
| /** | |
| * Checks if native TypeScript support is | |
| * enabled in the current Node.js process. | |
| * | |
| * This function determines if the | |
| * {@linkcode NodeJS.ProcessFeatures.typescript | typescript} | |
| * feature is present in the | |
| * {@linkcode process.features} object | |
| * and if its value is either "strip" or "transform". | |
| * @returns {boolean} `true` if native TypeScript support is enabled, otherwise `false`. | |
| * @since 9.24.0 | |
| */ | |
| function isNativeTypeScriptSupportEnabled() { | |
| return ( | |
| // eslint-disable-next-line n/no-unsupported-features/node-builtins -- it's still an experimental feature. | |
| ["strip", "transform"].includes(process.features.typescript) | |
| ); | |
| } | |
| /** | |
| * Load the TypeScript configuration file. | |
| * @param {string} filePath The absolute file path to load. | |
| * @param {URL} fileURL The file URL to load. | |
| * @param {number} mtime The last modified timestamp of the file. | |
| * @returns {Promise<any>} The configuration loaded from the file. | |
| * @since 9.24.0 | |
| */ | |
| async function loadTypeScriptConfigFileWithJiti(filePath, fileURL, mtime) { | |
| const { createJiti, version: jitiVersion } = | |
| // eslint-disable-next-line no-use-before-define -- `ConfigLoader.loadJiti` can be overwritten for testing | |
| await ConfigLoader.loadJiti().catch(() => { | |
| throw new Error( | |
| "The 'jiti' library is required for loading TypeScript configuration files. Make sure to install it.", | |
| ); | |
| }); | |
| // Ensure the version is at least 2.2.0. | |
| const [jitiMajor, jitiMinor] = jitiVersion | |
| .split(".") | |
| .map(versionPart => Number(versionPart)); | |
| if (jitiMajor < 2 || (jitiMajor === 2 && jitiMinor < 2)) { | |
| 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 jitiOptions = { | |
| moduleCache: false, | |
| }; | |
| const jiti = createJiti(__filename, jitiOptions); | |
| const config = await jiti.import(fileURL.href); | |
| importedConfigFileModificationTime.set(filePath, mtime); | |
| return config?.default ?? config; | |
| } | |
| /** | |
| * Dynamically imports a module from the given file path. | |
| * @param {string} filePath The absolute file path of the module to import. | |
| * @param {URL} fileURL The file URL to load. | |
| * @param {number} mtime The last modified timestamp of the file. | |
| * @returns {Promise<any>} - A {@linkcode Promise | promise} that resolves to the imported ESLint config. | |
| * @since 9.24.0 | |
| */ | |
| async function dynamicImportConfig(filePath, fileURL, mtime) { | |
| const module = await import(fileURL.href); | |
| importedConfigFileModificationTime.set(filePath, mtime); | |
| return module.default; | |
| } | |
| /** | |
| * Load the config array from the given filename. | |
| * @param {string} filePath The filename to load from. | |
| * @param {boolean} hasUnstableNativeNodeJsTSConfigFlag The flag to indicate whether the `unstable_native_nodejs_ts_config` flag is enabled. | |
| * @returns {Promise<any>} The config loaded from the config file. | |
| */ | |
| async function loadConfigFile(filePath, hasUnstableNativeNodeJsTSConfigFlag) { | |
| 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 (isTS) { | |
| if (hasUnstableNativeNodeJsTSConfigFlag) { | |
| if (isNativeTypeScriptSupportEnabled()) { | |
| return await dynamicImportConfig(filePath, fileURL, mtime); | |
| } | |
| if (!("typescript" in process.features)) { | |
| throw new Error( | |
| "The unstable_native_nodejs_ts_config flag is not supported in older versions of Node.js.", | |
| ); | |
| } | |
| throw new Error( | |
| "The unstable_native_nodejs_ts_config flag is enabled, but native TypeScript support is not enabled in the current Node.js process. You need to either enable native TypeScript support by passing --experimental-strip-types or remove the unstable_native_nodejs_ts_config flag.", | |
| ); | |
| } | |
| if (!isDeno && !isBun) { | |
| return await loadTypeScriptConfigFileWithJiti( | |
| filePath, | |
| fileURL, | |
| mtime, | |
| ); | |
| } | |
| } | |
| // fallback to normal runtime behavior | |
| return await dynamicImportConfig(filePath, fileURL, mtime); | |
| } | |
| //----------------------------------------------------------------------------- | |
| // 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<string, FlatConfigArray|Promise<FlatConfigArray>>} | |
| */ | |
| #configArrays = new Map(); | |
| /** | |
| * Map of absolute directory names to the config file paths for those directories. | |
| * @type {Map<string, {configFilePath:string,basePath:string}|Promise<{configFilePath:string,basePath:string}>>} | |
| */ | |
| #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.warningService | |
| ? options | |
| : { ...options, warningService: new WarningService() }; | |
| } | |
| /** | |
| * 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 resultPromise = ConfigLoader.locateConfigFileToUse({ | |
| useConfigFile: this.#options.configFile, | |
| cwd: this.#options.cwd, | |
| fromDirectory, | |
| }); | |
| // ensure `ConfigLoader.locateConfigFileToUse` is called only once for `fromDirectory` | |
| this.#configFilePaths.set(fromDirectory, resultPromise); | |
| // Unwrap the promise. This is primarily for the sync `getCachedConfigArrayForPath` method. | |
| const result = await resultPromise; | |
| this.#configFilePaths.set(fromDirectory, result); | |
| return result; | |
| } | |
| /** | |
| * 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<FlatConfigArray>} 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 configsPromise = ConfigLoader.calculateConfigArray( | |
| configFilePath, | |
| basePath, | |
| this.#options, | |
| ); | |
| // ensure `ConfigLoader.calculateConfigArray` is called only once for `configFilePath` | |
| this.#configArrays.set(configFilePath, configsPromise); | |
| // Unwrap the promise. This is primarily for the sync `getCachedConfigArrayForPath` method. | |
| const configs = await configsPromise; | |
| 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<string|undefined>} 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<FlatConfigArray>} A configuration object 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<FlatConfigArray>} A configuration object 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 {FlatConfigArray} A configuration object 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 {FlatConfigArray} A configuration object 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 configFilePathInfo = this.#configFilePaths.get(absoluteDirPath); | |
| if (typeof configFilePathInfo.then === "function") { | |
| throw new Error( | |
| `Config file path for ${fileOrDirPath} has not yet been calculated or an error occurred during the calculation`, | |
| ); | |
| } | |
| const { configFilePath } = configFilePathInfo; | |
| const configArray = this.#configArrays.get(configFilePath); | |
| if (!configArray || typeof configArray.then === "function") { | |
| throw new Error( | |
| `Config array for ${fileOrDirPath} has not yet been calculated or an error occurred during the calculation`, | |
| ); | |
| } | |
| return configArray; | |
| } | |
| /** | |
| * Used to import the jiti dependency. This method is exposed internally for testing purposes. | |
| * @returns {Promise<{createJiti: Function|undefined, version: string;}>} A promise that fulfills with an object containing the jiti module's createJiti function and version. | |
| */ | |
| static async loadJiti() { | |
| const { createJiti } = await import("jiti"); | |
| const version = require("jiti/package.json").version; | |
| return { createJiti, version }; | |
| } | |
| /** | |
| * 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`. | |
| * This method is exposed internally for testing purposes. | |
| * @param {Object} [options] the options object | |
| * @param {string|false|undefined} options.useConfigFile The path to the config file to use. | |
| * @param {string} options.cwd Path to a directory that should be considered as the current working directory. | |
| * @param {string} [options.fromDirectory] The directory from which to start searching. Defaults to `cwd`. | |
| * @returns {Promise<{configFilePath:string|undefined,basePath:string}>} Location information for | |
| * the config file. | |
| */ | |
| static async locateConfigFileToUse({ | |
| useConfigFile, | |
| cwd, | |
| fromDirectory = cwd, | |
| }) { | |
| // determine where to load config file from | |
| let configFilePath; | |
| 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(FLAT_CONFIG_FILENAMES, { | |
| cwd: fromDirectory, | |
| }); | |
| if (configFilePath) { | |
| basePath = path.dirname(configFilePath); | |
| } | |
| } | |
| return { | |
| configFilePath, | |
| basePath, | |
| }; | |
| } | |
| /** | |
| * Calculates the config array for this run based on inputs. | |
| * This method is exposed internally for testing purposes. | |
| * @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<FlatConfigArray>} The config array for `eslint`. | |
| */ | |
| static async calculateConfigArray(configFilePath, basePath, options) { | |
| const { | |
| cwd, | |
| baseConfig, | |
| ignoreEnabled, | |
| ignorePatterns, | |
| overrideConfig, | |
| hasUnstableNativeNodeJsTSConfigFlag = false, | |
| defaultConfigs = [], | |
| warningService, | |
| } = 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, | |
| hasUnstableNativeNodeJsTSConfigFlag, | |
| ); | |
| /* | |
| * It's possible that a config file could be empty or else | |
| * have an empty object or array. In this case, we want to | |
| * warn the user that they have an empty config. | |
| * | |
| * An empty CommonJS file exports an empty object while | |
| * an empty ESM file exports undefined. | |
| */ | |
| let emptyConfig = typeof fileConfig === "undefined"; | |
| debug( | |
| `Config file ${configFilePath} is ${emptyConfig ? "empty" : "not empty"}`, | |
| ); | |
| if (!emptyConfig) { | |
| if (Array.isArray(fileConfig)) { | |
| if (fileConfig.length === 0) { | |
| debug( | |
| `Config file ${configFilePath} is an empty array`, | |
| ); | |
| emptyConfig = true; | |
| } else { | |
| configs.push(...fileConfig); | |
| } | |
| } else { | |
| if ( | |
| typeof fileConfig === "object" && | |
| fileConfig !== null && | |
| Object.keys(fileConfig).length === 0 | |
| ) { | |
| debug( | |
| `Config file ${configFilePath} is an empty object`, | |
| ); | |
| emptyConfig = true; | |
| } else { | |
| configs.push(fileConfig); | |
| } | |
| } | |
| } | |
| if (emptyConfig) { | |
| warningService.emitEmptyConfigWarning(configFilePath); | |
| } | |
| } | |
| // add in any configured defaults | |
| configs.push(...defaultConfigs); | |
| // append command line ignore patterns | |
| if (ignorePatterns && ignorePatterns.length > 0) { | |
| /* | |
| * Ignore patterns are added to the end of the config array | |
| * so they can override default ignores. | |
| */ | |
| configs.push({ | |
| basePath: cwd, | |
| ignores: ignorePatterns, | |
| }); | |
| } | |
| if (overrideConfig) { | |
| if (Array.isArray(overrideConfig)) { | |
| configs.push(...overrideConfig); | |
| } else { | |
| configs.push(overrideConfig); | |
| } | |
| } | |
| await configs.normalize(); | |
| return configs; | |
| } | |
| } | |
| module.exports = { ConfigLoader }; | |