prefer-arrow-callback.js 14 KB

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