prefer-arrow-callback.js 11 KB

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