@eslint/compat
Version:
Compatibility utilities for ESLint
559 lines (471 loc) • 16.6 kB
JavaScript
// @ts-self-types="./index.d.ts"
import fs from 'node:fs';
import path from 'node:path';
/**
* @fileoverview Functions to fix up rules to provide missing methods on the `context` and `sourceCode` objects.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/** @typedef {import("@eslint/core").Plugin} FixupPluginDefinition */
/** @typedef {import("@eslint/core").RuleDefinition} FixupRuleDefinition */
/** @typedef {FixupRuleDefinition["create"]} FixupLegacyRuleDefinition */
/** @typedef {import("@eslint/core").ConfigObject} FixupConfig */
/** @typedef {Array<FixupConfig>} FixupConfigArray */
//-----------------------------------------------------------------------------
// Data
//-----------------------------------------------------------------------------
/**
* The removed methods from the `context` object that need to be added back.
* The keys are the name of the method on the `context` object and the values
* are the name of the method on the `sourceCode` object.
* @type {Map<string, string>}
*/
const removedMethodNames = new Map([
["getSource", "getText"],
["getSourceLines", "getLines"],
["getAllComments", "getAllComments"],
["getDeclaredVariables", "getDeclaredVariables"],
["getNodeByRangeIndex", "getNodeByRangeIndex"],
["getCommentsBefore", "getCommentsBefore"],
["getCommentsAfter", "getCommentsAfter"],
["getCommentsInside", "getCommentsInside"],
["getJSDocComment", "getJSDocComment"],
["getFirstToken", "getFirstToken"],
["getFirstTokens", "getFirstTokens"],
["getLastToken", "getLastToken"],
["getLastTokens", "getLastTokens"],
["getTokenAfter", "getTokenAfter"],
["getTokenBefore", "getTokenBefore"],
["getTokenByRangeStart", "getTokenByRangeStart"],
["getTokens", "getTokens"],
["getTokensAfter", "getTokensAfter"],
["getTokensBefore", "getTokensBefore"],
["getTokensBetween", "getTokensBetween"],
]);
/**
* Tracks the original rule definition and the fixed-up rule definition.
* @type {WeakMap<FixupRuleDefinition|FixupLegacyRuleDefinition,FixupRuleDefinition>}
*/
const fixedUpRuleReplacements = new WeakMap();
/**
* Tracks all of the fixed up rule definitions so we don't duplicate effort.
* @type {WeakSet<FixupRuleDefinition>}
*/
const fixedUpRules = new WeakSet();
/**
* Tracks the original plugin definition and the fixed-up plugin definition.
* @type {WeakMap<FixupPluginDefinition,FixupPluginDefinition>}
*/
const fixedUpPluginReplacements = new WeakMap();
/**
* Tracks all of the fixed up plugin definitions so we don't duplicate effort.
* @type {WeakSet<FixupPluginDefinition>}
*/
const fixedUpPlugins = new WeakSet();
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Determines if two nodes or tokens overlap.
* @param {object} first The first node or token to check.
* @param {object} second The second node or token to check.
* @returns {boolean} True if the two nodes or tokens overlap.
*/
function nodesOrTokensOverlap(first, second) {
return (
(first.range[0] <= second.range[0] &&
first.range[1] >= second.range[0]) ||
(second.range[0] <= first.range[0] && second.range[1] >= first.range[0])
);
}
/**
* Checks whether a node is an export declaration.
* @param {object} node An AST node.
* @returns {boolean} True if the node is an export declaration.
*/
function looksLikeExport(node) {
return (
node.type === "ExportDefaultDeclaration" ||
node.type === "ExportNamedDeclaration"
);
}
/**
* Checks for the presence of a JSDoc comment for the given node and returns it.
* @param {object} node The AST node to get the comment for.
* @param {object} sourceCode A SourceCode instance to get comments.
* @returns {object|null} The Block comment token containing the JSDoc comment
* for the given node or null if not found.
*/
function findJSDocComment(node, sourceCode) {
const tokenBefore = sourceCode.getTokenBefore(node, {
includeComments: true,
});
if (
tokenBefore &&
tokenBefore.type === "Block" &&
tokenBefore.value.charAt(0) === "*" &&
node.loc.start.line - tokenBefore.loc.end.line <= 1
) {
return tokenBefore;
}
return null;
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Takes the given rule and creates a new rule with the `create()` method wrapped
* to provide missing methods on the `context` and `sourceCode` objects.
* @param {FixupRuleDefinition|FixupLegacyRuleDefinition} ruleDefinition The rule to fix up.
* @returns {FixupRuleDefinition} The fixed-up rule.
*/
function fixupRule(ruleDefinition) {
// first check if we've already fixed up this rule
if (fixedUpRuleReplacements.has(ruleDefinition)) {
return fixedUpRuleReplacements.get(ruleDefinition);
}
const isLegacyRule = typeof ruleDefinition === "function";
// check to see if this rule definition has already been fixed up
if (!isLegacyRule && fixedUpRules.has(ruleDefinition)) {
return ruleDefinition;
}
const originalCreate = isLegacyRule
? ruleDefinition
: ruleDefinition.create.bind(ruleDefinition);
function ruleCreate(context) {
const sourceCode = context.sourceCode;
// No need to create old methods for ESLint < 9
if ("getScope" in context) {
return originalCreate(context);
}
let eslintVersion = 9;
if (!("getCwd" in context)) {
eslintVersion = 10;
}
let compatSourceCode = sourceCode;
if (eslintVersion >= 10) {
compatSourceCode = Object.assign(Object.create(sourceCode), {
getTokenOrCommentBefore(node, skip) {
return sourceCode.getTokenBefore(node, {
includeComments: true,
skip,
});
},
getTokenOrCommentAfter(node, skip) {
return sourceCode.getTokenAfter(node, {
includeComments: true,
skip,
});
},
isSpaceBetweenTokens(first, second) {
if (nodesOrTokensOverlap(first, second)) {
return false;
}
const [startingNodeOrToken, endingNodeOrToken] =
first.range[1] <= second.range[0]
? [first, second]
: [second, first];
const firstToken =
sourceCode.getLastToken(startingNodeOrToken) ||
startingNodeOrToken;
const finalToken =
sourceCode.getFirstToken(endingNodeOrToken) ||
endingNodeOrToken;
let currentToken = firstToken;
while (currentToken !== finalToken) {
const nextToken = sourceCode.getTokenAfter(
currentToken,
{
includeComments: true,
},
);
if (
currentToken.range[1] !== nextToken.range[0] ||
(nextToken !== finalToken &&
nextToken.type === "JSXText" &&
/\s/u.test(nextToken.value))
) {
return true;
}
currentToken = nextToken;
}
return false;
},
getJSDocComment(node) {
let parent = node.parent;
switch (node.type) {
case "ClassDeclaration":
case "FunctionDeclaration":
return findJSDocComment(
looksLikeExport(parent) ? parent : node,
sourceCode,
);
case "ClassExpression":
return findJSDocComment(parent.parent, sourceCode);
case "ArrowFunctionExpression":
case "FunctionExpression":
if (
parent.type !== "CallExpression" &&
parent.type !== "NewExpression"
) {
while (
!sourceCode.getCommentsBefore(parent)
.length &&
!/Function/u.test(parent.type) &&
parent.type !== "MethodDefinition" &&
parent.type !== "Property"
) {
parent = parent.parent;
if (!parent) {
break;
}
}
if (
parent &&
parent.type !== "FunctionDeclaration" &&
parent.type !== "Program"
) {
return findJSDocComment(parent, sourceCode);
}
}
return findJSDocComment(node, sourceCode);
default:
return null;
}
},
});
Object.freeze(compatSourceCode);
}
let currentNode = compatSourceCode.ast;
const compatContext = Object.assign(Object.create(context), {
parserServices: compatSourceCode.parserServices,
/*
* The following methods rely on the current node in the traversal,
* so we need to add them manually.
*/
getScope() {
return compatSourceCode.getScope(currentNode);
},
getAncestors() {
return compatSourceCode.getAncestors(currentNode);
},
markVariableAsUsed(variable) {
compatSourceCode.markVariableAsUsed(variable, currentNode);
},
});
if (eslintVersion >= 10) {
Object.assign(compatContext, {
parserOptions: compatContext.languageOptions.parserOptions,
getCwd() {
return compatContext.cwd;
},
getFilename() {
return compatContext.filename;
},
getPhysicalFilename() {
return compatContext.physicalFilename;
},
getSourceCode() {
return compatSourceCode;
},
});
Object.defineProperty(compatContext, "sourceCode", {
enumerable: true,
value: compatSourceCode,
});
}
// add passthrough methods
for (const [
contextMethodName,
sourceCodeMethodName,
] of removedMethodNames) {
compatContext[contextMethodName] =
compatSourceCode[sourceCodeMethodName].bind(compatSourceCode);
}
// freeze just like the original context
Object.freeze(compatContext);
/*
* Create the visitor object using the original create() method.
* This is necessary to ensure that the visitor object is created
* with the correct context.
*/
const visitor = originalCreate(compatContext);
/*
* Wrap each method in the visitor object to update the currentNode
* before calling the original method. This is necessary because the
* methods like `getScope()` need to know the current node.
*/
for (const [methodName, method] of Object.entries(visitor)) {
/*
* Node is the second argument to most code path methods,
* and the third argument for onCodePathSegmentLoop.
*/
if (methodName.startsWith("on")) {
// eslint-disable-next-line no-loop-func -- intentionally updating shared `currentNode` variable
visitor[methodName] = (...args) => {
currentNode =
args[methodName === "onCodePathSegmentLoop" ? 2 : 1];
return method.call(visitor, ...args);
};
continue;
}
// eslint-disable-next-line no-loop-func -- intentionally updating shared `currentNode` variable
visitor[methodName] = (...args) => {
currentNode = args[0];
return method.call(visitor, ...args);
};
}
return visitor;
}
const newRuleDefinition = {
...(isLegacyRule ? undefined : ruleDefinition),
create: ruleCreate,
};
// copy `schema` property of function-style rule or top-level `schema` property of object-style rule into `meta` object
// @ts-ignore -- top-level `schema` property was not officially supported for object-style rules so it doesn't exist in types
const { schema } = ruleDefinition;
if (schema) {
if (!newRuleDefinition.meta) {
newRuleDefinition.meta = { schema };
} else {
newRuleDefinition.meta = {
...newRuleDefinition.meta,
// top-level `schema` had precedence over `meta.schema` so it's okay to overwrite `meta.schema` if it exists
schema,
};
}
}
// cache the fixed up rule
fixedUpRuleReplacements.set(ruleDefinition, newRuleDefinition);
fixedUpRules.add(newRuleDefinition);
return newRuleDefinition;
}
/**
* Takes the given plugin and creates a new plugin with all of the rules wrapped
* to provide missing methods on the `context` and `sourceCode` objects.
* @param {FixupPluginDefinition} plugin The plugin to fix up.
* @returns {FixupPluginDefinition} The fixed-up plugin.
*/
function fixupPluginRules(plugin) {
// first check if we've already fixed up this plugin
if (fixedUpPluginReplacements.has(plugin)) {
return fixedUpPluginReplacements.get(plugin);
}
/*
* If the plugin has already been fixed up, or if the plugin
* doesn't have any rules, we can just return it.
*/
if (fixedUpPlugins.has(plugin) || !plugin.rules) {
return plugin;
}
const newPlugin = {
...plugin,
rules: Object.fromEntries(
Object.entries(plugin.rules).map(([ruleId, ruleDefinition]) => [
ruleId,
fixupRule(ruleDefinition),
]),
),
};
// cache the fixed up plugin
fixedUpPluginReplacements.set(plugin, newPlugin);
fixedUpPlugins.add(newPlugin);
return newPlugin;
}
/**
* Takes the given configuration and creates a new configuration with all of the
* rules wrapped to provide missing methods on the `context` and `sourceCode` objects.
* @param {FixupConfigArray|FixupConfig} config The configuration to fix up.
* @returns {FixupConfigArray} The fixed-up configuration.
*/
function fixupConfigRules(config) {
const configs = Array.isArray(config) ? config : [config];
return configs.map(configItem => {
if (!configItem.plugins) {
return configItem;
}
const newPlugins = Object.fromEntries(
Object.entries(configItem.plugins).map(([pluginName, plugin]) => [
pluginName,
fixupPluginRules(plugin),
]),
);
return {
...configItem,
plugins: newPlugins,
};
});
}
/**
* @fileoverview Ignore file utilities for the compat package.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/** @typedef {import("@eslint/core").ConfigObject} FlatConfig */
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Converts an ESLint ignore pattern to a minimatch pattern.
* @param {string} pattern The .eslintignore or .gitignore pattern to convert.
* @returns {string} The converted pattern.
*/
function convertIgnorePatternToMinimatch(pattern) {
const isNegated = pattern.startsWith("!");
const negatedPrefix = isNegated ? "!" : "";
const patternToTest = (isNegated ? pattern.slice(1) : pattern).trimEnd();
// special cases
if (["", "**", "/**", "**/"].includes(patternToTest)) {
return `${negatedPrefix}${patternToTest}`;
}
const firstIndexOfSlash = patternToTest.indexOf("/");
const matchEverywherePrefix =
firstIndexOfSlash < 0 || firstIndexOfSlash === patternToTest.length - 1
? "**/"
: "";
const patternWithoutLeadingSlash =
firstIndexOfSlash === 0 ? patternToTest.slice(1) : patternToTest;
/*
* Escape `{` and `(` because in gitignore patterns they are just
* literal characters without any specific syntactic meaning,
* while in minimatch patterns they can form brace expansion or extglob syntax.
*
* For example, gitignore pattern `src/{a,b}.js` ignores file `src/{a,b}.js`.
* But, the same minimatch pattern `src/{a,b}.js` ignores files `src/a.js` and `src/b.js`.
* Minimatch pattern `src/\{a,b}.js` is equivalent to gitignore pattern `src/{a,b}.js`.
*/
const escapedPatternWithoutLeadingSlash =
patternWithoutLeadingSlash.replaceAll(
// eslint-disable-next-line regexp/no-empty-lookarounds-assertion -- False positive
/(?=((?:\\.|[^{(])*))\1([{(])/guy,
"$1\\$2",
);
const matchInsideSuffix = patternToTest.endsWith("/**") ? "/*" : "";
return `${negatedPrefix}${matchEverywherePrefix}${escapedPatternWithoutLeadingSlash}${matchInsideSuffix}`;
}
/**
* Reads an ignore file and returns an object with the ignore patterns.
* @param {string} ignoreFilePath The absolute path to the ignore file.
* @param {string} [name] The name of the ignore file config.
* @returns {FlatConfig} An object with an `ignores` property that is an array of ignore patterns.
* @throws {Error} If the ignore file path is not an absolute path.
*/
function includeIgnoreFile(ignoreFilePath, name) {
if (!path.isAbsolute(ignoreFilePath)) {
throw new Error("The ignore file location must be an absolute path.");
}
const ignoreFile = fs.readFileSync(ignoreFilePath, "utf8");
const lines = ignoreFile.split(/\r?\n/u);
return {
name: name || "Imported .gitignore patterns",
ignores: lines
.map(line => line.trim())
.filter(line => line && !line.startsWith("#"))
.map(convertIgnorePatternToMinimatch),
};
}
export { convertIgnorePatternToMinimatch, fixupConfigRules, fixupPluginRules, fixupRule, includeIgnoreFile };