no-useless-escape.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. /**
  2. * @fileoverview Look for useless escapes in strings and regexes
  3. * @author Onur Temizkan
  4. */
  5. "use strict";
  6. const astUtils = require("./utils/ast-utils");
  7. const { RegExpParser, visitRegExpAST } = require("@eslint-community/regexpp");
  8. /**
  9. * @typedef {import('@eslint-community/regexpp').AST.CharacterClass} CharacterClass
  10. * @typedef {import('@eslint-community/regexpp').AST.ExpressionCharacterClass} ExpressionCharacterClass
  11. */
  12. //------------------------------------------------------------------------------
  13. // Rule Definition
  14. //------------------------------------------------------------------------------
  15. /**
  16. * Returns the union of two sets.
  17. * @param {Set} setA The first set
  18. * @param {Set} setB The second set
  19. * @returns {Set} The union of the two sets
  20. */
  21. function union(setA, setB) {
  22. return new Set(
  23. (function* () {
  24. yield* setA;
  25. yield* setB;
  26. })(),
  27. );
  28. }
  29. const VALID_STRING_ESCAPES = union(new Set("\\nrvtbfux"), astUtils.LINEBREAKS);
  30. const REGEX_GENERAL_ESCAPES = new Set("\\bcdDfnpPrsStvwWxu0123456789]");
  31. const REGEX_NON_CHARCLASS_ESCAPES = union(
  32. REGEX_GENERAL_ESCAPES,
  33. new Set("^/.$*+?[{}|()Bk"),
  34. );
  35. /*
  36. * Set of characters that require escaping in character classes in `unicodeSets` mode.
  37. * ( ) [ ] { } / - \ | are ClassSetSyntaxCharacter
  38. */
  39. const REGEX_CLASSSET_CHARACTER_ESCAPES = union(
  40. REGEX_GENERAL_ESCAPES,
  41. new Set("q/[{}|()-"),
  42. );
  43. /*
  44. * A single character set of ClassSetReservedDoublePunctuator.
  45. * && !! ## $$ %% ** ++ ,, .. :: ;; << == >> ?? @@ ^^ `` ~~ are ClassSetReservedDoublePunctuator
  46. */
  47. const REGEX_CLASS_SET_RESERVED_DOUBLE_PUNCTUATOR = new Set(
  48. "!#$%&*+,.:;<=>?@^`~",
  49. );
  50. /** @type {import('../types').Rule.RuleModule} */
  51. module.exports = {
  52. meta: {
  53. type: "suggestion",
  54. defaultOptions: [
  55. {
  56. allowRegexCharacters: [],
  57. },
  58. ],
  59. docs: {
  60. description: "Disallow unnecessary escape characters",
  61. recommended: true,
  62. url: "https://eslint.org/docs/latest/rules/no-useless-escape",
  63. },
  64. hasSuggestions: true,
  65. messages: {
  66. unnecessaryEscape: "Unnecessary escape character: \\{{character}}.",
  67. removeEscape:
  68. "Remove the `\\`. This maintains the current functionality.",
  69. removeEscapeDoNotKeepSemantics:
  70. "Remove the `\\` if it was inserted by mistake.",
  71. escapeBackslash:
  72. "Replace the `\\` with `\\\\` to include the actual backslash character.",
  73. },
  74. schema: [
  75. {
  76. type: "object",
  77. properties: {
  78. allowRegexCharacters: {
  79. type: "array",
  80. items: {
  81. type: "string",
  82. },
  83. uniqueItems: true,
  84. },
  85. },
  86. additionalProperties: false,
  87. },
  88. ],
  89. },
  90. create(context) {
  91. const sourceCode = context.sourceCode;
  92. const [{ allowRegexCharacters }] = context.options;
  93. const parser = new RegExpParser();
  94. /**
  95. * Reports a node
  96. * @param {ASTNode} node The node to report
  97. * @param {number} startOffset The backslash's offset from the start of the node
  98. * @param {string} character The uselessly escaped character (not including the backslash)
  99. * @param {boolean} [disableEscapeBackslashSuggest] `true` if escapeBackslash suggestion should be turned off.
  100. * @returns {void}
  101. */
  102. function report(
  103. node,
  104. startOffset,
  105. character,
  106. disableEscapeBackslashSuggest,
  107. ) {
  108. const rangeStart = node.range[0] + startOffset;
  109. const range = [rangeStart, rangeStart + 1];
  110. const start = sourceCode.getLocFromIndex(rangeStart);
  111. context.report({
  112. node,
  113. loc: {
  114. start,
  115. end: { line: start.line, column: start.column + 1 },
  116. },
  117. messageId: "unnecessaryEscape",
  118. data: { character },
  119. suggest: [
  120. {
  121. // Removing unnecessary `\` characters in a directive is not guaranteed to maintain functionality.
  122. messageId: astUtils.isDirective(node.parent)
  123. ? "removeEscapeDoNotKeepSemantics"
  124. : "removeEscape",
  125. fix(fixer) {
  126. return fixer.removeRange(range);
  127. },
  128. },
  129. ...(disableEscapeBackslashSuggest
  130. ? []
  131. : [
  132. {
  133. messageId: "escapeBackslash",
  134. fix(fixer) {
  135. return fixer.insertTextBeforeRange(
  136. range,
  137. "\\",
  138. );
  139. },
  140. },
  141. ]),
  142. ],
  143. });
  144. }
  145. /**
  146. * Checks if the escape character in given string slice is unnecessary.
  147. * @private
  148. * @param {ASTNode} node node to validate.
  149. * @param {string} match string slice to validate.
  150. * @returns {void}
  151. */
  152. function validateString(node, match) {
  153. const isTemplateElement = node.type === "TemplateElement";
  154. const escapedChar = match[0][1];
  155. let isUnnecessaryEscape = !VALID_STRING_ESCAPES.has(escapedChar);
  156. let isQuoteEscape;
  157. if (isTemplateElement) {
  158. isQuoteEscape = escapedChar === "`";
  159. if (escapedChar === "$") {
  160. // Warn if `\$` is not followed by `{`
  161. isUnnecessaryEscape = match.input[match.index + 2] !== "{";
  162. } else if (escapedChar === "{") {
  163. /*
  164. * Warn if `\{` is not preceded by `$`. If preceded by `$`, escaping
  165. * is necessary and the rule should not warn. If preceded by `/$`, the rule
  166. * will warn for the `/$` instead, as it is the first unnecessarily escaped character.
  167. */
  168. isUnnecessaryEscape = match.input[match.index - 1] !== "$";
  169. }
  170. } else {
  171. isQuoteEscape = escapedChar === node.raw[0];
  172. }
  173. if (isUnnecessaryEscape && !isQuoteEscape) {
  174. report(node, match.index, match[0].slice(1));
  175. }
  176. }
  177. /**
  178. * Checks if the escape character in given regexp is unnecessary.
  179. * @private
  180. * @param {ASTNode} node node to validate.
  181. * @returns {void}
  182. */
  183. function validateRegExp(node) {
  184. const { pattern, flags } = node.regex;
  185. let patternNode;
  186. const unicode = flags.includes("u");
  187. const unicodeSets = flags.includes("v");
  188. try {
  189. patternNode = parser.parsePattern(pattern, 0, pattern.length, {
  190. unicode,
  191. unicodeSets,
  192. });
  193. } catch {
  194. // Ignore regular expressions with syntax errors
  195. return;
  196. }
  197. /** @type {(CharacterClass | ExpressionCharacterClass)[]} */
  198. const characterClassStack = [];
  199. visitRegExpAST(patternNode, {
  200. onCharacterClassEnter: characterClassNode =>
  201. characterClassStack.unshift(characterClassNode),
  202. onCharacterClassLeave: () => characterClassStack.shift(),
  203. onExpressionCharacterClassEnter: characterClassNode =>
  204. characterClassStack.unshift(characterClassNode),
  205. onExpressionCharacterClassLeave: () =>
  206. characterClassStack.shift(),
  207. onCharacterEnter(characterNode) {
  208. if (!characterNode.raw.startsWith("\\")) {
  209. // It's not an escaped character.
  210. return;
  211. }
  212. const escapedChar = characterNode.raw.slice(1);
  213. if (
  214. escapedChar !==
  215. String.fromCodePoint(characterNode.value) ||
  216. allowRegexCharacters.includes(escapedChar)
  217. ) {
  218. // It's a valid escape.
  219. return;
  220. }
  221. let allowedEscapes;
  222. if (characterClassStack.length) {
  223. allowedEscapes = unicodeSets
  224. ? REGEX_CLASSSET_CHARACTER_ESCAPES
  225. : REGEX_GENERAL_ESCAPES;
  226. } else {
  227. allowedEscapes = REGEX_NON_CHARCLASS_ESCAPES;
  228. }
  229. if (allowedEscapes.has(escapedChar)) {
  230. return;
  231. }
  232. const reportedIndex = characterNode.start + 1;
  233. let disableEscapeBackslashSuggest = false;
  234. if (characterClassStack.length) {
  235. const characterClassNode = characterClassStack[0];
  236. if (escapedChar === "^") {
  237. /*
  238. * The '^' character is also a special case; it must always be escaped outside of character classes, but
  239. * it only needs to be escaped in character classes if it's at the beginning of the character class. To
  240. * account for this, consider it to be a valid escape character outside of character classes, and filter
  241. * out '^' characters that appear at the start of a character class.
  242. */
  243. if (
  244. characterClassNode.start + 1 ===
  245. characterNode.start
  246. ) {
  247. return;
  248. }
  249. }
  250. if (!unicodeSets) {
  251. if (escapedChar === "-") {
  252. /*
  253. * The '-' character is a special case, because it's only valid to escape it if it's in a character
  254. * class, and is not at either edge of the character class. To account for this, don't consider '-'
  255. * characters to be valid in general, and filter out '-' characters that appear in the middle of a
  256. * character class.
  257. */
  258. if (
  259. characterClassNode.start + 1 !==
  260. characterNode.start &&
  261. characterNode.end !==
  262. characterClassNode.end - 1
  263. ) {
  264. return;
  265. }
  266. }
  267. } else {
  268. // unicodeSets mode
  269. if (
  270. REGEX_CLASS_SET_RESERVED_DOUBLE_PUNCTUATOR.has(
  271. escapedChar,
  272. )
  273. ) {
  274. // Escaping is valid if it is a ClassSetReservedDoublePunctuator.
  275. if (
  276. pattern[characterNode.end] === escapedChar
  277. ) {
  278. return;
  279. }
  280. if (
  281. pattern[characterNode.start - 1] ===
  282. escapedChar
  283. ) {
  284. if (escapedChar !== "^") {
  285. return;
  286. }
  287. // If the previous character is a `negate` caret(`^`), escape to caret is unnecessary.
  288. if (!characterClassNode.negate) {
  289. return;
  290. }
  291. const negateCaretIndex =
  292. characterClassNode.start + 1;
  293. if (
  294. negateCaretIndex <
  295. characterNode.start - 1
  296. ) {
  297. return;
  298. }
  299. }
  300. }
  301. if (
  302. characterNode.parent.type ===
  303. "ClassIntersection" ||
  304. characterNode.parent.type === "ClassSubtraction"
  305. ) {
  306. disableEscapeBackslashSuggest = true;
  307. }
  308. }
  309. }
  310. report(
  311. node,
  312. reportedIndex,
  313. escapedChar,
  314. disableEscapeBackslashSuggest,
  315. );
  316. },
  317. });
  318. }
  319. /**
  320. * Checks if a node has an escape.
  321. * @param {ASTNode} node node to check.
  322. * @returns {void}
  323. */
  324. function check(node) {
  325. const isTemplateElement = node.type === "TemplateElement";
  326. if (
  327. isTemplateElement &&
  328. node.parent &&
  329. node.parent.parent &&
  330. node.parent.parent.type === "TaggedTemplateExpression" &&
  331. node.parent === node.parent.parent.quasi
  332. ) {
  333. // Don't report tagged template literals, because the backslash character is accessible to the tag function.
  334. return;
  335. }
  336. if (typeof node.value === "string" || isTemplateElement) {
  337. /*
  338. * JSXAttribute doesn't have any escape sequence: https://facebook.github.io/jsx/.
  339. * In addition, backticks are not supported by JSX yet: https://github.com/facebook/jsx/issues/25.
  340. */
  341. if (
  342. node.parent.type === "JSXAttribute" ||
  343. node.parent.type === "JSXElement" ||
  344. node.parent.type === "JSXFragment"
  345. ) {
  346. return;
  347. }
  348. const value = isTemplateElement
  349. ? sourceCode.getText(node)
  350. : node.raw;
  351. const pattern = /\\\D/gu;
  352. let match;
  353. while ((match = pattern.exec(value))) {
  354. validateString(node, match);
  355. }
  356. } else if (node.regex) {
  357. validateRegExp(node);
  358. }
  359. }
  360. return {
  361. Literal: check,
  362. TemplateElement: check,
  363. };
  364. },
  365. };