]>
Commit | Line | Data |
---|---|---|
eb39fafa DC |
1 | /** |
2 | * @fileoverview A rule to suggest using arrow functions as callbacks. | |
3 | * @author Toru Nagashima | |
4 | */ | |
5 | ||
6 | "use strict"; | |
7 | ||
6f036462 TL |
8 | const astUtils = require("./utils/ast-utils"); |
9 | ||
eb39fafa DC |
10 | //------------------------------------------------------------------------------ |
11 | // Helpers | |
12 | //------------------------------------------------------------------------------ | |
13 | ||
14 | /** | |
15 | * Checks whether or not a given variable is a function name. | |
16 | * @param {eslint-scope.Variable} variable A variable to check. | |
17 | * @returns {boolean} `true` if the variable is a function name. | |
18 | */ | |
19 | function isFunctionName(variable) { | |
20 | return variable && variable.defs[0].type === "FunctionName"; | |
21 | } | |
22 | ||
23 | /** | |
24 | * Checks whether or not a given MetaProperty node equals to a given value. | |
25 | * @param {ASTNode} node A MetaProperty node to check. | |
26 | * @param {string} metaName The name of `MetaProperty.meta`. | |
27 | * @param {string} propertyName The name of `MetaProperty.property`. | |
28 | * @returns {boolean} `true` if the node is the specific value. | |
29 | */ | |
30 | function checkMetaProperty(node, metaName, propertyName) { | |
31 | return node.meta.name === metaName && node.property.name === propertyName; | |
32 | } | |
33 | ||
34 | /** | |
35 | * Gets the variable object of `arguments` which is defined implicitly. | |
36 | * @param {eslint-scope.Scope} scope A scope to get. | |
37 | * @returns {eslint-scope.Variable} The found variable object. | |
38 | */ | |
39 | function getVariableOfArguments(scope) { | |
40 | const variables = scope.variables; | |
41 | ||
42 | for (let i = 0; i < variables.length; ++i) { | |
43 | const variable = variables[i]; | |
44 | ||
45 | if (variable.name === "arguments") { | |
46 | ||
47 | /* | |
48 | * If there was a parameter which is named "arguments", the | |
49 | * implicit "arguments" is not defined. | |
50 | * So does fast return with null. | |
51 | */ | |
52 | return (variable.identifiers.length === 0) ? variable : null; | |
53 | } | |
54 | } | |
55 | ||
8f9d1d4d | 56 | /* c8 ignore next */ |
eb39fafa DC |
57 | return null; |
58 | } | |
59 | ||
60 | /** | |
61 | * Checks whether or not a given node is a callback. | |
62 | * @param {ASTNode} node A node to check. | |
609c276f | 63 | * @throws {Error} (Unreachable.) |
eb39fafa DC |
64 | * @returns {Object} |
65 | * {boolean} retv.isCallback - `true` if the node is a callback. | |
66 | * {boolean} retv.isLexicalThis - `true` if the node is with `.bind(this)`. | |
67 | */ | |
68 | function getCallbackInfo(node) { | |
69 | const retv = { isCallback: false, isLexicalThis: false }; | |
70 | let currentNode = node; | |
71 | let parent = node.parent; | |
6f036462 | 72 | let bound = false; |
eb39fafa DC |
73 | |
74 | while (currentNode) { | |
75 | switch (parent.type) { | |
76 | ||
77 | // Checks parents recursively. | |
78 | ||
79 | case "LogicalExpression": | |
6f036462 | 80 | case "ChainExpression": |
eb39fafa DC |
81 | case "ConditionalExpression": |
82 | break; | |
83 | ||
84 | // Checks whether the parent node is `.bind(this)` call. | |
85 | case "MemberExpression": | |
6f036462 TL |
86 | if ( |
87 | parent.object === currentNode && | |
eb39fafa DC |
88 | !parent.property.computed && |
89 | parent.property.type === "Identifier" && | |
6f036462 | 90 | parent.property.name === "bind" |
eb39fafa | 91 | ) { |
6f036462 TL |
92 | const maybeCallee = parent.parent.type === "ChainExpression" |
93 | ? parent.parent | |
94 | : parent; | |
95 | ||
96 | if (astUtils.isCallee(maybeCallee)) { | |
97 | if (!bound) { | |
98 | bound = true; // Use only the first `.bind()` to make `isLexicalThis` value. | |
99 | retv.isLexicalThis = ( | |
100 | maybeCallee.parent.arguments.length === 1 && | |
101 | maybeCallee.parent.arguments[0].type === "ThisExpression" | |
102 | ); | |
103 | } | |
104 | parent = maybeCallee.parent; | |
105 | } else { | |
106 | return retv; | |
107 | } | |
eb39fafa DC |
108 | } else { |
109 | return retv; | |
110 | } | |
111 | break; | |
112 | ||
113 | // Checks whether the node is a callback. | |
114 | case "CallExpression": | |
115 | case "NewExpression": | |
116 | if (parent.callee !== currentNode) { | |
117 | retv.isCallback = true; | |
118 | } | |
119 | return retv; | |
120 | ||
121 | default: | |
122 | return retv; | |
123 | } | |
124 | ||
125 | currentNode = parent; | |
126 | parent = parent.parent; | |
127 | } | |
128 | ||
8f9d1d4d | 129 | /* c8 ignore next */ |
eb39fafa DC |
130 | throw new Error("unreachable"); |
131 | } | |
132 | ||
133 | /** | |
134 | * Checks whether a simple list of parameters contains any duplicates. This does not handle complex | |
135 | * parameter lists (e.g. with destructuring), since complex parameter lists are a SyntaxError with duplicate | |
136 | * parameter names anyway. Instead, it always returns `false` for complex parameter lists. | |
137 | * @param {ASTNode[]} paramsList The list of parameters for a function | |
138 | * @returns {boolean} `true` if the list of parameters contains any duplicates | |
139 | */ | |
140 | function hasDuplicateParams(paramsList) { | |
141 | return paramsList.every(param => param.type === "Identifier") && paramsList.length !== new Set(paramsList.map(param => param.name)).size; | |
142 | } | |
143 | ||
144 | //------------------------------------------------------------------------------ | |
145 | // Rule Definition | |
146 | //------------------------------------------------------------------------------ | |
147 | ||
34eeec05 | 148 | /** @type {import('../shared/types').Rule} */ |
eb39fafa DC |
149 | module.exports = { |
150 | meta: { | |
151 | type: "suggestion", | |
152 | ||
153 | docs: { | |
8f9d1d4d | 154 | description: "Require using arrow functions for callbacks", |
eb39fafa | 155 | recommended: false, |
f2a92ac6 | 156 | url: "https://eslint.org/docs/latest/rules/prefer-arrow-callback" |
eb39fafa DC |
157 | }, |
158 | ||
159 | schema: [ | |
160 | { | |
161 | type: "object", | |
162 | properties: { | |
163 | allowNamedFunctions: { | |
164 | type: "boolean", | |
165 | default: false | |
166 | }, | |
167 | allowUnboundThis: { | |
168 | type: "boolean", | |
169 | default: true | |
170 | } | |
171 | }, | |
172 | additionalProperties: false | |
173 | } | |
174 | ], | |
175 | ||
176 | fixable: "code", | |
177 | ||
178 | messages: { | |
179 | preferArrowCallback: "Unexpected function expression." | |
180 | } | |
181 | }, | |
182 | ||
183 | create(context) { | |
184 | const options = context.options[0] || {}; | |
185 | ||
186 | const allowUnboundThis = options.allowUnboundThis !== false; // default to true | |
187 | const allowNamedFunctions = options.allowNamedFunctions; | |
f2a92ac6 | 188 | const sourceCode = context.sourceCode; |
eb39fafa DC |
189 | |
190 | /* | |
191 | * {Array<{this: boolean, super: boolean, meta: boolean}>} | |
192 | * - this - A flag which shows there are one or more ThisExpression. | |
193 | * - super - A flag which shows there are one or more Super. | |
194 | * - meta - A flag which shows there are one or more MethProperty. | |
195 | */ | |
196 | let stack = []; | |
197 | ||
198 | /** | |
199 | * Pushes new function scope with all `false` flags. | |
200 | * @returns {void} | |
201 | */ | |
202 | function enterScope() { | |
203 | stack.push({ this: false, super: false, meta: false }); | |
204 | } | |
205 | ||
206 | /** | |
207 | * Pops a function scope from the stack. | |
208 | * @returns {{this: boolean, super: boolean, meta: boolean}} The information of the last scope. | |
209 | */ | |
210 | function exitScope() { | |
211 | return stack.pop(); | |
212 | } | |
213 | ||
214 | return { | |
215 | ||
216 | // Reset internal state. | |
217 | Program() { | |
218 | stack = []; | |
219 | }, | |
220 | ||
221 | // If there are below, it cannot replace with arrow functions merely. | |
222 | ThisExpression() { | |
223 | const info = stack[stack.length - 1]; | |
224 | ||
225 | if (info) { | |
226 | info.this = true; | |
227 | } | |
228 | }, | |
229 | ||
230 | Super() { | |
231 | const info = stack[stack.length - 1]; | |
232 | ||
233 | if (info) { | |
234 | info.super = true; | |
235 | } | |
236 | }, | |
237 | ||
238 | MetaProperty(node) { | |
239 | const info = stack[stack.length - 1]; | |
240 | ||
241 | if (info && checkMetaProperty(node, "new", "target")) { | |
242 | info.meta = true; | |
243 | } | |
244 | }, | |
245 | ||
246 | // To skip nested scopes. | |
247 | FunctionDeclaration: enterScope, | |
248 | "FunctionDeclaration:exit": exitScope, | |
249 | ||
250 | // Main. | |
251 | FunctionExpression: enterScope, | |
252 | "FunctionExpression:exit"(node) { | |
253 | const scopeInfo = exitScope(); | |
254 | ||
255 | // Skip named function expressions | |
256 | if (allowNamedFunctions && node.id && node.id.name) { | |
257 | return; | |
258 | } | |
259 | ||
260 | // Skip generators. | |
261 | if (node.generator) { | |
262 | return; | |
263 | } | |
264 | ||
265 | // Skip recursive functions. | |
f2a92ac6 | 266 | const nameVar = sourceCode.getDeclaredVariables(node)[0]; |
eb39fafa DC |
267 | |
268 | if (isFunctionName(nameVar) && nameVar.references.length > 0) { | |
269 | return; | |
270 | } | |
271 | ||
272 | // Skip if it's using arguments. | |
f2a92ac6 | 273 | const variable = getVariableOfArguments(sourceCode.getScope(node)); |
eb39fafa DC |
274 | |
275 | if (variable && variable.references.length > 0) { | |
276 | return; | |
277 | } | |
278 | ||
279 | // Reports if it's a callback which can replace with arrows. | |
280 | const callbackInfo = getCallbackInfo(node); | |
281 | ||
282 | if (callbackInfo.isCallback && | |
283 | (!allowUnboundThis || !scopeInfo.this || callbackInfo.isLexicalThis) && | |
284 | !scopeInfo.super && | |
285 | !scopeInfo.meta | |
286 | ) { | |
287 | context.report({ | |
288 | node, | |
289 | messageId: "preferArrowCallback", | |
6f036462 | 290 | *fix(fixer) { |
eb39fafa DC |
291 | if ((!callbackInfo.isLexicalThis && scopeInfo.this) || hasDuplicateParams(node.params)) { |
292 | ||
293 | /* | |
294 | * If the callback function does not have .bind(this) and contains a reference to `this`, there | |
295 | * is no way to determine what `this` should be, so don't perform any fixes. | |
296 | * If the callback function has duplicates in its list of parameters (possible in sloppy mode), | |
297 | * don't replace it with an arrow function, because this is a SyntaxError with arrow functions. | |
298 | */ | |
609c276f | 299 | return; |
eb39fafa DC |
300 | } |
301 | ||
6f036462 TL |
302 | // Remove `.bind(this)` if exists. |
303 | if (callbackInfo.isLexicalThis) { | |
304 | const memberNode = node.parent; | |
eb39fafa | 305 | |
6f036462 TL |
306 | /* |
307 | * If `.bind(this)` exists but the parent is not `.bind(this)`, don't remove it automatically. | |
308 | * E.g. `(foo || function(){}).bind(this)` | |
309 | */ | |
310 | if (memberNode.type !== "MemberExpression") { | |
609c276f | 311 | return; |
6f036462 TL |
312 | } |
313 | ||
314 | const callNode = memberNode.parent; | |
315 | const firstTokenToRemove = sourceCode.getTokenAfter(memberNode.object, astUtils.isNotClosingParenToken); | |
316 | const lastTokenToRemove = sourceCode.getLastToken(callNode); | |
317 | ||
318 | /* | |
319 | * If the member expression is parenthesized, don't remove the right paren. | |
320 | * E.g. `(function(){}.bind)(this)` | |
321 | * ^^^^^^^^^^^^ | |
322 | */ | |
323 | if (astUtils.isParenthesised(sourceCode, memberNode)) { | |
609c276f | 324 | return; |
6f036462 TL |
325 | } |
326 | ||
327 | // If comments exist in the `.bind(this)`, don't remove those. | |
328 | if (sourceCode.commentsExistBetween(firstTokenToRemove, lastTokenToRemove)) { | |
609c276f | 329 | return; |
6f036462 TL |
330 | } |
331 | ||
332 | yield fixer.removeRange([firstTokenToRemove.range[0], lastTokenToRemove.range[1]]); | |
333 | } | |
334 | ||
335 | // Convert the function expression to an arrow function. | |
336 | const functionToken = sourceCode.getFirstToken(node, node.async ? 1 : 0); | |
337 | const leftParenToken = sourceCode.getTokenAfter(functionToken, astUtils.isOpeningParenToken); | |
f2a92ac6 | 338 | const tokenBeforeBody = sourceCode.getTokenBefore(node.body); |
6f036462 TL |
339 | |
340 | if (sourceCode.commentsExistBetween(functionToken, leftParenToken)) { | |
341 | ||
342 | // Remove only extra tokens to keep comments. | |
343 | yield fixer.remove(functionToken); | |
344 | if (node.id) { | |
345 | yield fixer.remove(node.id); | |
346 | } | |
347 | } else { | |
348 | ||
349 | // Remove extra tokens and spaces. | |
350 | yield fixer.removeRange([functionToken.range[0], leftParenToken.range[0]]); | |
351 | } | |
f2a92ac6 | 352 | yield fixer.insertTextAfter(tokenBeforeBody, " =>"); |
6f036462 TL |
353 | |
354 | // Get the node that will become the new arrow function. | |
355 | let replacedNode = callbackInfo.isLexicalThis ? node.parent.parent : node; | |
356 | ||
357 | if (replacedNode.type === "ChainExpression") { | |
358 | replacedNode = replacedNode.parent; | |
359 | } | |
eb39fafa DC |
360 | |
361 | /* | |
362 | * If the replaced node is part of a BinaryExpression, LogicalExpression, or MemberExpression, then | |
363 | * the arrow function needs to be parenthesized, because `foo || () => {}` is invalid syntax even | |
364 | * though `foo || function() {}` is valid. | |
365 | */ | |
6f036462 TL |
366 | if ( |
367 | replacedNode.parent.type !== "CallExpression" && | |
368 | replacedNode.parent.type !== "ConditionalExpression" && | |
369 | !astUtils.isParenthesised(sourceCode, replacedNode) && | |
370 | !astUtils.isParenthesised(sourceCode, node) | |
371 | ) { | |
372 | yield fixer.insertTextBefore(replacedNode, "("); | |
373 | yield fixer.insertTextAfter(replacedNode, ")"); | |
374 | } | |
eb39fafa DC |
375 | } |
376 | }); | |
377 | } | |
378 | } | |
379 | }; | |
380 | } | |
381 | }; |