• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/**
2 * @fileoverview Look for useless escapes in strings and regexes
3 * @author Onur Temizkan
4 */
5
6"use strict";
7
8const 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 */
20function union(setA, setB) {
21    return new Set(function *() {
22        yield* setA;
23        yield* setB;
24    }());
25}
26
27const VALID_STRING_ESCAPES = union(new Set("\\nrvtbfux"), astUtils.LINEBREAKS);
28const REGEX_GENERAL_ESCAPES = new Set("\\bcdDfnpPrsStvwWxu0123456789]");
29const 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 */
48function 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
80module.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) {
112            const start = sourceCode.getLocFromIndex(sourceCode.getIndexFromLoc(node.loc.start) + startOffset);
113            const rangeStart = sourceCode.getIndexFromLoc(node.loc.start) + startOffset;
114            const range = [rangeStart, rangeStart + 1];
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) {
175                report(node, match.index + 1, match[0].slice(1));
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
209                const value = isTemplateElement ? node.value.raw : node.raw.slice(1, -1);
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};
253