]>
Commit | Line | Data |
---|---|---|
eb39fafa DC |
1 | /** |
2 | * @fileoverview A rule to suggest using template literals instead of string concatenation. | |
3 | * @author Toru Nagashima | |
4 | */ | |
5 | ||
6 | "use strict"; | |
7 | ||
8 | //------------------------------------------------------------------------------ | |
9 | // Requirements | |
10 | //------------------------------------------------------------------------------ | |
11 | ||
12 | const astUtils = require("./utils/ast-utils"); | |
13 | ||
14 | //------------------------------------------------------------------------------ | |
15 | // Helpers | |
16 | //------------------------------------------------------------------------------ | |
17 | ||
18 | /** | |
19 | * Checks whether or not a given node is a concatenation. | |
20 | * @param {ASTNode} node A node to check. | |
21 | * @returns {boolean} `true` if the node is a concatenation. | |
22 | */ | |
23 | function isConcatenation(node) { | |
24 | return node.type === "BinaryExpression" && node.operator === "+"; | |
25 | } | |
26 | ||
27 | /** | |
28 | * Gets the top binary expression node for concatenation in parents of a given node. | |
29 | * @param {ASTNode} node A node to get. | |
30 | * @returns {ASTNode} the top binary expression node in parents of a given node. | |
31 | */ | |
32 | function getTopConcatBinaryExpression(node) { | |
33 | let currentNode = node; | |
34 | ||
35 | while (isConcatenation(currentNode.parent)) { | |
36 | currentNode = currentNode.parent; | |
37 | } | |
38 | return currentNode; | |
39 | } | |
40 | ||
41 | /** | |
6f036462 | 42 | * Checks whether or not a node contains a string literal with an octal or non-octal decimal escape sequence |
eb39fafa | 43 | * @param {ASTNode} node A node to check |
6f036462 TL |
44 | * @returns {boolean} `true` if at least one string literal within the node contains |
45 | * an octal or non-octal decimal escape sequence | |
eb39fafa | 46 | */ |
6f036462 TL |
47 | function hasOctalOrNonOctalDecimalEscapeSequence(node) { |
48 | if (isConcatenation(node)) { | |
49 | return ( | |
50 | hasOctalOrNonOctalDecimalEscapeSequence(node.left) || | |
51 | hasOctalOrNonOctalDecimalEscapeSequence(node.right) | |
52 | ); | |
eb39fafa DC |
53 | } |
54 | ||
6f036462 TL |
55 | // No need to check TemplateLiterals – would throw parsing error |
56 | if (node.type === "Literal" && typeof node.value === "string") { | |
57 | return astUtils.hasOctalOrNonOctalDecimalEscapeSequence(node.raw); | |
eb39fafa DC |
58 | } |
59 | ||
6f036462 | 60 | return false; |
eb39fafa DC |
61 | } |
62 | ||
63 | /** | |
64 | * Checks whether or not a given binary expression has string literals. | |
65 | * @param {ASTNode} node A node to check. | |
66 | * @returns {boolean} `true` if the node has string literals. | |
67 | */ | |
68 | function hasStringLiteral(node) { | |
69 | if (isConcatenation(node)) { | |
70 | ||
71 | // `left` is deeper than `right` normally. | |
72 | return hasStringLiteral(node.right) || hasStringLiteral(node.left); | |
73 | } | |
74 | return astUtils.isStringLiteral(node); | |
75 | } | |
76 | ||
77 | /** | |
78 | * Checks whether or not a given binary expression has non string literals. | |
79 | * @param {ASTNode} node A node to check. | |
80 | * @returns {boolean} `true` if the node has non string literals. | |
81 | */ | |
82 | function hasNonStringLiteral(node) { | |
83 | if (isConcatenation(node)) { | |
84 | ||
85 | // `left` is deeper than `right` normally. | |
86 | return hasNonStringLiteral(node.right) || hasNonStringLiteral(node.left); | |
87 | } | |
88 | return !astUtils.isStringLiteral(node); | |
89 | } | |
90 | ||
91 | /** | |
92 | * Determines whether a given node will start with a template curly expression (`${}`) when being converted to a template literal. | |
93 | * @param {ASTNode} node The node that will be fixed to a template literal | |
94 | * @returns {boolean} `true` if the node will start with a template curly. | |
95 | */ | |
96 | function startsWithTemplateCurly(node) { | |
97 | if (node.type === "BinaryExpression") { | |
98 | return startsWithTemplateCurly(node.left); | |
99 | } | |
100 | if (node.type === "TemplateLiteral") { | |
101 | return node.expressions.length && node.quasis.length && node.quasis[0].range[0] === node.quasis[0].range[1]; | |
102 | } | |
103 | return node.type !== "Literal" || typeof node.value !== "string"; | |
104 | } | |
105 | ||
106 | /** | |
107 | * Determines whether a given node end with a template curly expression (`${}`) when being converted to a template literal. | |
108 | * @param {ASTNode} node The node that will be fixed to a template literal | |
109 | * @returns {boolean} `true` if the node will end with a template curly. | |
110 | */ | |
111 | function endsWithTemplateCurly(node) { | |
112 | if (node.type === "BinaryExpression") { | |
113 | return startsWithTemplateCurly(node.right); | |
114 | } | |
115 | if (node.type === "TemplateLiteral") { | |
116 | return node.expressions.length && node.quasis.length && node.quasis[node.quasis.length - 1].range[0] === node.quasis[node.quasis.length - 1].range[1]; | |
117 | } | |
118 | return node.type !== "Literal" || typeof node.value !== "string"; | |
119 | } | |
120 | ||
121 | //------------------------------------------------------------------------------ | |
122 | // Rule Definition | |
123 | //------------------------------------------------------------------------------ | |
124 | ||
34eeec05 | 125 | /** @type {import('../shared/types').Rule} */ |
eb39fafa DC |
126 | module.exports = { |
127 | meta: { | |
128 | type: "suggestion", | |
129 | ||
130 | docs: { | |
8f9d1d4d | 131 | description: "Require template literals instead of string concatenation", |
eb39fafa DC |
132 | recommended: false, |
133 | url: "https://eslint.org/docs/rules/prefer-template" | |
134 | }, | |
135 | ||
136 | schema: [], | |
137 | fixable: "code", | |
138 | ||
139 | messages: { | |
140 | unexpectedStringConcatenation: "Unexpected string concatenation." | |
141 | } | |
142 | }, | |
143 | ||
144 | create(context) { | |
145 | const sourceCode = context.getSourceCode(); | |
146 | let done = Object.create(null); | |
147 | ||
148 | /** | |
149 | * Gets the non-token text between two nodes, ignoring any other tokens that appear between the two tokens. | |
150 | * @param {ASTNode} node1 The first node | |
151 | * @param {ASTNode} node2 The second node | |
152 | * @returns {string} The text between the nodes, excluding other tokens | |
153 | */ | |
154 | function getTextBetween(node1, node2) { | |
155 | const allTokens = [node1].concat(sourceCode.getTokensBetween(node1, node2)).concat(node2); | |
156 | const sourceText = sourceCode.getText(); | |
157 | ||
158 | return allTokens.slice(0, -1).reduce((accumulator, token, index) => accumulator + sourceText.slice(token.range[1], allTokens[index + 1].range[0]), ""); | |
159 | } | |
160 | ||
161 | /** | |
162 | * Returns a template literal form of the given node. | |
163 | * @param {ASTNode} currentNode A node that should be converted to a template literal | |
164 | * @param {string} textBeforeNode Text that should appear before the node | |
165 | * @param {string} textAfterNode Text that should appear after the node | |
166 | * @returns {string} A string form of this node, represented as a template literal | |
167 | */ | |
168 | function getTemplateLiteral(currentNode, textBeforeNode, textAfterNode) { | |
169 | if (currentNode.type === "Literal" && typeof currentNode.value === "string") { | |
170 | ||
171 | /* | |
172 | * If the current node is a string literal, escape any instances of ${ or ` to prevent them from being interpreted | |
173 | * as a template placeholder. However, if the code already contains a backslash before the ${ or ` | |
174 | * for some reason, don't add another backslash, because that would change the meaning of the code (it would cause | |
175 | * an actual backslash character to appear before the dollar sign). | |
176 | */ | |
177 | return `\`${currentNode.raw.slice(1, -1).replace(/\\*(\$\{|`)/gu, matched => { | |
178 | if (matched.lastIndexOf("\\") % 2) { | |
179 | return `\\${matched}`; | |
180 | } | |
181 | return matched; | |
182 | ||
183 | // Unescape any quotes that appear in the original Literal that no longer need to be escaped. | |
184 | }).replace(new RegExp(`\\\\${currentNode.raw[0]}`, "gu"), currentNode.raw[0])}\``; | |
185 | } | |
186 | ||
187 | if (currentNode.type === "TemplateLiteral") { | |
188 | return sourceCode.getText(currentNode); | |
189 | } | |
190 | ||
8f9d1d4d | 191 | if (isConcatenation(currentNode) && hasStringLiteral(currentNode)) { |
eb39fafa DC |
192 | const plusSign = sourceCode.getFirstTokenBetween(currentNode.left, currentNode.right, token => token.value === "+"); |
193 | const textBeforePlus = getTextBetween(currentNode.left, plusSign); | |
194 | const textAfterPlus = getTextBetween(plusSign, currentNode.right); | |
195 | const leftEndsWithCurly = endsWithTemplateCurly(currentNode.left); | |
196 | const rightStartsWithCurly = startsWithTemplateCurly(currentNode.right); | |
197 | ||
198 | if (leftEndsWithCurly) { | |
199 | ||
200 | // If the left side of the expression ends with a template curly, add the extra text to the end of the curly bracket. | |
201 | // `foo${bar}` /* comment */ + 'baz' --> `foo${bar /* comment */ }${baz}` | |
202 | return getTemplateLiteral(currentNode.left, textBeforeNode, textBeforePlus + textAfterPlus).slice(0, -1) + | |
203 | getTemplateLiteral(currentNode.right, null, textAfterNode).slice(1); | |
204 | } | |
205 | if (rightStartsWithCurly) { | |
206 | ||
207 | // Otherwise, if the right side of the expression starts with a template curly, add the text there. | |
208 | // 'foo' /* comment */ + `${bar}baz` --> `foo${ /* comment */ bar}baz` | |
209 | return getTemplateLiteral(currentNode.left, textBeforeNode, null).slice(0, -1) + | |
210 | getTemplateLiteral(currentNode.right, textBeforePlus + textAfterPlus, textAfterNode).slice(1); | |
211 | } | |
212 | ||
213 | /* | |
214 | * Otherwise, these nodes should not be combined into a template curly, since there is nowhere to put | |
215 | * the text between them. | |
216 | */ | |
217 | return `${getTemplateLiteral(currentNode.left, textBeforeNode, null)}${textBeforePlus}+${textAfterPlus}${getTemplateLiteral(currentNode.right, textAfterNode, null)}`; | |
218 | } | |
219 | ||
220 | return `\`\${${textBeforeNode || ""}${sourceCode.getText(currentNode)}${textAfterNode || ""}}\``; | |
221 | } | |
222 | ||
223 | /** | |
224 | * Returns a fixer object that converts a non-string binary expression to a template literal | |
225 | * @param {SourceCodeFixer} fixer The fixer object | |
226 | * @param {ASTNode} node A node that should be converted to a template literal | |
227 | * @returns {Object} A fix for this binary expression | |
228 | */ | |
229 | function fixNonStringBinaryExpression(fixer, node) { | |
230 | const topBinaryExpr = getTopConcatBinaryExpression(node.parent); | |
231 | ||
6f036462 | 232 | if (hasOctalOrNonOctalDecimalEscapeSequence(topBinaryExpr)) { |
eb39fafa DC |
233 | return null; |
234 | } | |
235 | ||
236 | return fixer.replaceText(topBinaryExpr, getTemplateLiteral(topBinaryExpr, null, null)); | |
237 | } | |
238 | ||
239 | /** | |
240 | * Reports if a given node is string concatenation with non string literals. | |
241 | * @param {ASTNode} node A node to check. | |
242 | * @returns {void} | |
243 | */ | |
244 | function checkForStringConcat(node) { | |
245 | if (!astUtils.isStringLiteral(node) || !isConcatenation(node.parent)) { | |
246 | return; | |
247 | } | |
248 | ||
249 | const topBinaryExpr = getTopConcatBinaryExpression(node.parent); | |
250 | ||
251 | // Checks whether or not this node had been checked already. | |
252 | if (done[topBinaryExpr.range[0]]) { | |
253 | return; | |
254 | } | |
255 | done[topBinaryExpr.range[0]] = true; | |
256 | ||
257 | if (hasNonStringLiteral(topBinaryExpr)) { | |
258 | context.report({ | |
259 | node: topBinaryExpr, | |
260 | messageId: "unexpectedStringConcatenation", | |
261 | fix: fixer => fixNonStringBinaryExpression(fixer, node) | |
262 | }); | |
263 | } | |
264 | } | |
265 | ||
266 | return { | |
267 | Program() { | |
268 | done = Object.create(null); | |
269 | }, | |
270 | ||
271 | Literal: checkForStringConcat, | |
272 | TemplateLiteral: checkForStringConcat | |
273 | }; | |
274 | } | |
275 | }; |