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 | |
| 8 | //------------------------------------------------------------------------------ |
| 9 | // Helpers |
| 10 | //------------------------------------------------------------------------------ |
| 11 | |
| 12 | /** |
| 13 | * 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] | 14 | * @param {eslint-scope.Variable} variable A variable to check. |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 15 | * @returns {boolean} `true` if the variable is a function name. |
| 16 | */ |
| 17 | function isFunctionName(variable) { |
| 18 | return variable && variable.defs[0].type === "FunctionName"; |
| 19 | } |
| 20 | |
| 21 | /** |
| 22 | * 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] | 23 | * @param {ASTNode} node A MetaProperty node to check. |
| 24 | * @param {string} metaName The name of `MetaProperty.meta`. |
| 25 | * @param {string} propertyName The name of `MetaProperty.property`. |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 26 | * @returns {boolean} `true` if the node is the specific value. |
| 27 | */ |
| 28 | function checkMetaProperty(node, metaName, propertyName) { |
| 29 | return node.meta.name === metaName && node.property.name === propertyName; |
| 30 | } |
| 31 | |
| 32 | /** |
| 33 | * Gets the variable object of `arguments` which is defined implicitly. |
Tim van der Lippe | c8f6ffd | 2020-04-06 13:42:00 +0100 | [diff] [blame] | 34 | * @param {eslint-scope.Scope} scope A scope to get. |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 35 | * @returns {eslint-scope.Variable} The found variable object. |
| 36 | */ |
| 37 | function getVariableOfArguments(scope) { |
| 38 | const variables = scope.variables; |
| 39 | |
| 40 | for (let i = 0; i < variables.length; ++i) { |
| 41 | const variable = variables[i]; |
| 42 | |
| 43 | if (variable.name === "arguments") { |
| 44 | |
| 45 | /* |
| 46 | * If there was a parameter which is named "arguments", the |
| 47 | * implicit "arguments" is not defined. |
| 48 | * So does fast return with null. |
| 49 | */ |
| 50 | return (variable.identifiers.length === 0) ? variable : null; |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | /* istanbul ignore next */ |
| 55 | return null; |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * Checkes whether or not a given node is a callback. |
Tim van der Lippe | c8f6ffd | 2020-04-06 13:42:00 +0100 | [diff] [blame] | 60 | * @param {ASTNode} node A node to check. |
Yang Guo | 4fd355c | 2019-09-19 10:59:03 +0200 | [diff] [blame] | 61 | * @returns {Object} |
| 62 | * {boolean} retv.isCallback - `true` if the node is a callback. |
| 63 | * {boolean} retv.isLexicalThis - `true` if the node is with `.bind(this)`. |
| 64 | */ |
| 65 | function getCallbackInfo(node) { |
| 66 | const retv = { isCallback: false, isLexicalThis: false }; |
| 67 | let currentNode = node; |
| 68 | let parent = node.parent; |
| 69 | |
| 70 | while (currentNode) { |
| 71 | switch (parent.type) { |
| 72 | |
| 73 | // Checks parents recursively. |
| 74 | |
| 75 | case "LogicalExpression": |
| 76 | case "ConditionalExpression": |
| 77 | break; |
| 78 | |
| 79 | // Checks whether the parent node is `.bind(this)` call. |
| 80 | case "MemberExpression": |
| 81 | if (parent.object === currentNode && |
| 82 | !parent.property.computed && |
| 83 | parent.property.type === "Identifier" && |
| 84 | parent.property.name === "bind" && |
| 85 | parent.parent.type === "CallExpression" && |
| 86 | parent.parent.callee === parent |
| 87 | ) { |
| 88 | retv.isLexicalThis = ( |
| 89 | parent.parent.arguments.length === 1 && |
| 90 | parent.parent.arguments[0].type === "ThisExpression" |
| 91 | ); |
| 92 | parent = parent.parent; |
| 93 | } else { |
| 94 | return retv; |
| 95 | } |
| 96 | break; |
| 97 | |
| 98 | // Checks whether the node is a callback. |
| 99 | case "CallExpression": |
| 100 | case "NewExpression": |
| 101 | if (parent.callee !== currentNode) { |
| 102 | retv.isCallback = true; |
| 103 | } |
| 104 | return retv; |
| 105 | |
| 106 | default: |
| 107 | return retv; |
| 108 | } |
| 109 | |
| 110 | currentNode = parent; |
| 111 | parent = parent.parent; |
| 112 | } |
| 113 | |
| 114 | /* istanbul ignore next */ |
| 115 | throw new Error("unreachable"); |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Checks whether a simple list of parameters contains any duplicates. This does not handle complex |
| 120 | * parameter lists (e.g. with destructuring), since complex parameter lists are a SyntaxError with duplicate |
| 121 | * parameter names anyway. Instead, it always returns `false` for complex parameter lists. |
| 122 | * @param {ASTNode[]} paramsList The list of parameters for a function |
| 123 | * @returns {boolean} `true` if the list of parameters contains any duplicates |
| 124 | */ |
| 125 | function hasDuplicateParams(paramsList) { |
| 126 | return paramsList.every(param => param.type === "Identifier") && paramsList.length !== new Set(paramsList.map(param => param.name)).size; |
| 127 | } |
| 128 | |
| 129 | //------------------------------------------------------------------------------ |
| 130 | // Rule Definition |
| 131 | //------------------------------------------------------------------------------ |
| 132 | |
| 133 | module.exports = { |
| 134 | meta: { |
| 135 | type: "suggestion", |
| 136 | |
| 137 | docs: { |
| 138 | description: "require using arrow functions for callbacks", |
| 139 | category: "ECMAScript 6", |
| 140 | recommended: false, |
| 141 | url: "https://eslint.org/docs/rules/prefer-arrow-callback" |
| 142 | }, |
| 143 | |
| 144 | schema: [ |
| 145 | { |
| 146 | type: "object", |
| 147 | properties: { |
| 148 | allowNamedFunctions: { |
| 149 | type: "boolean", |
| 150 | default: false |
| 151 | }, |
| 152 | allowUnboundThis: { |
| 153 | type: "boolean", |
| 154 | default: true |
| 155 | } |
| 156 | }, |
| 157 | additionalProperties: false |
| 158 | } |
| 159 | ], |
| 160 | |
| 161 | fixable: "code" |
| 162 | }, |
| 163 | |
| 164 | create(context) { |
| 165 | const options = context.options[0] || {}; |
| 166 | |
| 167 | const allowUnboundThis = options.allowUnboundThis !== false; // default to true |
| 168 | const allowNamedFunctions = options.allowNamedFunctions; |
| 169 | const sourceCode = context.getSourceCode(); |
| 170 | |
| 171 | /* |
| 172 | * {Array<{this: boolean, super: boolean, meta: boolean}>} |
| 173 | * - this - A flag which shows there are one or more ThisExpression. |
| 174 | * - super - A flag which shows there are one or more Super. |
| 175 | * - meta - A flag which shows there are one or more MethProperty. |
| 176 | */ |
| 177 | let stack = []; |
| 178 | |
| 179 | /** |
| 180 | * Pushes new function scope with all `false` flags. |
| 181 | * @returns {void} |
| 182 | */ |
| 183 | function enterScope() { |
| 184 | stack.push({ this: false, super: false, meta: false }); |
| 185 | } |
| 186 | |
| 187 | /** |
| 188 | * Pops a function scope from the stack. |
| 189 | * @returns {{this: boolean, super: boolean, meta: boolean}} The information of the last scope. |
| 190 | */ |
| 191 | function exitScope() { |
| 192 | return stack.pop(); |
| 193 | } |
| 194 | |
| 195 | return { |
| 196 | |
| 197 | // Reset internal state. |
| 198 | Program() { |
| 199 | stack = []; |
| 200 | }, |
| 201 | |
| 202 | // If there are below, it cannot replace with arrow functions merely. |
| 203 | ThisExpression() { |
| 204 | const info = stack[stack.length - 1]; |
| 205 | |
| 206 | if (info) { |
| 207 | info.this = true; |
| 208 | } |
| 209 | }, |
| 210 | |
| 211 | Super() { |
| 212 | const info = stack[stack.length - 1]; |
| 213 | |
| 214 | if (info) { |
| 215 | info.super = true; |
| 216 | } |
| 217 | }, |
| 218 | |
| 219 | MetaProperty(node) { |
| 220 | const info = stack[stack.length - 1]; |
| 221 | |
| 222 | if (info && checkMetaProperty(node, "new", "target")) { |
| 223 | info.meta = true; |
| 224 | } |
| 225 | }, |
| 226 | |
| 227 | // To skip nested scopes. |
| 228 | FunctionDeclaration: enterScope, |
| 229 | "FunctionDeclaration:exit": exitScope, |
| 230 | |
| 231 | // Main. |
| 232 | FunctionExpression: enterScope, |
| 233 | "FunctionExpression:exit"(node) { |
| 234 | const scopeInfo = exitScope(); |
| 235 | |
| 236 | // Skip named function expressions |
| 237 | if (allowNamedFunctions && node.id && node.id.name) { |
| 238 | return; |
| 239 | } |
| 240 | |
| 241 | // Skip generators. |
| 242 | if (node.generator) { |
| 243 | return; |
| 244 | } |
| 245 | |
| 246 | // Skip recursive functions. |
| 247 | const nameVar = context.getDeclaredVariables(node)[0]; |
| 248 | |
| 249 | if (isFunctionName(nameVar) && nameVar.references.length > 0) { |
| 250 | return; |
| 251 | } |
| 252 | |
| 253 | // Skip if it's using arguments. |
| 254 | const variable = getVariableOfArguments(context.getScope()); |
| 255 | |
| 256 | if (variable && variable.references.length > 0) { |
| 257 | return; |
| 258 | } |
| 259 | |
| 260 | // Reports if it's a callback which can replace with arrows. |
| 261 | const callbackInfo = getCallbackInfo(node); |
| 262 | |
| 263 | if (callbackInfo.isCallback && |
| 264 | (!allowUnboundThis || !scopeInfo.this || callbackInfo.isLexicalThis) && |
| 265 | !scopeInfo.super && |
| 266 | !scopeInfo.meta |
| 267 | ) { |
| 268 | context.report({ |
| 269 | node, |
| 270 | message: "Unexpected function expression.", |
| 271 | fix(fixer) { |
| 272 | if ((!callbackInfo.isLexicalThis && scopeInfo.this) || hasDuplicateParams(node.params)) { |
| 273 | |
| 274 | /* |
| 275 | * If the callback function does not have .bind(this) and contains a reference to `this`, there |
| 276 | * is no way to determine what `this` should be, so don't perform any fixes. |
| 277 | * If the callback function has duplicates in its list of parameters (possible in sloppy mode), |
| 278 | * don't replace it with an arrow function, because this is a SyntaxError with arrow functions. |
| 279 | */ |
| 280 | return null; |
| 281 | } |
| 282 | |
| 283 | const paramsLeftParen = node.params.length ? sourceCode.getTokenBefore(node.params[0]) : sourceCode.getTokenBefore(node.body, 1); |
| 284 | const paramsRightParen = sourceCode.getTokenBefore(node.body); |
| 285 | const asyncKeyword = node.async ? "async " : ""; |
| 286 | const paramsFullText = sourceCode.text.slice(paramsLeftParen.range[0], paramsRightParen.range[1]); |
| 287 | const arrowFunctionText = `${asyncKeyword}${paramsFullText} => ${sourceCode.getText(node.body)}`; |
| 288 | |
| 289 | /* |
| 290 | * If the callback function has `.bind(this)`, replace it with an arrow function and remove the binding. |
| 291 | * Otherwise, just replace the arrow function itself. |
| 292 | */ |
| 293 | const replacedNode = callbackInfo.isLexicalThis ? node.parent.parent : node; |
| 294 | |
| 295 | /* |
| 296 | * If the replaced node is part of a BinaryExpression, LogicalExpression, or MemberExpression, then |
| 297 | * the arrow function needs to be parenthesized, because `foo || () => {}` is invalid syntax even |
| 298 | * though `foo || function() {}` is valid. |
| 299 | */ |
| 300 | const needsParens = replacedNode.parent.type !== "CallExpression" && replacedNode.parent.type !== "ConditionalExpression"; |
| 301 | const replacementText = needsParens ? `(${arrowFunctionText})` : arrowFunctionText; |
| 302 | |
| 303 | return fixer.replaceText(replacedNode, replacementText); |
| 304 | } |
| 305 | }); |
| 306 | } |
| 307 | } |
| 308 | }; |
| 309 | } |
| 310 | }; |