]>
Commit | Line | Data |
---|---|---|
eb39fafa DC |
1 | /** |
2 | * @fileoverview Look for useless escapes in strings and regexes | |
3 | * @author Onur Temizkan | |
4 | */ | |
5 | ||
6 | "use strict"; | |
7 | ||
8 | const astUtils = require("./utils/ast-utils"); | |
9 | ||
10 | //------------------------------------------------------------------------------ | |
11 | // Rule Definition | |
12 | //------------------------------------------------------------------------------ | |
13 | ||
14 | /** | |
15 | * Returns the union of two sets. | |
16 | * @param {Set} setA The first set | |
17 | * @param {Set} setB The second set | |
18 | * @returns {Set} The union of the two sets | |
19 | */ | |
20 | function union(setA, setB) { | |
21 | return new Set(function *() { | |
22 | yield* setA; | |
23 | yield* setB; | |
24 | }()); | |
25 | } | |
26 | ||
27 | const VALID_STRING_ESCAPES = union(new Set("\\nrvtbfux"), astUtils.LINEBREAKS); | |
28 | const REGEX_GENERAL_ESCAPES = new Set("\\bcdDfnpPrsStvwWxu0123456789]"); | |
29 | const REGEX_NON_CHARCLASS_ESCAPES = union(REGEX_GENERAL_ESCAPES, new Set("^/.$*+?[{}|()Bk")); | |
30 | ||
31 | /** | |
32 | * Parses a regular expression into a list of characters with character class info. | |
33 | * @param {string} regExpText The raw text used to create the regular expression | |
34 | * @returns {Object[]} A list of characters, each with info on escaping and whether they're in a character class. | |
35 | * @example | |
36 | * | |
37 | * parseRegExp('a\\b[cd-]') | |
38 | * | |
39 | * returns: | |
40 | * [ | |
41 | * {text: 'a', index: 0, escaped: false, inCharClass: false, startsCharClass: false, endsCharClass: false}, | |
42 | * {text: 'b', index: 2, escaped: true, inCharClass: false, startsCharClass: false, endsCharClass: false}, | |
43 | * {text: 'c', index: 4, escaped: false, inCharClass: true, startsCharClass: true, endsCharClass: false}, | |
44 | * {text: 'd', index: 5, escaped: false, inCharClass: true, startsCharClass: false, endsCharClass: false}, | |
45 | * {text: '-', index: 6, escaped: false, inCharClass: true, startsCharClass: false, endsCharClass: false} | |
46 | * ] | |
47 | */ | |
48 | function parseRegExp(regExpText) { | |
49 | const charList = []; | |
50 | ||
51 | regExpText.split("").reduce((state, char, index) => { | |
52 | if (!state.escapeNextChar) { | |
53 | if (char === "\\") { | |
54 | return Object.assign(state, { escapeNextChar: true }); | |
55 | } | |
56 | if (char === "[" && !state.inCharClass) { | |
57 | return Object.assign(state, { inCharClass: true, startingCharClass: true }); | |
58 | } | |
59 | if (char === "]" && state.inCharClass) { | |
60 | if (charList.length && charList[charList.length - 1].inCharClass) { | |
61 | charList[charList.length - 1].endsCharClass = true; | |
62 | } | |
63 | return Object.assign(state, { inCharClass: false, startingCharClass: false }); | |
64 | } | |
65 | } | |
66 | charList.push({ | |
67 | text: char, | |
68 | index, | |
69 | escaped: state.escapeNextChar, | |
70 | inCharClass: state.inCharClass, | |
71 | startsCharClass: state.startingCharClass, | |
72 | endsCharClass: false | |
73 | }); | |
74 | return Object.assign(state, { escapeNextChar: false, startingCharClass: false }); | |
75 | }, { escapeNextChar: false, inCharClass: false, startingCharClass: false }); | |
76 | ||
77 | return charList; | |
78 | } | |
79 | ||
80 | module.exports = { | |
81 | meta: { | |
82 | type: "suggestion", | |
83 | ||
84 | docs: { | |
85 | description: "disallow unnecessary escape characters", | |
86 | category: "Best Practices", | |
87 | recommended: true, | |
88 | url: "https://eslint.org/docs/rules/no-useless-escape", | |
89 | suggestion: true | |
90 | }, | |
91 | ||
92 | messages: { | |
93 | unnecessaryEscape: "Unnecessary escape character: \\{{character}}.", | |
94 | removeEscape: "Remove the `\\`. This maintains the current functionality.", | |
95 | escapeBackslash: "Replace the `\\` with `\\\\` to include the actual backslash character." | |
96 | }, | |
97 | ||
98 | schema: [] | |
99 | }, | |
100 | ||
101 | create(context) { | |
102 | const sourceCode = context.getSourceCode(); | |
103 | ||
104 | /** | |
105 | * Reports a node | |
106 | * @param {ASTNode} node The node to report | |
107 | * @param {number} startOffset The backslash's offset from the start of the node | |
108 | * @param {string} character The uselessly escaped character (not including the backslash) | |
109 | * @returns {void} | |
110 | */ | |
111 | function report(node, startOffset, character) { | |
456be15e | 112 | const rangeStart = node.range[0] + startOffset; |
eb39fafa | 113 | const range = [rangeStart, rangeStart + 1]; |
456be15e | 114 | const start = sourceCode.getLocFromIndex(rangeStart); |
eb39fafa DC |
115 | |
116 | context.report({ | |
117 | node, | |
118 | loc: { | |
119 | start, | |
120 | end: { line: start.line, column: start.column + 1 } | |
121 | }, | |
122 | messageId: "unnecessaryEscape", | |
123 | data: { character }, | |
124 | suggest: [ | |
125 | { | |
126 | messageId: "removeEscape", | |
127 | fix(fixer) { | |
128 | return fixer.removeRange(range); | |
129 | } | |
130 | }, | |
131 | { | |
132 | messageId: "escapeBackslash", | |
133 | fix(fixer) { | |
134 | return fixer.insertTextBeforeRange(range, "\\"); | |
135 | } | |
136 | } | |
137 | ] | |
138 | }); | |
139 | } | |
140 | ||
141 | /** | |
142 | * Checks if the escape character in given string slice is unnecessary. | |
143 | * @private | |
144 | * @param {ASTNode} node node to validate. | |
145 | * @param {string} match string slice to validate. | |
146 | * @returns {void} | |
147 | */ | |
148 | function validateString(node, match) { | |
149 | const isTemplateElement = node.type === "TemplateElement"; | |
150 | const escapedChar = match[0][1]; | |
151 | let isUnnecessaryEscape = !VALID_STRING_ESCAPES.has(escapedChar); | |
152 | let isQuoteEscape; | |
153 | ||
154 | if (isTemplateElement) { | |
155 | isQuoteEscape = escapedChar === "`"; | |
156 | ||
157 | if (escapedChar === "$") { | |
158 | ||
159 | // Warn if `\$` is not followed by `{` | |
160 | isUnnecessaryEscape = match.input[match.index + 2] !== "{"; | |
161 | } else if (escapedChar === "{") { | |
162 | ||
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 | ||
174 | if (isUnnecessaryEscape && !isQuoteEscape) { | |
456be15e | 175 | report(node, match.index, match[0].slice(1)); |
eb39fafa DC |
176 | } |
177 | } | |
178 | ||
179 | /** | |
180 | * Checks if a node has an escape. | |
181 | * @param {ASTNode} node node to check. | |
182 | * @returns {void} | |
183 | */ | |
184 | function check(node) { | |
185 | const isTemplateElement = node.type === "TemplateElement"; | |
186 | ||
187 | if ( | |
188 | isTemplateElement && | |
189 | node.parent && | |
190 | node.parent.parent && | |
191 | node.parent.parent.type === "TaggedTemplateExpression" && | |
192 | node.parent === node.parent.parent.quasi | |
193 | ) { | |
194 | ||
195 | // Don't report tagged template literals, because the backslash character is accessible to the tag function. | |
196 | return; | |
197 | } | |
198 | ||
199 | if (typeof node.value === "string" || isTemplateElement) { | |
200 | ||
201 | /* | |
202 | * JSXAttribute doesn't have any escape sequence: https://facebook.github.io/jsx/. | |
203 | * In addition, backticks are not supported by JSX yet: https://github.com/facebook/jsx/issues/25. | |
204 | */ | |
205 | if (node.parent.type === "JSXAttribute" || node.parent.type === "JSXElement" || node.parent.type === "JSXFragment") { | |
206 | return; | |
207 | } | |
208 | ||
456be15e | 209 | const value = isTemplateElement ? sourceCode.getText(node) : node.raw; |
eb39fafa DC |
210 | const pattern = /\\[^\d]/gu; |
211 | let match; | |
212 | ||
213 | while ((match = pattern.exec(value))) { | |
214 | validateString(node, match); | |
215 | } | |
216 | } else if (node.regex) { | |
217 | parseRegExp(node.regex.pattern) | |
218 | ||
219 | /* | |
220 | * The '-' character is a special case, because it's only valid to escape it if it's in a character | |
221 | * class, and is not at either edge of the character class. To account for this, don't consider '-' | |
222 | * characters to be valid in general, and filter out '-' characters that appear in the middle of a | |
223 | * character class. | |
224 | */ | |
225 | .filter(charInfo => !(charInfo.text === "-" && charInfo.inCharClass && !charInfo.startsCharClass && !charInfo.endsCharClass)) | |
226 | ||
227 | /* | |
228 | * The '^' character is also a special case; it must always be escaped outside of character classes, but | |
229 | * it only needs to be escaped in character classes if it's at the beginning of the character class. To | |
230 | * account for this, consider it to be a valid escape character outside of character classes, and filter | |
231 | * out '^' characters that appear at the start of a character class. | |
232 | */ | |
233 | .filter(charInfo => !(charInfo.text === "^" && charInfo.startsCharClass)) | |
234 | ||
235 | // Filter out characters that aren't escaped. | |
236 | .filter(charInfo => charInfo.escaped) | |
237 | ||
238 | // Filter out characters that are valid to escape, based on their position in the regular expression. | |
239 | .filter(charInfo => !(charInfo.inCharClass ? REGEX_GENERAL_ESCAPES : REGEX_NON_CHARCLASS_ESCAPES).has(charInfo.text)) | |
240 | ||
241 | // Report all the remaining characters. | |
242 | .forEach(charInfo => report(node, charInfo.index, charInfo.text)); | |
243 | } | |
244 | ||
245 | } | |
246 | ||
247 | return { | |
248 | Literal: check, | |
249 | TemplateElement: check | |
250 | }; | |
251 | } | |
252 | }; |