You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

326 lines
12 KiB

4 years ago
  1. 'use strict';
  2. /*
  3. * relax complexity and max-statements eslint rules in order to preserve the imported
  4. * core eslint `prefer-arrow-callback` rule code so that future updates to that code
  5. * can be more easily applied here.
  6. */
  7. /* eslint "complexity": [ "error", 18 ], "max-statements": [ "error", 15 ] */
  8. /**
  9. * @fileoverview A rule to suggest using arrow functions as callbacks.
  10. * @author Toru Nagashima (core eslint rule)
  11. * @author Michael Fields (mocha-aware rule modifications)
  12. */
  13. const createAstUtils = require('../util/ast');
  14. // ------------------------------------------------------------------------------
  15. // Helpers
  16. // ------------------------------------------------------------------------------
  17. /**
  18. * Checks whether or not a given variable is a function name.
  19. * @param {eslint-scope.Variable} variable - A variable to check.
  20. * @returns {boolean} `true` if the variable is a function name.
  21. */
  22. function isFunctionName(variable) {
  23. return variable && variable.defs[0].type === 'FunctionName';
  24. }
  25. /**
  26. * Gets the variable object of `arguments` which is defined implicitly.
  27. * @param {eslint-scope.Scope} scope - A scope to get.
  28. * @returns {eslint-scope.Variable} The found variable object.
  29. */
  30. function getVariableOfArguments(scope) {
  31. const variables = scope.variables;
  32. let variableObject = null;
  33. for (let i = 0; i < variables.length; i += 1) {
  34. const variable = variables[i];
  35. /*
  36. * If there was a parameter which is named "arguments", the
  37. * implicit "arguments" is not defined.
  38. * So does fast return with null.
  39. */
  40. if (variable.name === 'arguments' && variable.identifiers.length === 0) {
  41. variableObject = variable;
  42. break;
  43. }
  44. }
  45. return variableObject;
  46. }
  47. /**
  48. * Does the node property indicate a `bind` identifier
  49. * @param {object} property - the node property.
  50. * @returns {boolean}
  51. */
  52. function propertyIndicatesBind(property) {
  53. return !property.computed &&
  54. property.type === 'Identifier' &&
  55. property.name === 'bind';
  56. }
  57. /**
  58. * Is the node a `.bind(this)` node?
  59. * @param {ASTNode} node - the node property.
  60. * @returns {boolean}
  61. */
  62. function isBindThis(node, currentNode) {
  63. return node.object === currentNode &&
  64. propertyIndicatesBind(node.property) &&
  65. node.parent.type === 'CallExpression' &&
  66. node.parent.callee === node;
  67. }
  68. /**
  69. * Checks whether a simple list of parameters contains any duplicates. This does not handle complex
  70. * parameter lists (e.g. with destructuring), since complex parameter lists are a SyntaxError with duplicate
  71. * parameter names anyway. Instead, it always returns `false` for complex parameter lists.
  72. * @param {ASTNode[]} paramsList The list of parameters for a function
  73. * @returns {boolean} `true` if the list of parameters contains any duplicates
  74. */
  75. function hasDuplicateParams(paramsList) {
  76. return paramsList.every((param) => param.type === 'Identifier') &&
  77. paramsList.length !== new Set(paramsList.map((param) => param.name)).size;
  78. }
  79. // ------------------------------------------------------------------------------
  80. // Rule Definition
  81. // ------------------------------------------------------------------------------
  82. module.exports = {
  83. meta: {
  84. type: 'suggestion',
  85. docs: {
  86. description: 'Require using arrow functions for callbacks',
  87. category: 'ECMAScript 6',
  88. recommended: false,
  89. url: 'https://eslint.org/docs/rules/prefer-arrow-callback'
  90. },
  91. schema: [
  92. {
  93. type: 'object',
  94. properties: {
  95. allowNamedFunctions: {
  96. type: 'boolean'
  97. },
  98. allowUnboundThis: {
  99. type: 'boolean'
  100. }
  101. },
  102. additionalProperties: false
  103. }
  104. ],
  105. fixable: 'code'
  106. },
  107. create(context) {
  108. const astUtils = createAstUtils(context.settings);
  109. const options = context.options[0] || {};
  110. // allowUnboundThis defaults to true
  111. const allowUnboundThis = options.allowUnboundThis !== false;
  112. const allowNamedFunctions = options.allowNamedFunctions;
  113. const sourceCode = context.getSourceCode();
  114. /**
  115. * Checkes whether or not a given node is a callback.
  116. * @param {ASTNode} node - A node to check.
  117. * @param {object} context - The eslint context.
  118. * @returns {Object}
  119. * {boolean} retv.isCallback - `true` if the node is a callback.
  120. * {boolean} retv.isMochaCallback - `true` if the node is an argument to a mocha function.
  121. * {boolean} retv.isLexicalThis - `true` if the node is with `.bind(this)`.
  122. */
  123. function getCallbackInfo(node) {
  124. const retv = { isCallback: false, isLexicalThis: false };
  125. let searchComplete = false;
  126. let currentNode = node;
  127. let parent = node.parent;
  128. while (currentNode && !searchComplete) {
  129. switch (parent.type) {
  130. // Checks parents recursively.
  131. case 'LogicalExpression':
  132. case 'ConditionalExpression':
  133. break;
  134. // Checks whether the parent node is `.bind(this)` call.
  135. case 'MemberExpression':
  136. if (isBindThis(parent, currentNode)) {
  137. retv.isLexicalThis =
  138. parent.parent.arguments.length === 1 &&
  139. parent.parent.arguments[0].type === 'ThisExpression';
  140. parent = parent.parent;
  141. } else {
  142. searchComplete = true;
  143. }
  144. break;
  145. // Checks whether the node is a callback.
  146. case 'CallExpression':
  147. case 'NewExpression':
  148. if (parent.callee !== currentNode) {
  149. retv.isCallback = true;
  150. }
  151. // Checks whether the node is a mocha function callback.
  152. if (retv.isCallback && astUtils.isMochaFunctionCall(parent, context.getScope())) {
  153. retv.isMochaCallback = true;
  154. }
  155. searchComplete = true;
  156. break;
  157. default:
  158. searchComplete = true;
  159. }
  160. if (!searchComplete) {
  161. currentNode = parent;
  162. parent = parent.parent;
  163. }
  164. }
  165. return retv;
  166. }
  167. /*
  168. * {Array<{this: boolean, meta: boolean}>}
  169. * - this - A flag which shows there are one or more ThisExpression.
  170. * - meta - A flag which shows there are one or more MetaProperty.
  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, meta: false });
  179. }
  180. /**
  181. * Pops a function scope from the stack.
  182. * @returns {{this: 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[stack.length - 1];
  195. if (info) {
  196. info.this = true;
  197. }
  198. },
  199. MetaProperty() {
  200. const info = stack[stack.length - 1];
  201. info.meta = true;
  202. },
  203. // To skip nested scopes.
  204. FunctionDeclaration: enterScope,
  205. 'FunctionDeclaration:exit': exitScope,
  206. // Main.
  207. FunctionExpression: enterScope,
  208. 'FunctionExpression:exit'(node) {
  209. const scopeInfo = exitScope();
  210. // Skip named function expressions
  211. if (allowNamedFunctions && node.id && node.id.name) {
  212. return;
  213. }
  214. // Skip generators.
  215. if (node.generator) {
  216. return;
  217. }
  218. // Skip recursive functions.
  219. const nameVar = context.getDeclaredVariables(node)[0];
  220. if (isFunctionName(nameVar) && nameVar.references.length > 0) {
  221. return;
  222. }
  223. // Skip if it's using arguments.
  224. const variable = getVariableOfArguments(context.getScope());
  225. if (variable && variable.references.length > 0) {
  226. return;
  227. }
  228. // Reports if it's a callback which can replace with arrows.
  229. const callbackInfo = getCallbackInfo(node, context);
  230. if (callbackInfo.isCallback &&
  231. (!allowUnboundThis || !scopeInfo.this || callbackInfo.isLexicalThis) &&
  232. !scopeInfo.meta &&
  233. !callbackInfo.isMochaCallback
  234. ) {
  235. context.report({
  236. node,
  237. message: 'Unexpected function expression.',
  238. fix(fixer) {
  239. if (!callbackInfo.isLexicalThis && scopeInfo.this || hasDuplicateParams(node.params)) {
  240. /*
  241. * If the callback function does not have .bind(this) and contains a reference to
  242. * `this`, there is no way to determine what `this` should be, so don't perform any
  243. * fixes. If the callback function has duplicates in its list of parameters (possible
  244. * in sloppy mode), don't replace it with an arrow function, because this is a
  245. * SyntaxError with arrow functions.
  246. */
  247. return null;
  248. }
  249. const paramsLeftParen = node.params.length ?
  250. sourceCode.getTokenBefore(node.params[0]) :
  251. sourceCode.getTokenBefore(node.body, 1);
  252. const paramsRightParen = sourceCode.getTokenBefore(node.body);
  253. const asyncKeyword = node.async ? 'async ' : '';
  254. const paramsFullText = sourceCode.text.slice(
  255. paramsLeftParen.range[0], paramsRightParen.range[1]
  256. );
  257. const arrowFunctionText =
  258. `${asyncKeyword}${paramsFullText} => ${sourceCode.getText(node.body)}`;
  259. /*
  260. * If the callback function has `.bind(this)`, replace it with an arrow function and remove
  261. * the binding. Otherwise, just replace the arrow function itself.
  262. */
  263. const replacedNode = callbackInfo.isLexicalThis ? node.parent.parent : node;
  264. /*
  265. * If the replaced node is part of a BinaryExpression, LogicalExpression, or
  266. * MemberExpression, then the arrow function needs to be parenthesized, because
  267. * `foo || () => {}` is invalid syntax even though `foo || function() {}` is valid.
  268. */
  269. const needsParens = replacedNode.parent.type !== 'CallExpression' &&
  270. replacedNode.parent.type !== 'ConditionalExpression';
  271. const replacementText = needsParens ? `(${arrowFunctionText})` : arrowFunctionText;
  272. return fixer.replaceText(replacedNode, replacementText);
  273. }
  274. });
  275. }
  276. }
  277. };
  278. }
  279. };