Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 1 | /** |
| 2 | * @fileoverview A rule to suggest using arrow functions as callbacks. |
| 3 | * @author Toru Nagashima |
| 4 | */ |
| 5 | |
| 6 | "use strict"; |
| 7 | |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 8 | const astUtils = require("./utils/ast-utils"); |
| 9 | |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 10 | //------------------------------------------------------------------------------ |
| 11 | // Helpers |
| 12 | //------------------------------------------------------------------------------ |
| 13 | |
| 14 | /** |
| 15 | * Checks whether or not a given variable is a function name. |
Tim van der Lippe | c8f6ffd | 2020-04-06 13:42:00 +0100 | [diff] [blame] | 16 | * @param {eslint-scope.Variable} variable A variable to check. |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 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. |
Tim van der Lippe | c8f6ffd | 2020-04-06 13:42:00 +0100 | [diff] [blame] | 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`. |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 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. |
Tim van der Lippe | c8f6ffd | 2020-04-06 13:42:00 +0100 | [diff] [blame] | 36 | * @param {eslint-scope.Scope} scope A scope to get. |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 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 | |
| 56 | /* istanbul ignore next */ |
| 57 | return null; |
| 58 | } |
| 59 | |
| 60 | /** |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 61 | * Checks whether or not a given node is a callback. |
Tim van der Lippe | c8f6ffd | 2020-04-06 13:42:00 +0100 | [diff] [blame] | 62 | * @param {ASTNode} node A node to check. |
Tim van der Lippe | 0fb4780 | 2021-11-08 16:23:10 +0000 | [diff] [blame] | 63 | * @throws {Error} (Unreachable.) |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 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; |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 72 | let bound = false; |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 73 | |
| 74 | while (currentNode) { |
| 75 | switch (parent.type) { |
| 76 | |
| 77 | // Checks parents recursively. |
| 78 | |
| 79 | case "LogicalExpression": |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 80 | case "ChainExpression": |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 81 | case "ConditionalExpression": |
| 82 | break; |
| 83 | |
| 84 | // Checks whether the parent node is `.bind(this)` call. |
| 85 | case "MemberExpression": |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 86 | if ( |
| 87 | parent.object === currentNode && |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 88 | !parent.property.computed && |
| 89 | parent.property.type === "Identifier" && |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 90 | parent.property.name === "bind" |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 91 | ) { |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 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 | } |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 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 | |
| 129 | /* istanbul ignore next */ |
| 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 | |
| 148 | module.exports = { |
| 149 | meta: { |
| 150 | type: "suggestion", |
| 151 | |
| 152 | docs: { |
| 153 | description: "require using arrow functions for callbacks", |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 154 | recommended: false, |
| 155 | url: "https://eslint.org/docs/rules/prefer-arrow-callback" |
| 156 | }, |
| 157 | |
| 158 | schema: [ |
| 159 | { |
| 160 | type: "object", |
| 161 | properties: { |
| 162 | allowNamedFunctions: { |
| 163 | type: "boolean", |
| 164 | default: false |
| 165 | }, |
| 166 | allowUnboundThis: { |
| 167 | type: "boolean", |
| 168 | default: true |
| 169 | } |
| 170 | }, |
| 171 | additionalProperties: false |
| 172 | } |
| 173 | ], |
| 174 | |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 175 | fixable: "code", |
| 176 | |
| 177 | messages: { |
| 178 | preferArrowCallback: "Unexpected function expression." |
| 179 | } |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 180 | }, |
| 181 | |
| 182 | create(context) { |
| 183 | const options = context.options[0] || {}; |
| 184 | |
| 185 | const allowUnboundThis = options.allowUnboundThis !== false; // default to true |
| 186 | const allowNamedFunctions = options.allowNamedFunctions; |
| 187 | const sourceCode = context.getSourceCode(); |
| 188 | |
| 189 | /* |
| 190 | * {Array<{this: boolean, super: boolean, meta: boolean}>} |
| 191 | * - this - A flag which shows there are one or more ThisExpression. |
| 192 | * - super - A flag which shows there are one or more Super. |
| 193 | * - meta - A flag which shows there are one or more MethProperty. |
| 194 | */ |
| 195 | let stack = []; |
| 196 | |
| 197 | /** |
| 198 | * Pushes new function scope with all `false` flags. |
| 199 | * @returns {void} |
| 200 | */ |
| 201 | function enterScope() { |
| 202 | stack.push({ this: false, super: false, meta: false }); |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * Pops a function scope from the stack. |
| 207 | * @returns {{this: boolean, super: boolean, meta: boolean}} The information of the last scope. |
| 208 | */ |
| 209 | function exitScope() { |
| 210 | return stack.pop(); |
| 211 | } |
| 212 | |
| 213 | return { |
| 214 | |
| 215 | // Reset internal state. |
| 216 | Program() { |
| 217 | stack = []; |
| 218 | }, |
| 219 | |
| 220 | // If there are below, it cannot replace with arrow functions merely. |
| 221 | ThisExpression() { |
| 222 | const info = stack[stack.length - 1]; |
| 223 | |
| 224 | if (info) { |
| 225 | info.this = true; |
| 226 | } |
| 227 | }, |
| 228 | |
| 229 | Super() { |
| 230 | const info = stack[stack.length - 1]; |
| 231 | |
| 232 | if (info) { |
| 233 | info.super = true; |
| 234 | } |
| 235 | }, |
| 236 | |
| 237 | MetaProperty(node) { |
| 238 | const info = stack[stack.length - 1]; |
| 239 | |
| 240 | if (info && checkMetaProperty(node, "new", "target")) { |
| 241 | info.meta = true; |
| 242 | } |
| 243 | }, |
| 244 | |
| 245 | // To skip nested scopes. |
| 246 | FunctionDeclaration: enterScope, |
| 247 | "FunctionDeclaration:exit": exitScope, |
| 248 | |
| 249 | // Main. |
| 250 | FunctionExpression: enterScope, |
| 251 | "FunctionExpression:exit"(node) { |
| 252 | const scopeInfo = exitScope(); |
| 253 | |
| 254 | // Skip named function expressions |
| 255 | if (allowNamedFunctions && node.id && node.id.name) { |
| 256 | return; |
| 257 | } |
| 258 | |
| 259 | // Skip generators. |
| 260 | if (node.generator) { |
| 261 | return; |
| 262 | } |
| 263 | |
| 264 | // Skip recursive functions. |
| 265 | const nameVar = context.getDeclaredVariables(node)[0]; |
| 266 | |
| 267 | if (isFunctionName(nameVar) && nameVar.references.length > 0) { |
| 268 | return; |
| 269 | } |
| 270 | |
| 271 | // Skip if it's using arguments. |
| 272 | const variable = getVariableOfArguments(context.getScope()); |
| 273 | |
| 274 | if (variable && variable.references.length > 0) { |
| 275 | return; |
| 276 | } |
| 277 | |
| 278 | // Reports if it's a callback which can replace with arrows. |
| 279 | const callbackInfo = getCallbackInfo(node); |
| 280 | |
| 281 | if (callbackInfo.isCallback && |
| 282 | (!allowUnboundThis || !scopeInfo.this || callbackInfo.isLexicalThis) && |
| 283 | !scopeInfo.super && |
| 284 | !scopeInfo.meta |
| 285 | ) { |
| 286 | context.report({ |
| 287 | node, |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 288 | messageId: "preferArrowCallback", |
| 289 | *fix(fixer) { |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 290 | if ((!callbackInfo.isLexicalThis && scopeInfo.this) || hasDuplicateParams(node.params)) { |
| 291 | |
| 292 | /* |
| 293 | * If the callback function does not have .bind(this) and contains a reference to `this`, there |
| 294 | * is no way to determine what `this` should be, so don't perform any fixes. |
| 295 | * If the callback function has duplicates in its list of parameters (possible in sloppy mode), |
| 296 | * don't replace it with an arrow function, because this is a SyntaxError with arrow functions. |
| 297 | */ |
Tim van der Lippe | 2c89197 | 2021-07-29 16:22:50 +0100 | [diff] [blame] | 298 | return; |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 299 | } |
| 300 | |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 301 | // Remove `.bind(this)` if exists. |
| 302 | if (callbackInfo.isLexicalThis) { |
| 303 | const memberNode = node.parent; |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 304 | |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 305 | /* |
| 306 | * If `.bind(this)` exists but the parent is not `.bind(this)`, don't remove it automatically. |
| 307 | * E.g. `(foo || function(){}).bind(this)` |
| 308 | */ |
| 309 | if (memberNode.type !== "MemberExpression") { |
Tim van der Lippe | 2c89197 | 2021-07-29 16:22:50 +0100 | [diff] [blame] | 310 | return; |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 311 | } |
| 312 | |
| 313 | const callNode = memberNode.parent; |
| 314 | const firstTokenToRemove = sourceCode.getTokenAfter(memberNode.object, astUtils.isNotClosingParenToken); |
| 315 | const lastTokenToRemove = sourceCode.getLastToken(callNode); |
| 316 | |
| 317 | /* |
| 318 | * If the member expression is parenthesized, don't remove the right paren. |
| 319 | * E.g. `(function(){}.bind)(this)` |
| 320 | * ^^^^^^^^^^^^ |
| 321 | */ |
| 322 | if (astUtils.isParenthesised(sourceCode, memberNode)) { |
Tim van der Lippe | 2c89197 | 2021-07-29 16:22:50 +0100 | [diff] [blame] | 323 | return; |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 324 | } |
| 325 | |
| 326 | // If comments exist in the `.bind(this)`, don't remove those. |
| 327 | if (sourceCode.commentsExistBetween(firstTokenToRemove, lastTokenToRemove)) { |
Tim van der Lippe | 2c89197 | 2021-07-29 16:22:50 +0100 | [diff] [blame] | 328 | return; |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 329 | } |
| 330 | |
| 331 | yield fixer.removeRange([firstTokenToRemove.range[0], lastTokenToRemove.range[1]]); |
| 332 | } |
| 333 | |
| 334 | // Convert the function expression to an arrow function. |
| 335 | const functionToken = sourceCode.getFirstToken(node, node.async ? 1 : 0); |
| 336 | const leftParenToken = sourceCode.getTokenAfter(functionToken, astUtils.isOpeningParenToken); |
| 337 | |
| 338 | if (sourceCode.commentsExistBetween(functionToken, leftParenToken)) { |
| 339 | |
| 340 | // Remove only extra tokens to keep comments. |
| 341 | yield fixer.remove(functionToken); |
| 342 | if (node.id) { |
| 343 | yield fixer.remove(node.id); |
| 344 | } |
| 345 | } else { |
| 346 | |
| 347 | // Remove extra tokens and spaces. |
| 348 | yield fixer.removeRange([functionToken.range[0], leftParenToken.range[0]]); |
| 349 | } |
| 350 | yield fixer.insertTextBefore(node.body, "=> "); |
| 351 | |
| 352 | // Get the node that will become the new arrow function. |
| 353 | let replacedNode = callbackInfo.isLexicalThis ? node.parent.parent : node; |
| 354 | |
| 355 | if (replacedNode.type === "ChainExpression") { |
| 356 | replacedNode = replacedNode.parent; |
| 357 | } |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 358 | |
| 359 | /* |
| 360 | * If the replaced node is part of a BinaryExpression, LogicalExpression, or MemberExpression, then |
| 361 | * the arrow function needs to be parenthesized, because `foo || () => {}` is invalid syntax even |
| 362 | * though `foo || function() {}` is valid. |
| 363 | */ |
Tim van der Lippe | 16aca39 | 2020-11-13 11:37:13 +0000 | [diff] [blame] | 364 | if ( |
| 365 | replacedNode.parent.type !== "CallExpression" && |
| 366 | replacedNode.parent.type !== "ConditionalExpression" && |
| 367 | !astUtils.isParenthesised(sourceCode, replacedNode) && |
| 368 | !astUtils.isParenthesised(sourceCode, node) |
| 369 | ) { |
| 370 | yield fixer.insertTextBefore(replacedNode, "("); |
| 371 | yield fixer.insertTextAfter(replacedNode, ")"); |
| 372 | } |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 373 | } |
| 374 | }); |
| 375 | } |
| 376 | } |
| 377 | }; |
| 378 | } |
| 379 | }; |