UNPKG

eslint-plugin-unicorn

Version:
417 lines (351 loc) • 8.89 kB
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;