588 lines
16 KiB
JavaScript
588 lines
16 KiB
JavaScript
/**
|
|
* @fileoverview Rule to disallow use of the `RegExp` constructor in favor of regular expression literals
|
|
* @author Milos Djermanovic
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Requirements
|
|
//------------------------------------------------------------------------------
|
|
|
|
const astUtils = require('./utils/ast-utils');
|
|
const {
|
|
CALL,
|
|
CONSTRUCT,
|
|
ReferenceTracker,
|
|
findVariable,
|
|
} = require('@eslint-community/eslint-utils');
|
|
const {
|
|
RegExpValidator,
|
|
visitRegExpAST,
|
|
RegExpParser,
|
|
} = require('@eslint-community/regexpp');
|
|
const { canTokensBeAdjacent } = require('./utils/ast-utils');
|
|
const { REGEXPP_LATEST_ECMA_VERSION } = require('./utils/regular-expressions');
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Helpers
|
|
//------------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Determines whether the given node is a string literal.
|
|
* @param {ASTNode} node Node to check.
|
|
* @returns {boolean} True if the node is a string literal.
|
|
*/
|
|
function isStringLiteral(node) {
|
|
return node.type === 'Literal' && typeof node.value === 'string';
|
|
}
|
|
|
|
/**
|
|
* Determines whether the given node is a regex literal.
|
|
* @param {ASTNode} node Node to check.
|
|
* @returns {boolean} True if the node is a regex literal.
|
|
*/
|
|
function isRegexLiteral(node) {
|
|
return node.type === 'Literal' && Object.hasOwn(node, 'regex');
|
|
}
|
|
|
|
const validPrecedingTokens = new Set([
|
|
'(',
|
|
';',
|
|
'[',
|
|
',',
|
|
'=',
|
|
'+',
|
|
'*',
|
|
'-',
|
|
'?',
|
|
'~',
|
|
'%',
|
|
'**',
|
|
'!',
|
|
'typeof',
|
|
'instanceof',
|
|
'&&',
|
|
'||',
|
|
'??',
|
|
'return',
|
|
'...',
|
|
'delete',
|
|
'void',
|
|
'in',
|
|
'<',
|
|
'>',
|
|
'<=',
|
|
'>=',
|
|
'==',
|
|
'===',
|
|
'!=',
|
|
'!==',
|
|
'<<',
|
|
'>>',
|
|
'>>>',
|
|
'&',
|
|
'|',
|
|
'^',
|
|
':',
|
|
'{',
|
|
'=>',
|
|
'*=',
|
|
'<<=',
|
|
'>>=',
|
|
'>>>=',
|
|
'^=',
|
|
'|=',
|
|
'&=',
|
|
'??=',
|
|
'||=',
|
|
'&&=',
|
|
'**=',
|
|
'+=',
|
|
'-=',
|
|
'/=',
|
|
'%=',
|
|
'/',
|
|
'do',
|
|
'break',
|
|
'continue',
|
|
'debugger',
|
|
'case',
|
|
'throw',
|
|
]);
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Rule Definition
|
|
//------------------------------------------------------------------------------
|
|
|
|
/** @type {import('../types').Rule.RuleModule} */
|
|
module.exports = {
|
|
meta: {
|
|
type: 'suggestion',
|
|
|
|
defaultOptions: [
|
|
{
|
|
disallowRedundantWrapping: false,
|
|
},
|
|
],
|
|
|
|
docs: {
|
|
description:
|
|
'Disallow use of the `RegExp` constructor in favor of regular expression literals',
|
|
recommended: false,
|
|
url: 'https://eslint.org/docs/latest/rules/prefer-regex-literals',
|
|
},
|
|
|
|
hasSuggestions: true,
|
|
|
|
schema: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
disallowRedundantWrapping: {
|
|
type: 'boolean',
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
],
|
|
|
|
messages: {
|
|
unexpectedRegExp:
|
|
"Use a regular expression literal instead of the 'RegExp' constructor.",
|
|
replaceWithLiteral:
|
|
'Replace with an equivalent regular expression literal.',
|
|
replaceWithLiteralAndFlags:
|
|
"Replace with an equivalent regular expression literal with flags '{{ flags }}'.",
|
|
replaceWithIntendedLiteralAndFlags:
|
|
"Replace with a regular expression literal with flags '{{ flags }}'.",
|
|
unexpectedRedundantRegExp:
|
|
"Regular expression literal is unnecessarily wrapped within a 'RegExp' constructor.",
|
|
unexpectedRedundantRegExpWithFlags:
|
|
"Use regular expression literal with flags instead of the 'RegExp' constructor.",
|
|
},
|
|
},
|
|
|
|
create(context) {
|
|
const [{ disallowRedundantWrapping }] = context.options;
|
|
const sourceCode = context.sourceCode;
|
|
|
|
/**
|
|
* Determines whether the given identifier node is a reference to a global variable.
|
|
* @param {ASTNode} node `Identifier` node to check.
|
|
* @returns {boolean} True if the identifier is a reference to a global variable.
|
|
*/
|
|
function isGlobalReference(node) {
|
|
const scope = sourceCode.getScope(node);
|
|
const variable = findVariable(scope, node);
|
|
|
|
return (
|
|
variable !== null &&
|
|
variable.scope.type === 'global' &&
|
|
variable.defs.length === 0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Determines whether the given node is a String.raw`` tagged template expression
|
|
* with a static template literal.
|
|
* @param {ASTNode} node Node to check.
|
|
* @returns {boolean} True if the node is String.raw`` with a static template.
|
|
*/
|
|
function isStringRawTaggedStaticTemplateLiteral(node) {
|
|
return (
|
|
node.type === 'TaggedTemplateExpression' &&
|
|
astUtils.isSpecificMemberAccess(node.tag, 'String', 'raw') &&
|
|
isGlobalReference(astUtils.skipChainExpression(node.tag).object) &&
|
|
astUtils.isStaticTemplateLiteral(node.quasi)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Gets the value of a string
|
|
* @param {ASTNode} node The node to get the string of.
|
|
* @returns {string|null} The value of the node.
|
|
*/
|
|
function getStringValue(node) {
|
|
if (isStringLiteral(node)) {
|
|
return node.value;
|
|
}
|
|
|
|
if (astUtils.isStaticTemplateLiteral(node)) {
|
|
return node.quasis[0].value.cooked;
|
|
}
|
|
|
|
if (isStringRawTaggedStaticTemplateLiteral(node)) {
|
|
return node.quasi.quasis[0].value.raw;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Determines whether the given node is considered to be a static string by the logic of this rule.
|
|
* @param {ASTNode} node Node to check.
|
|
* @returns {boolean} True if the node is a static string.
|
|
*/
|
|
function isStaticString(node) {
|
|
return (
|
|
isStringLiteral(node) ||
|
|
astUtils.isStaticTemplateLiteral(node) ||
|
|
isStringRawTaggedStaticTemplateLiteral(node)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Determines whether the relevant arguments of the given are all static string literals.
|
|
* @param {ASTNode} node Node to check.
|
|
* @returns {boolean} True if all arguments are static strings.
|
|
*/
|
|
function hasOnlyStaticStringArguments(node) {
|
|
const args = node.arguments;
|
|
|
|
if (
|
|
(args.length === 1 || args.length === 2) &&
|
|
args.every(isStaticString)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Determines whether the arguments of the given node indicate that a regex literal is unnecessarily wrapped.
|
|
* @param {ASTNode} node Node to check.
|
|
* @returns {boolean} True if the node already contains a regex literal argument.
|
|
*/
|
|
function isUnnecessarilyWrappedRegexLiteral(node) {
|
|
const args = node.arguments;
|
|
|
|
if (args.length === 1 && isRegexLiteral(args[0])) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
args.length === 2 &&
|
|
isRegexLiteral(args[0]) &&
|
|
isStaticString(args[1])
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns a ecmaVersion compatible for regexpp.
|
|
* @param {number} ecmaVersion The ecmaVersion to convert.
|
|
* @returns {import("@eslint-community/regexpp/ecma-versions").EcmaVersion} The resulting ecmaVersion compatible for regexpp.
|
|
*/
|
|
function getRegexppEcmaVersion(ecmaVersion) {
|
|
if (ecmaVersion <= 5) {
|
|
return 5;
|
|
}
|
|
return Math.min(ecmaVersion, REGEXPP_LATEST_ECMA_VERSION);
|
|
}
|
|
|
|
const regexppEcmaVersion = getRegexppEcmaVersion(
|
|
context.languageOptions.ecmaVersion
|
|
);
|
|
|
|
/**
|
|
* Makes a character escaped or else returns null.
|
|
* @param {string} character The character to escape.
|
|
* @returns {string} The resulting escaped character.
|
|
*/
|
|
function resolveEscapes(character) {
|
|
switch (character) {
|
|
case '\n':
|
|
case '\\\n':
|
|
return '\\n';
|
|
|
|
case '\r':
|
|
case '\\\r':
|
|
return '\\r';
|
|
|
|
case '\t':
|
|
case '\\\t':
|
|
return '\\t';
|
|
|
|
case '\v':
|
|
case '\\\v':
|
|
return '\\v';
|
|
|
|
case '\f':
|
|
case '\\\f':
|
|
return '\\f';
|
|
|
|
case '/':
|
|
return '\\/';
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks whether the given regex and flags are valid for the ecma version or not.
|
|
* @param {string} pattern The regex pattern to check.
|
|
* @param {string | undefined} flags The regex flags to check.
|
|
* @returns {boolean} True if the given regex pattern and flags are valid for the ecma version.
|
|
*/
|
|
function isValidRegexForEcmaVersion(pattern, flags) {
|
|
const validator = new RegExpValidator({
|
|
ecmaVersion: regexppEcmaVersion,
|
|
});
|
|
|
|
try {
|
|
validator.validatePattern(pattern, 0, pattern.length, {
|
|
unicode: flags ? flags.includes('u') : false,
|
|
unicodeSets: flags ? flags.includes('v') : false,
|
|
});
|
|
if (flags) {
|
|
validator.validateFlags(flags);
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks whether two given regex flags contain the same flags or not.
|
|
* @param {string} flagsA The regex flags.
|
|
* @param {string} flagsB The regex flags.
|
|
* @returns {boolean} True if two regex flags contain same flags.
|
|
*/
|
|
function areFlagsEqual(flagsA, flagsB) {
|
|
return [...flagsA].sort().join('') === [...flagsB].sort().join('');
|
|
}
|
|
|
|
/**
|
|
* Merges two regex flags.
|
|
* @param {string} flagsA The regex flags.
|
|
* @param {string} flagsB The regex flags.
|
|
* @returns {string} The merged regex flags.
|
|
*/
|
|
function mergeRegexFlags(flagsA, flagsB) {
|
|
const flagsSet = new Set([...flagsA, ...flagsB]);
|
|
|
|
return [...flagsSet].join('');
|
|
}
|
|
|
|
/**
|
|
* Checks whether a give node can be fixed to the given regex pattern and flags.
|
|
* @param {ASTNode} node The node to check.
|
|
* @param {string} pattern The regex pattern to check.
|
|
* @param {string} flags The regex flags
|
|
* @returns {boolean} True if a node can be fixed to the given regex pattern and flags.
|
|
*/
|
|
function canFixTo(node, pattern, flags) {
|
|
const tokenBefore = sourceCode.getTokenBefore(node);
|
|
|
|
return (
|
|
sourceCode.getCommentsInside(node).length === 0 &&
|
|
(!tokenBefore || validPrecedingTokens.has(tokenBefore.value)) &&
|
|
isValidRegexForEcmaVersion(pattern, flags)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns a safe output code considering the before and after tokens.
|
|
* @param {ASTNode} node The regex node.
|
|
* @param {string} newRegExpValue The new regex expression value.
|
|
* @returns {string} The output code.
|
|
*/
|
|
function getSafeOutput(node, newRegExpValue) {
|
|
const tokenBefore = sourceCode.getTokenBefore(node);
|
|
const tokenAfter = sourceCode.getTokenAfter(node);
|
|
|
|
return (
|
|
((
|
|
tokenBefore &&
|
|
!canTokensBeAdjacent(tokenBefore, newRegExpValue) &&
|
|
tokenBefore.range[1] === node.range[0]
|
|
) ?
|
|
' '
|
|
: '') +
|
|
newRegExpValue +
|
|
((
|
|
tokenAfter &&
|
|
!canTokensBeAdjacent(newRegExpValue, tokenAfter) &&
|
|
node.range[1] === tokenAfter.range[0]
|
|
) ?
|
|
' '
|
|
: '')
|
|
);
|
|
}
|
|
|
|
return {
|
|
Program(node) {
|
|
const scope = sourceCode.getScope(node);
|
|
const tracker = new ReferenceTracker(scope);
|
|
const traceMap = {
|
|
RegExp: {
|
|
[CALL]: true,
|
|
[CONSTRUCT]: true,
|
|
},
|
|
};
|
|
|
|
for (const { node: refNode } of tracker.iterateGlobalReferences(
|
|
traceMap
|
|
)) {
|
|
if (
|
|
disallowRedundantWrapping &&
|
|
isUnnecessarilyWrappedRegexLiteral(refNode)
|
|
) {
|
|
const regexNode = refNode.arguments[0];
|
|
|
|
if (refNode.arguments.length === 2) {
|
|
const suggests = [];
|
|
|
|
const argFlags = getStringValue(refNode.arguments[1]) || '';
|
|
|
|
if (canFixTo(refNode, regexNode.regex.pattern, argFlags)) {
|
|
suggests.push({
|
|
messageId: 'replaceWithLiteralAndFlags',
|
|
pattern: regexNode.regex.pattern,
|
|
flags: argFlags,
|
|
});
|
|
}
|
|
|
|
const literalFlags = regexNode.regex.flags || '';
|
|
const mergedFlags = mergeRegexFlags(literalFlags, argFlags);
|
|
|
|
if (
|
|
!areFlagsEqual(mergedFlags, argFlags) &&
|
|
canFixTo(refNode, regexNode.regex.pattern, mergedFlags)
|
|
) {
|
|
suggests.push({
|
|
messageId: 'replaceWithIntendedLiteralAndFlags',
|
|
pattern: regexNode.regex.pattern,
|
|
flags: mergedFlags,
|
|
});
|
|
}
|
|
|
|
context.report({
|
|
node: refNode,
|
|
messageId: 'unexpectedRedundantRegExpWithFlags',
|
|
suggest: suggests.map(({ flags, pattern, messageId }) => ({
|
|
messageId,
|
|
data: {
|
|
flags,
|
|
},
|
|
fix(fixer) {
|
|
return fixer.replaceText(
|
|
refNode,
|
|
getSafeOutput(refNode, `/${pattern}/${flags}`)
|
|
);
|
|
},
|
|
})),
|
|
});
|
|
} else {
|
|
const outputs = [];
|
|
|
|
if (
|
|
canFixTo(
|
|
refNode,
|
|
regexNode.regex.pattern,
|
|
regexNode.regex.flags
|
|
)
|
|
) {
|
|
outputs.push(sourceCode.getText(regexNode));
|
|
}
|
|
|
|
context.report({
|
|
node: refNode,
|
|
messageId: 'unexpectedRedundantRegExp',
|
|
suggest: outputs.map((output) => ({
|
|
messageId: 'replaceWithLiteral',
|
|
fix(fixer) {
|
|
return fixer.replaceText(
|
|
refNode,
|
|
getSafeOutput(refNode, output)
|
|
);
|
|
},
|
|
})),
|
|
});
|
|
}
|
|
} else if (hasOnlyStaticStringArguments(refNode)) {
|
|
let regexContent = getStringValue(refNode.arguments[0]);
|
|
let noFix = false;
|
|
let flags;
|
|
|
|
if (refNode.arguments[1]) {
|
|
flags = getStringValue(refNode.arguments[1]);
|
|
}
|
|
|
|
if (!canFixTo(refNode, regexContent, flags)) {
|
|
noFix = true;
|
|
}
|
|
|
|
if (
|
|
!/^[-a-zA-Z0-9\\[\](){} \t\r\n\v\f!@#$%^&*+^_=/~`.><?,'"|:;]*$/u.test(
|
|
regexContent
|
|
)
|
|
) {
|
|
noFix = true;
|
|
}
|
|
|
|
if (regexContent && !noFix) {
|
|
let charIncrease = 0;
|
|
|
|
const ast = new RegExpParser({
|
|
ecmaVersion: regexppEcmaVersion,
|
|
}).parsePattern(regexContent, 0, regexContent.length, {
|
|
unicode: flags ? flags.includes('u') : false,
|
|
unicodeSets: flags ? flags.includes('v') : false,
|
|
});
|
|
|
|
visitRegExpAST(ast, {
|
|
onCharacterEnter(characterNode) {
|
|
const escaped = resolveEscapes(characterNode.raw);
|
|
|
|
if (escaped) {
|
|
regexContent =
|
|
regexContent.slice(
|
|
0,
|
|
characterNode.start + charIncrease
|
|
) +
|
|
escaped +
|
|
regexContent.slice(characterNode.end + charIncrease);
|
|
|
|
if (characterNode.raw.length === 1) {
|
|
charIncrease += 1;
|
|
}
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
const newRegExpValue = `/${regexContent || '(?:)'}/${flags || ''}`;
|
|
|
|
context.report({
|
|
node: refNode,
|
|
messageId: 'unexpectedRegExp',
|
|
suggest:
|
|
noFix ?
|
|
[]
|
|
: [
|
|
{
|
|
messageId: 'replaceWithLiteral',
|
|
fix(fixer) {
|
|
return fixer.replaceText(
|
|
refNode,
|
|
getSafeOutput(refNode, newRegExpValue)
|
|
);
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|