eslint-plugin-sonarjs
Version:
SonarJS rules for ESLint
230 lines (229 loc) • 9.79 kB
JavaScript
/*
* SonarQube JavaScript Plugin
* Copyright (C) 2011-2025 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
* See the Sonar Source-Available License for more details.
*
* You should have received a copy of the Sonar Source-Available License
* along with this program; if not, see https://sonarsource.com/license/ssal/
*/
// https://sonarsource.github.io/rspec/#/rspec/S5868/javascript
Object.defineProperty(exports, "__esModule", { value: true });
exports.rule = void 0;
const index_js_1 = require("../helpers/index.js");
const regexpp_1 = require("@eslint-community/regexpp");
const meta_js_1 = require("./meta.js");
const extract_js_1 = require("../helpers/regex/extract.js");
const flags_js_1 = require("../helpers/regex/flags.js");
const rule_template_js_1 = require("../helpers/regex/rule-template.js");
const ast_js_1 = require("../helpers/regex/ast.js");
const MODIFIABLE_REGEXP_FLAGS_TYPES = [
'Literal',
'TemplateLiteral',
'TaggedTemplateExpression',
];
exports.rule = (0, rule_template_js_1.createRegExpRule)(context => {
function characters(nodes) {
let current = [];
const sequences = [current];
for (const node of nodes) {
if (node.type === 'Character') {
current.push(node);
}
else if (node.type === 'CharacterClassRange') {
// for following regexp [xa-z] we produce [[xa],[z]]
// we would report for example if instead of 'xa' there would be unicode combined class
current.push(node.min);
current = [node.max];
sequences.push(current);
}
else if (node.type === 'CharacterSet' && current.length > 0) {
// CharacterSet is for example [\d], ., or \p{ASCII}
// see https://github.com/mysticatea/regexpp/blob/master/src/ast.ts#L222
current = [];
sequences.push(current);
}
}
return sequences;
}
function checkSequence(sequence) {
// Stop on the first illegal character in the sequence
for (let index = 0; index < sequence.length; index++) {
if (checkCharacter(sequence[index], index, sequence)) {
return;
}
}
}
function checkCharacter(character, index, characters) {
// Stop on the first failed check as there may be overlaps between checks
// for instance a zero-width-sequence containing a modified emoji.
for (const check of characterChecks) {
if (check(character, index, characters)) {
return true;
}
}
return false;
}
function checkCombinedCharacter(character, index, characters) {
let reported = false;
if (index !== 0 &&
isCombiningCharacter(character.value) &&
!isCombiningCharacter(characters[index - 1].value)) {
const combinedChar = characters[index - 1].raw + characters[index].raw;
const message = `Move this Unicode combined character '${combinedChar}' outside of the character class`;
context.reportRegExpNode({ regexpNode: characters[index], node: context.node, message });
reported = true;
}
return reported;
}
function checkSurrogatePairTailCharacter(character, index, characters) {
let reported = false;
if (index !== 0 && isSurrogatePair(characters[index - 1].value, character.value)) {
const surrogatePair = characters[index - 1].raw + characters[index].raw;
const message = `Move this Unicode surrogate pair '${surrogatePair}' outside of the character class or use 'u' flag`;
const pattern = (0, extract_js_1.getPatternFromNode)(context.node, context)?.pattern;
let suggest;
if (pattern && isValidWithUnicodeFlag(pattern)) {
suggest = [
{
desc: "Add unicode 'u' flag to regex",
fix: fixer => addUnicodeFlag(fixer, context.node),
},
];
}
context.reportRegExpNode({
regexpNode: characters[index],
node: context.node,
message,
suggest,
});
reported = true;
}
return reported;
}
function addUnicodeFlag(fixer, node) {
if ((0, index_js_1.isRegexLiteral)(node)) {
return insertTextAfter(fixer, node, 'u');
}
const regExpConstructor = getRegExpConstructor(node);
if (!regExpConstructor) {
return null;
}
const args = regExpConstructor.arguments;
if (args.length === 1) {
const token = sourceCode.getLastToken(regExpConstructor, { skip: 1 });
return insertTextAfter(fixer, token, ', "u"');
}
if (args.length > 1 && args[1]?.range && hasModifiableFlags(regExpConstructor)) {
const [start, end] = args[1].range;
return fixer.insertTextAfterRange([start, end - 1], 'u');
}
return null;
}
function checkModifiedEmojiCharacter(character, index, characters) {
let reported = false;
if (index !== 0 &&
isEmojiModifier(character.value) &&
!isEmojiModifier(characters[index - 1].value)) {
const modifiedEmoji = characters[index - 1].raw + characters[index].raw;
const message = `Move this Unicode modified Emoji '${modifiedEmoji}' outside of the character class`;
context.reportRegExpNode({ regexpNode: characters[index], node: context.node, message });
reported = true;
}
return reported;
}
function checkRegionalIndicatorCharacter(character, index, characters) {
let reported = false;
if (index !== 0 &&
isRegionalIndicator(character.value) &&
isRegionalIndicator(characters[index - 1].value)) {
const regionalIndicator = characters[index - 1].raw + characters[index].raw;
const message = `Move this Unicode regional indicator '${regionalIndicator}' outside of the character class`;
context.reportRegExpNode({ regexpNode: characters[index], node: context.node, message });
reported = true;
}
return reported;
}
function checkZeroWidthJoinerCharacter(character, index, characters) {
let reported = false;
if (index !== 0 &&
index !== characters.length - 1 &&
isZeroWidthJoiner(character.value) &&
!isZeroWidthJoiner(characters[index - 1].value) &&
!isZeroWidthJoiner(characters[index + 1].value)) {
// It's practically difficult to determine the full joined character sequence
// as it may join more than 2 elements that consist of characters or modified Emojis
// see: https://unicode.org/emoji/charts/emoji-zwj-sequences.html
const message = 'Move this Unicode joined character sequence outside of the character class';
context.reportRegExpNode({
regexpNode: characters[index - 1],
node: context.node,
message,
});
reported = true;
}
return reported;
}
function isValidWithUnicodeFlag(pattern) {
try {
validator.validatePattern(pattern, undefined, undefined, true);
return true;
}
catch {
return false;
}
}
const sourceCode = context.sourceCode;
const validator = new regexpp_1.RegExpValidator();
// The order matters as surrogate pair check may trigger at the same time as zero-width-joiner.
const characterChecks = [
checkCombinedCharacter,
checkZeroWidthJoinerCharacter,
checkModifiedEmojiCharacter,
checkRegionalIndicatorCharacter,
checkSurrogatePairTailCharacter,
];
return {
onCharacterClassEnter(ccNode) {
for (const chars of characters(ccNode.elements)) {
checkSequence(chars);
}
},
};
}, (0, index_js_1.generateMeta)(meta_js_1.meta, { hasSuggestions: true }));
function isCombiningCharacter(codePoint) {
return /^[\p{Mc}\p{Me}\p{Mn}]$/u.test(String.fromCodePoint(codePoint));
}
function isSurrogatePair(lead, tail) {
return lead >= 0xd800 && lead < 0xdc00 && tail >= 0xdc00 && tail < 0xe000;
}
function isEmojiModifier(code) {
return code >= 0x1f3fb && code <= 0x1f3ff;
}
function isRegionalIndicator(code) {
return code >= 0x1f1e6 && code <= 0x1f1ff;
}
function isZeroWidthJoiner(code) {
return code === 0x200d;
}
function getRegExpConstructor(node) {
return (0, index_js_1.ancestorsChain)(node, new Set(['CallExpression', 'NewExpression'])).find(n => (0, ast_js_1.isRegExpConstructor)(n));
}
function hasModifiableFlags(regExpConstructor) {
const args = regExpConstructor.arguments;
return (typeof args[1]?.range?.[0] === 'number' &&
typeof args[1]?.range?.[1] === 'number' &&
(0, flags_js_1.getFlags)(regExpConstructor) != null &&
MODIFIABLE_REGEXP_FLAGS_TYPES.includes(args[1].type));
}
function insertTextAfter(fixer, node, text) {
return node ? fixer.insertTextAfter(node, text) : null;
}
;