eslint-plugin-unicorn
Version:
More than 300 powerful ESLint rules
417 lines (351 loc) • 8.89 kB
JavaScript
import {replaceTemplateElement} from './fix/index.js';
import {isRegexLiteral, isStringLiteral, isTaggedTemplateLiteral} from './ast/index.js';
import {getTemplateElementRaw} from './utils/index.js';
const MESSAGE_ID = 'prefer-unicode-code-point-escapes';
const MESSAGE_ID_SUGGESTION = 'prefer-unicode-code-point-escapes/add-unicode-flag';
const messages = {
[MESSAGE_ID]: 'Prefer Unicode code point escapes.',
[MESSAGE_ID_SUGGESTION]: 'Use Unicode code point escapes and add the `u` flag.',
};
const BACKSLASH = '\\';
const CODE_POINT_ESCAPE_PREFIX = String.raw`\u{`;
const HEX_DIGIT = /^[\da-f]$/iv;
const OCTAL_DIGIT = /^[0-7]$/v;
const CONTROL_LETTER = /^[A-Za-z]$/v;
const MINIMUM_PRINTABLE_ASCII = 0x20;
const MAXIMUM_PRINTABLE_ASCII = 0x7E;
const MAXIMUM_CODE_POINT = 0x10_FF_FF;
const SHORT_ESCAPE_CODE_POINTS = new Set([0, 8, 9, 10, 11, 12, 13, 34, 39, 47, 92, 96]);
function isHexDigit(character) {
return HEX_DIGIT.test(character);
}
function isOctalDigit(character) {
return OCTAL_DIGIT.test(character);
}
function isControlLetter(character) {
return CONTROL_LETTER.test(character);
}
function parseHex(text, start, length) {
const value = text.slice(start, start + length);
if (value.length !== length || [...value].some(character => !isHexDigit(character))) {
return;
}
return Number.parseInt(value, 16);
}
function isHighSurrogate(value) {
return value >= 0xD8_00 && value <= 0xDB_FF;
}
function isLowSurrogate(value) {
return value >= 0xDC_00 && value <= 0xDF_FF;
}
function getSurrogatePairCodePoint(highSurrogate, lowSurrogate) {
return ((highSurrogate - 0xD8_00) * 0x4_00) + (lowSurrogate - 0xDC_00) + 0x1_00_00;
}
function formatCodePointEscape(value) {
return String.raw`\u{${value.toString(16).toUpperCase()}}`;
}
function parseCodePointEscape(text, index) {
if (!text.startsWith(CODE_POINT_ESCAPE_PREFIX, index)) {
return;
}
let end = index + CODE_POINT_ESCAPE_PREFIX.length;
while (isHexDigit(text[end])) {
end++;
}
if (
end === index + CODE_POINT_ESCAPE_PREFIX.length
|| text[end] !== '}'
) {
return;
}
const value = Number.parseInt(text.slice(index + CODE_POINT_ESCAPE_PREFIX.length, end), 16);
if (value > MAXIMUM_CODE_POINT) {
return;
}
return value;
}
function getOctalEscape(text, index) {
const firstDigit = text[index + 1];
if (
firstDigit === '0'
&& !isOctalDigit(text[index + 2])
) {
return;
}
if (!isOctalDigit(firstDigit)) {
return;
}
const maximumLength = firstDigit >= '0' && firstDigit <= '3' ? 3 : 2;
let end = index + 1;
while (
end < index + 1 + maximumLength
&& isOctalDigit(text[end])
) {
end++;
}
return {
end,
replacement: formatCodePointEscape(Number.parseInt(text.slice(index + 1, end), 8)),
};
}
function getControlEscape(text, index) {
const character = text[index + 2];
if (
text[index + 1] !== 'c'
|| !isControlLetter(character)
) {
return;
}
return {
end: index + 3,
replacement: formatCodePointEscape(character.toUpperCase().codePointAt(0) % 32),
};
}
function getHexEscape(text, index) {
if (text[index + 1] !== 'x') {
return;
}
const value = parseHex(text, index + 2, 2);
if (value === undefined) {
return;
}
return {
end: index + 4,
replacement: formatCodePointEscape(value),
value,
};
}
function getUnicodeEscape(text, index, {allowSurrogatePair, allowSurrogate, preferShortEscape}) {
if (
text[index + 1] !== 'u'
|| text[index + 2] === '{'
) {
return;
}
const value = parseHex(text, index + 2, 4);
if (value === undefined) {
return;
}
if (
preferShortEscape
&& SHORT_ESCAPE_CODE_POINTS.has(value)
&& (value !== 0 || !/^\d$/v.test(text[index + 6]))
) {
return;
}
if (
!allowSurrogate
&& (isHighSurrogate(value) || isLowSurrogate(value))
) {
return;
}
const nextEscapeIndex = index + 6;
if (
allowSurrogatePair
&& isHighSurrogate(value)
&& text[nextEscapeIndex] === BACKSLASH
&& text[nextEscapeIndex + 1] === 'u'
) {
const nextValue = parseHex(text, nextEscapeIndex + 2, 4);
if (isLowSurrogate(nextValue)) {
const codePoint = getSurrogatePairCodePoint(value, nextValue);
return {
end: nextEscapeIndex + 6,
replacement: formatCodePointEscape(codePoint),
value: codePoint,
};
}
}
return {
end: index + 6,
replacement: formatCodePointEscape(value),
value,
};
}
function getEscapeReplacement(text, index, {isRegex, isInCharacterClass, allowOctal}) {
const legacyEscape = getHexEscape(text, index)
?? getUnicodeEscape(text, index, {
allowSurrogatePair: !isInCharacterClass,
allowSurrogate: !isRegex || !isInCharacterClass,
preferShortEscape: !isRegex,
});
if (
!isRegex
&& legacyEscape?.value >= MINIMUM_PRINTABLE_ASCII
&& legacyEscape.value <= MAXIMUM_PRINTABLE_ASCII
) {
return;
}
if (legacyEscape) {
return legacyEscape;
}
if (isRegex) {
return getControlEscape(text, index);
}
if (allowOctal) {
return getOctalEscape(text, index);
}
}
function replaceEscapeSequences(text, {isRegex = false, supportsNestedCharacterClasses = false, allowOctal = true} = {}) {
let fixed = '';
let characterClassDepth = 0;
let hasReplacement = false;
let hasCodePointEscape = false;
for (let index = 0; index < text.length; index++) {
const character = text[index];
const isInCharacterClass = characterClassDepth > 0;
if (character === BACKSLASH) {
if (
isRegex
&& parseCodePointEscape(text, index) !== undefined
) {
hasCodePointEscape = true;
}
const replacement = getEscapeReplacement(text, index, {isRegex, isInCharacterClass, allowOctal});
if (replacement) {
fixed += replacement.replacement;
index = replacement.end - 1;
hasReplacement = true;
continue;
}
fixed += character;
if (index + 1 < text.length) {
fixed += text[index + 1];
index++;
}
continue;
}
if (
isRegex
&& character === '['
&& (!isInCharacterClass || supportsNestedCharacterClasses)
) {
characterClassDepth++;
} else if (
isRegex
&& character === ']'
&& isInCharacterClass
) {
characterClassDepth--;
}
fixed += character;
}
return {
fixed,
hasReplacement,
hasCodePointEscape,
};
}
function getStringProblem(node, value, {fix, allowOctal} = {}) {
const {fixed, hasReplacement} = replaceEscapeSequences(value, {allowOctal});
if (!hasReplacement) {
return;
}
return {
node,
messageId: MESSAGE_ID,
fix: fixer => fix ? fix(fixer, fixed) : fixer.replaceText(node, fixed),
};
}
function getRegexLiteralParts(raw) {
const patternEnd = raw.lastIndexOf('/');
return {
pattern: raw.slice(1, patternEnd),
flags: raw.slice(patternEnd + 1),
};
}
function addUnicodeFlag(flags) {
return flags.includes('u') || flags.includes('v') ? flags : `${flags}u`;
}
function isValidRegex(pattern, flags) {
try {
// eslint-disable-next-line no-new
new RegExp(pattern, flags);
return true;
} catch {
return false;
}
}
function getRegexProblem(node) {
const {raw} = node;
const {pattern, flags} = getRegexLiteralParts(raw);
const {fixed, hasReplacement, hasCodePointEscape} = replaceEscapeSequences(pattern, {
isRegex: true,
supportsNestedCharacterClasses: flags.includes('v'),
});
const hasUnicodeFlag = flags.includes('u') || flags.includes('v');
if (!hasReplacement && (hasUnicodeFlag || !hasCodePointEscape)) {
return;
}
const fixedPattern = hasReplacement ? fixed : pattern;
const fixedFlags = addUnicodeFlag(flags);
const fixedRegex = `/${fixedPattern}/${fixedFlags}`;
if (!hasUnicodeFlag) {
if (!isValidRegex(fixedPattern, fixedFlags)) {
return {
node,
messageId: MESSAGE_ID,
};
}
return {
node,
messageId: MESSAGE_ID,
suggest: [
{
messageId: MESSAGE_ID_SUGGESTION,
fix: fixer => fixer.replaceText(node, fixedRegex),
},
],
};
}
return {
node,
messageId: MESSAGE_ID,
fix: fixer => fixer.replaceText(node, fixedRegex),
};
}
/**
@param {import('eslint').Rule.RuleContext} context
*/
const create = context => {
context.on('Literal', node => {
if (
isStringLiteral(node)
&& node.parent.type !== 'JSXAttribute'
) {
return getStringProblem(node, node.raw);
}
if (isRegexLiteral(node)) {
return getRegexProblem(node);
}
});
context.on('TemplateElement', node => {
if (isTaggedTemplateLiteral(node.parent)) {
return;
}
return getStringProblem(node, getTemplateElementRaw(node, context), {
allowOctal: false,
fix: (fixer, fixed) => replaceTemplateElement(node, fixed, context, fixer),
});
});
};
/**
@type {import('eslint').Rule.RuleModule}
*/
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Prefer Unicode code point escapes over legacy escape sequences.',
recommended: 'unopinionated',
},
fixable: 'code',
hasSuggestions: true,
messages,
languages: [
'js/js',
],
},
};
export default config;