1/** 2 * @fileoverview A rule to suggest using arrow functions as callbacks. 3 * @author Toru Nagashima 4 */ 5 6"use strict"; 7 8const astUtils = require("./utils/ast-utils"); 9 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 */ 19function 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 */ 30function 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 */ 39function 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/** 61 * Checks whether or not a given node is a callback. 62 * @param {ASTNode} node A node to check. 63 * @returns {Object} 64 * {boolean} retv.isCallback - `true` if the node is a callback. 65 * {boolean} retv.isLexicalThis - `true` if the node is with `.bind(this)`. 66 */ 67function getCallbackInfo(node) { 68 const retv = { isCallback: false, isLexicalThis: false }; 69 let currentNode = node; 70 let parent = node.parent; 71 let bound = false; 72 73 while (currentNode) { 74 switch (parent.type) { 75 76 // Checks parents recursively. 77 78 case "LogicalExpression": 79 case "ChainExpression": 80 case "ConditionalExpression": 81 break; 82 83 // Checks whether the parent node is `.bind(this)` call. 84 case "MemberExpression": 85 if ( 86 parent.object === currentNode && 87 !parent.property.computed && 88 parent.property.type === "Identifier" && 89 parent.property.name === "bind" 90 ) { 91 const maybeCallee = parent.parent.type === "ChainExpression" 92 ? parent.parent 93 : parent; 94 95 if (astUtils.isCallee(maybeCallee)) { 96 if (!bound) { 97 bound = true; // Use only the first `.bind()` to make `isLexicalThis` value. 98 retv.isLexicalThis = ( 99 maybeCallee.parent.arguments.length === 1 && 100 maybeCallee.parent.arguments[0].type === "ThisExpression" 101 ); 102 } 103 parent = maybeCallee.parent; 104 } else { 105 return retv; 106 } 107 } else { 108 return retv; 109 } 110 break; 111 112 // Checks whether the node is a callback. 113 case "CallExpression": 114 case "NewExpression": 115 if (parent.callee !== currentNode) { 116 retv.isCallback = true; 117 } 118 return retv; 119 120 default: 121 return retv; 122 } 123 124 currentNode = parent; 125 parent = parent.parent; 126 } 127 128 /* istanbul ignore next */ 129 throw new Error("unreachable"); 130} 131 132/** 133 * Checks whether a simple list of parameters contains any duplicates. This does not handle complex 134 * parameter lists (e.g. with destructuring), since complex parameter lists are a SyntaxError with duplicate 135 * parameter names anyway. Instead, it always returns `false` for complex parameter lists. 136 * @param {ASTNode[]} paramsList The list of parameters for a function 137 * @returns {boolean} `true` if the list of parameters contains any duplicates 138 */ 139function hasDuplicateParams(paramsList) { 140 return paramsList.every(param => param.type === "Identifier") && paramsList.length !== new Set(paramsList.map(param => param.name)).size; 141} 142 143//------------------------------------------------------------------------------ 144// Rule Definition 145//------------------------------------------------------------------------------ 146 147module.exports = { 148 meta: { 149 type: "suggestion", 150 151 docs: { 152 description: "require using arrow functions for callbacks", 153 category: "ECMAScript 6", 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 175 fixable: "code", 176 177 messages: { 178 preferArrowCallback: "Unexpected function expression." 179 } 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, 288 messageId: "preferArrowCallback", 289 *fix(fixer) { 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 */ 298 return; // eslint-disable-line eslint-plugin/fixer-return -- false positive 299 } 300 301 // Remove `.bind(this)` if exists. 302 if (callbackInfo.isLexicalThis) { 303 const memberNode = node.parent; 304 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") { 310 return; // eslint-disable-line eslint-plugin/fixer-return -- false positive 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)) { 323 return; // eslint-disable-line eslint-plugin/fixer-return -- false positive 324 } 325 326 // If comments exist in the `.bind(this)`, don't remove those. 327 if (sourceCode.commentsExistBetween(firstTokenToRemove, lastTokenToRemove)) { 328 return; // eslint-disable-line eslint-plugin/fixer-return -- false positive 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 } 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 */ 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 } 373 } 374 }); 375 } 376 } 377 }; 378 } 379}; 380