568 lines
16 KiB
JavaScript
568 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* @fileoverview defineConfig helper
|
|
* @author Nicholas C. Zakas
|
|
*/
|
|
|
|
//-----------------------------------------------------------------------------
|
|
// Type Definitions
|
|
//-----------------------------------------------------------------------------
|
|
|
|
/** @typedef {import("eslint").Linter.Config} Config */
|
|
/** @typedef {import("eslint").Linter.LegacyConfig} LegacyConfig */
|
|
/** @typedef {import("eslint").ESLint.Plugin} Plugin */
|
|
/** @typedef {import("eslint").Linter.RuleEntry} RuleEntry */
|
|
/** @typedef {import("./types.ts").ExtendsElement} ExtendsElement */
|
|
/** @typedef {import("./types.ts").SimpleExtendsElement} SimpleExtendsElement */
|
|
/** @typedef {import("./types.ts").ConfigWithExtends} ConfigWithExtends */
|
|
/** @typedef {import("./types.ts").InfiniteArray<Config>} InfiniteConfigArray */
|
|
/** @typedef {import("./types.ts").ConfigWithExtendsArray} ConfigWithExtendsArray */
|
|
|
|
//-----------------------------------------------------------------------------
|
|
// Helpers
|
|
//-----------------------------------------------------------------------------
|
|
|
|
const eslintrcKeys = [
|
|
"env",
|
|
"extends",
|
|
"globals",
|
|
"ignorePatterns",
|
|
"noInlineConfig",
|
|
"overrides",
|
|
"parser",
|
|
"parserOptions",
|
|
"reportUnusedDisableDirectives",
|
|
"root",
|
|
];
|
|
|
|
const allowedGlobalIgnoreKeys = new Set(["ignores", "name"]);
|
|
|
|
/**
|
|
* Gets the name of a config object.
|
|
* @param {Config} config The config object.
|
|
* @param {string} indexPath The index path of the config object.
|
|
* @return {string} The name of the config object.
|
|
*/
|
|
function getConfigName(config, indexPath) {
|
|
if (config.name) {
|
|
return config.name;
|
|
}
|
|
|
|
return `UserConfig${indexPath}`;
|
|
}
|
|
|
|
/**
|
|
* Gets the name of an extension.
|
|
* @param {SimpleExtendsElement} extension The extension.
|
|
* @param {string} indexPath The index of the extension.
|
|
* @return {string} The name of the extension.
|
|
*/
|
|
function getExtensionName(extension, indexPath) {
|
|
if (typeof extension === "string") {
|
|
return extension;
|
|
}
|
|
|
|
if (extension.name) {
|
|
return extension.name;
|
|
}
|
|
|
|
return `ExtendedConfig${indexPath}`;
|
|
}
|
|
|
|
/**
|
|
* Determines if a config object is a legacy config.
|
|
* @param {Config|LegacyConfig} config The config object to check.
|
|
* @return {config is LegacyConfig} `true` if the config object is a legacy config.
|
|
*/
|
|
function isLegacyConfig(config) {
|
|
for (const key of eslintrcKeys) {
|
|
if (key in config) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Determines if a config object is a global ignores config.
|
|
* @param {Config} config The config object to check.
|
|
* @return {boolean} `true` if the config object is a global ignores config.
|
|
*/
|
|
function isGlobalIgnores(config) {
|
|
return Object.keys(config).every(key => allowedGlobalIgnoreKeys.has(key));
|
|
}
|
|
|
|
/**
|
|
* Parses a plugin member ID (rule, processor, etc.) and returns
|
|
* the namespace and member name.
|
|
* @param {string} id The ID to parse.
|
|
* @returns {{namespace:string, name:string}} The namespace and member name.
|
|
*/
|
|
function getPluginMember(id) {
|
|
const firstSlashIndex = id.indexOf("/");
|
|
|
|
if (firstSlashIndex === -1) {
|
|
return { namespace: "", name: id };
|
|
}
|
|
|
|
let namespace = id.slice(0, firstSlashIndex);
|
|
|
|
/*
|
|
* Special cases:
|
|
* 1. The namespace is `@`, that means it's referring to the
|
|
* core plugin so `@` is the full namespace.
|
|
* 2. The namespace starts with `@`, that means it's referring to
|
|
* an npm scoped package. That means the namespace is the scope
|
|
* and the package name (i.e., `@eslint/core`).
|
|
*/
|
|
if (namespace[0] === "@" && namespace !== "@") {
|
|
const secondSlashIndex = id.indexOf("/", firstSlashIndex + 1);
|
|
if (secondSlashIndex !== -1) {
|
|
namespace = id.slice(0, secondSlashIndex);
|
|
return { namespace, name: id.slice(secondSlashIndex + 1) };
|
|
}
|
|
}
|
|
|
|
const name = id.slice(firstSlashIndex + 1);
|
|
|
|
return { namespace, name };
|
|
}
|
|
|
|
/**
|
|
* Normalizes the plugin config by replacing the namespace with the plugin namespace.
|
|
* @param {string} userNamespace The namespace of the plugin.
|
|
* @param {Plugin} plugin The plugin config object.
|
|
* @param {Config} config The config object to normalize.
|
|
* @return {Config} The normalized config object.
|
|
*/
|
|
function normalizePluginConfig(userNamespace, plugin, config) {
|
|
// @ts-ignore -- ESLint types aren't updated yet
|
|
const pluginNamespace = plugin.meta?.namespace;
|
|
|
|
// don't do anything if the plugin doesn't have a namespace or rules
|
|
if (
|
|
!pluginNamespace ||
|
|
pluginNamespace === userNamespace ||
|
|
(!config.rules && !config.processor && !config.language)
|
|
) {
|
|
return config;
|
|
}
|
|
|
|
const result = { ...config };
|
|
|
|
// update the rules
|
|
if (result.rules) {
|
|
const ruleIds = Object.keys(result.rules);
|
|
|
|
/** @type {Record<string,RuleEntry|undefined>} */
|
|
const newRules = {};
|
|
|
|
for (let i = 0; i < ruleIds.length; i++) {
|
|
const ruleId = ruleIds[i];
|
|
const { namespace: ruleNamespace, name: ruleName } =
|
|
getPluginMember(ruleId);
|
|
|
|
if (ruleNamespace === pluginNamespace) {
|
|
newRules[`${userNamespace}/${ruleName}`] = result.rules[ruleId];
|
|
} else {
|
|
newRules[ruleId] = result.rules[ruleId];
|
|
}
|
|
}
|
|
|
|
result.rules = newRules;
|
|
}
|
|
|
|
// update the processor
|
|
|
|
if (typeof result.processor === "string") {
|
|
const { namespace: processorNamespace, name: processorName } =
|
|
getPluginMember(result.processor);
|
|
|
|
if (processorNamespace) {
|
|
if (processorNamespace === pluginNamespace) {
|
|
result.processor = `${userNamespace}/${processorName}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// update the language
|
|
if (typeof result.language === "string") {
|
|
const { namespace: languageNamespace, name: languageName } =
|
|
getPluginMember(result.language);
|
|
|
|
if (languageNamespace === pluginNamespace) {
|
|
result.language = `${userNamespace}/${languageName}`;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Deeply normalizes a plugin config, traversing recursively into an arrays.
|
|
* @param {string} userPluginNamespace The namespace of the plugin.
|
|
* @param {Plugin} plugin The plugin object.
|
|
* @param {Config|LegacyConfig|(Config|LegacyConfig)[]} pluginConfig The plugin config to normalize.
|
|
* @param {string} pluginConfigName The name of the plugin config.
|
|
* @return {InfiniteConfigArray} The normalized plugin config.
|
|
*/
|
|
function deepNormalizePluginConfig(
|
|
userPluginNamespace,
|
|
plugin,
|
|
pluginConfig,
|
|
pluginConfigName,
|
|
) {
|
|
// if it's an array then it's definitely a new config
|
|
if (Array.isArray(pluginConfig)) {
|
|
return pluginConfig.map(pluginSubConfig =>
|
|
deepNormalizePluginConfig(
|
|
userPluginNamespace,
|
|
plugin,
|
|
pluginSubConfig,
|
|
pluginConfigName,
|
|
),
|
|
);
|
|
}
|
|
|
|
// if it's a legacy config, throw an error
|
|
if (isLegacyConfig(pluginConfig)) {
|
|
throw new TypeError(
|
|
`Plugin config "${pluginConfigName}" is an eslintrc config and cannot be used in this context.`,
|
|
);
|
|
}
|
|
|
|
return normalizePluginConfig(userPluginNamespace, plugin, pluginConfig);
|
|
}
|
|
|
|
/**
|
|
* Finds a plugin config by name in the given config.
|
|
* @param {Config} config The config object.
|
|
* @param {string} pluginConfigName The name of the plugin config.
|
|
* @return {InfiniteConfigArray} The plugin config.
|
|
*/
|
|
function findPluginConfig(config, pluginConfigName) {
|
|
const { namespace: userPluginNamespace, name: configName } =
|
|
getPluginMember(pluginConfigName);
|
|
const plugin = config.plugins?.[userPluginNamespace];
|
|
|
|
if (!plugin) {
|
|
throw new TypeError(`Plugin "${userPluginNamespace}" not found.`);
|
|
}
|
|
|
|
const directConfig = plugin.configs?.[configName];
|
|
if (directConfig) {
|
|
// Arrays are always flat configs, and non-legacy configs can be used directly
|
|
if (Array.isArray(directConfig) || !isLegacyConfig(directConfig)) {
|
|
return deepNormalizePluginConfig(
|
|
userPluginNamespace,
|
|
plugin,
|
|
directConfig,
|
|
pluginConfigName,
|
|
);
|
|
}
|
|
|
|
// If it's a legacy config, look for the flat version
|
|
const flatConfig = plugin.configs?.[`flat/${configName}`];
|
|
|
|
if (
|
|
flatConfig &&
|
|
(Array.isArray(flatConfig) || !isLegacyConfig(flatConfig))
|
|
) {
|
|
return deepNormalizePluginConfig(
|
|
userPluginNamespace,
|
|
plugin,
|
|
flatConfig,
|
|
pluginConfigName,
|
|
);
|
|
}
|
|
|
|
throw new TypeError(
|
|
`Plugin config "${configName}" in plugin "${userPluginNamespace}" is an eslintrc config and cannot be used in this context.`,
|
|
);
|
|
}
|
|
|
|
throw new TypeError(
|
|
`Plugin config "${configName}" not found in plugin "${userPluginNamespace}".`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Flattens an array while keeping track of the index path.
|
|
* @param {any[]} configList The array to traverse.
|
|
* @param {string} indexPath The index path of the value in a multidimensional array.
|
|
* @return {IterableIterator<{indexPath:string, value:any}>} The flattened list of values.
|
|
*/
|
|
function* flatTraverse(configList, indexPath = "") {
|
|
for (let i = 0; i < configList.length; i++) {
|
|
const newIndexPath = indexPath ? `${indexPath}[${i}]` : `[${i}]`;
|
|
|
|
// if it's an array then traverse it as well
|
|
if (Array.isArray(configList[i])) {
|
|
yield* flatTraverse(configList[i], newIndexPath);
|
|
continue;
|
|
}
|
|
|
|
yield { indexPath: newIndexPath, value: configList[i] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extends a list of config files by creating every combination of base and extension files.
|
|
* @param {(string|string[])[]} [baseFiles] The base files.
|
|
* @param {(string|string[])[]} [extensionFiles] The extension files.
|
|
* @return {(string|string[])[]} The extended files.
|
|
*/
|
|
function extendConfigFiles(baseFiles = [], extensionFiles = []) {
|
|
if (!extensionFiles.length) {
|
|
return baseFiles.concat();
|
|
}
|
|
|
|
if (!baseFiles.length) {
|
|
return extensionFiles.concat();
|
|
}
|
|
|
|
/** @type {(string|string[])[]} */
|
|
const result = [];
|
|
|
|
for (const baseFile of baseFiles) {
|
|
for (const extensionFile of extensionFiles) {
|
|
/*
|
|
* Each entry can be a string or array of strings. The end result
|
|
* needs to be an array of strings, so we need to be sure to include
|
|
* all of the items when there's an array.
|
|
*/
|
|
|
|
const entry = [];
|
|
|
|
if (Array.isArray(baseFile)) {
|
|
entry.push(...baseFile);
|
|
} else {
|
|
entry.push(baseFile);
|
|
}
|
|
|
|
if (Array.isArray(extensionFile)) {
|
|
entry.push(...extensionFile);
|
|
} else {
|
|
entry.push(extensionFile);
|
|
}
|
|
|
|
result.push(entry);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Extends a config object with another config object.
|
|
* @param {Config} baseConfig The base config object.
|
|
* @param {string} baseConfigName The name of the base config object.
|
|
* @param {Config} extension The extension config object.
|
|
* @param {string} extensionName The index of the extension config object.
|
|
* @return {Config} The extended config object.
|
|
*/
|
|
function extendConfig(baseConfig, baseConfigName, extension, extensionName) {
|
|
const result = { ...extension };
|
|
|
|
// for global ignores there is no further work to be done, we just keep everything
|
|
if (!isGlobalIgnores(extension)) {
|
|
// for files we need to create every combination of base and extension files
|
|
if (baseConfig.files) {
|
|
result.files = extendConfigFiles(baseConfig.files, extension.files);
|
|
}
|
|
|
|
// for ignores we just concatenation the extension ignores onto the base ignores
|
|
if (baseConfig.ignores) {
|
|
result.ignores = baseConfig.ignores.concat(extension.ignores ?? []);
|
|
}
|
|
}
|
|
|
|
result.name = `${baseConfigName} > ${extensionName}`;
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Processes a list of extends elements.
|
|
* @param {ConfigWithExtends} config The config object.
|
|
* @param {WeakMap<Config, string>} configNames The map of config objects to their names.
|
|
* @return {Config[]} The flattened list of config objects.
|
|
*/
|
|
function processExtends(config, configNames) {
|
|
if (!config.extends) {
|
|
return [config];
|
|
}
|
|
|
|
if (!Array.isArray(config.extends)) {
|
|
throw new TypeError("The `extends` property must be an array.");
|
|
}
|
|
|
|
const {
|
|
/** @type {Config[]} */
|
|
extends: extendsList,
|
|
|
|
/** @type {Config} */
|
|
...configObject
|
|
} = config;
|
|
|
|
const extensionNames = new WeakMap();
|
|
|
|
// replace strings with the actual configs
|
|
const objectExtends = extendsList.map(extendsElement => {
|
|
if (typeof extendsElement === "string") {
|
|
const pluginConfig = findPluginConfig(config, extendsElement);
|
|
|
|
// assign names
|
|
if (Array.isArray(pluginConfig)) {
|
|
pluginConfig.forEach((pluginConfigElement, index) => {
|
|
extensionNames.set(
|
|
pluginConfigElement,
|
|
`${extendsElement}[${index}]`,
|
|
);
|
|
});
|
|
} else {
|
|
extensionNames.set(pluginConfig, extendsElement);
|
|
}
|
|
|
|
return pluginConfig;
|
|
}
|
|
|
|
return /** @type {Config} */ (extendsElement);
|
|
});
|
|
|
|
const result = [];
|
|
|
|
for (const { indexPath, value: extendsElement } of flatTraverse(
|
|
objectExtends,
|
|
)) {
|
|
const extension = /** @type {Config} */ (extendsElement);
|
|
|
|
if ("extends" in extension) {
|
|
throw new TypeError("Nested 'extends' is not allowed.");
|
|
}
|
|
|
|
const baseConfigName = /** @type {string} */ (configNames.get(config));
|
|
const extensionName =
|
|
extensionNames.get(extendsElement) ??
|
|
getExtensionName(extendsElement, indexPath);
|
|
|
|
result.push(
|
|
extendConfig(
|
|
configObject,
|
|
baseConfigName,
|
|
extension,
|
|
extensionName,
|
|
),
|
|
);
|
|
}
|
|
|
|
/*
|
|
* If the base config object has only `ignores` and `extends`, then
|
|
* removing `extends` turns it into a global ignores, which is not what
|
|
* we want. So we need to check if the base config object is a global ignores
|
|
* and if so, we don't add it to the array.
|
|
*
|
|
* (The other option would be to add a `files` entry, but that would result
|
|
* in a config that didn't actually do anything because there are no
|
|
* other keys in the config.)
|
|
*/
|
|
if (!isGlobalIgnores(configObject)) {
|
|
result.push(configObject);
|
|
}
|
|
|
|
return result.flat();
|
|
}
|
|
|
|
/**
|
|
* Processes a list of config objects and arrays.
|
|
* @param {ConfigWithExtends[]} configList The list of config objects and arrays.
|
|
* @param {WeakMap<Config, string>} configNames The map of config objects to their names.
|
|
* @return {Config[]} The flattened list of config objects.
|
|
*/
|
|
function processConfigList(configList, configNames) {
|
|
return configList.flatMap(config => processExtends(config, configNames));
|
|
}
|
|
|
|
//-----------------------------------------------------------------------------
|
|
// Exports
|
|
//-----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Helper function to define a config array.
|
|
* @param {ConfigWithExtendsArray} args The arguments to the function.
|
|
* @returns {Config[]} The config array.
|
|
*/
|
|
function defineConfig(...args) {
|
|
const configNames = new WeakMap();
|
|
const configs = [];
|
|
|
|
if (args.length === 0) {
|
|
throw new TypeError("Expected one or more arguments.");
|
|
}
|
|
|
|
// first flatten the list of configs and get the names
|
|
for (const { indexPath, value } of flatTraverse(args)) {
|
|
if (typeof value !== "object" || value === null) {
|
|
throw new TypeError(
|
|
`Expected an object but received ${String(value)}.`,
|
|
);
|
|
}
|
|
|
|
const config = /** @type {ConfigWithExtends} */ (value);
|
|
|
|
// save config name for easy reference later
|
|
configNames.set(config, getConfigName(config, indexPath));
|
|
configs.push(config);
|
|
}
|
|
|
|
return processConfigList(configs, configNames);
|
|
}
|
|
|
|
/**
|
|
* @fileoverview Global ignores helper function.
|
|
* @author Nicholas C. Zakas
|
|
*/
|
|
|
|
//-----------------------------------------------------------------------------
|
|
// Type Definitions
|
|
//-----------------------------------------------------------------------------
|
|
|
|
|
|
//-----------------------------------------------------------------------------
|
|
// Helpers
|
|
//-----------------------------------------------------------------------------
|
|
|
|
let globalIgnoreCount = 0;
|
|
|
|
//-----------------------------------------------------------------------------
|
|
// Exports
|
|
//-----------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Creates a global ignores config with the given patterns.
|
|
* @param {string[]} ignorePatterns The ignore patterns.
|
|
* @param {string} [name] The name of the global ignores config.
|
|
* @returns {Config} The global ignores config.
|
|
*/
|
|
function globalIgnores(ignorePatterns, name) {
|
|
if (!Array.isArray(ignorePatterns)) {
|
|
throw new TypeError("ignorePatterns must be an array");
|
|
}
|
|
|
|
if (ignorePatterns.length === 0) {
|
|
throw new TypeError("ignorePatterns must contain at least one pattern");
|
|
}
|
|
|
|
const id = globalIgnoreCount++;
|
|
|
|
return {
|
|
name: name || `globalIgnores ${id}`,
|
|
ignores: ignorePatterns,
|
|
};
|
|
}
|
|
|
|
exports.defineConfig = defineConfig;
|
|
exports.globalIgnores = globalIgnores;
|