consistent-function-scoping.js 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. 'use strict';
  2. const {getFunctionHeadLocation, getFunctionNameWithKind} = require('@eslint-community/eslint-utils');
  3. const getReferences = require('./utils/get-references.js');
  4. const {isNodeMatches} = require('./utils/is-node-matches.js');
  5. const MESSAGE_ID = 'consistent-function-scoping';
  6. const messages = {
  7. [MESSAGE_ID]: 'Move {{functionNameWithKind}} to the outer scope.',
  8. };
  9. const isSameScope = (scope1, scope2) =>
  10. scope1 && scope2 && (scope1 === scope2 || scope1.block === scope2.block);
  11. function checkReferences(scope, parent, scopeManager) {
  12. const hitReference = references => references.some(reference => {
  13. if (isSameScope(parent, reference.from)) {
  14. return true;
  15. }
  16. const {resolved} = reference;
  17. const [definition] = resolved.defs;
  18. // Skip recursive function name
  19. if (definition?.type === 'FunctionName' && resolved.name === definition.name.name) {
  20. return false;
  21. }
  22. return isSameScope(parent, resolved.scope);
  23. });
  24. const hitDefinitions = definitions => definitions.some(definition => {
  25. const scope = scopeManager.acquire(definition.node);
  26. return isSameScope(parent, scope);
  27. });
  28. // This check looks for neighboring function definitions
  29. const hitIdentifier = identifiers => identifiers.some(identifier => {
  30. // Only look at identifiers that live in a FunctionDeclaration
  31. if (
  32. !identifier.parent
  33. || identifier.parent.type !== 'FunctionDeclaration'
  34. ) {
  35. return false;
  36. }
  37. const identifierScope = scopeManager.acquire(identifier);
  38. // If we have a scope, the earlier checks should have worked so ignore them here
  39. /* c8 ignore next 3 */
  40. if (identifierScope) {
  41. return false;
  42. }
  43. const identifierParentScope = scopeManager.acquire(identifier.parent);
  44. /* c8 ignore next 3 */
  45. if (!identifierParentScope) {
  46. return false;
  47. }
  48. // Ignore identifiers from our own scope
  49. if (isSameScope(scope, identifierParentScope)) {
  50. return false;
  51. }
  52. // Look at the scope above the function definition to see if lives
  53. // next to the reference being checked
  54. return isSameScope(parent, identifierParentScope.upper);
  55. });
  56. return getReferences(scope)
  57. .map(({resolved}) => resolved)
  58. .filter(Boolean)
  59. .some(variable =>
  60. hitReference(variable.references)
  61. || hitDefinitions(variable.defs)
  62. || hitIdentifier(variable.identifiers),
  63. );
  64. }
  65. // https://reactjs.org/docs/hooks-reference.html
  66. const reactHooks = [
  67. 'useState',
  68. 'useEffect',
  69. 'useContext',
  70. 'useReducer',
  71. 'useCallback',
  72. 'useMemo',
  73. 'useRef',
  74. 'useImperativeHandle',
  75. 'useLayoutEffect',
  76. 'useDebugValue',
  77. ].flatMap(hookName => [hookName, `React.${hookName}`]);
  78. const isReactHook = scope =>
  79. scope.block?.parent?.callee
  80. && isNodeMatches(scope.block.parent.callee, reactHooks);
  81. const isArrowFunctionWithThis = scope =>
  82. scope.type === 'function'
  83. && scope.block?.type === 'ArrowFunctionExpression'
  84. && (scope.thisFound || scope.childScopes.some(scope => isArrowFunctionWithThis(scope)));
  85. const iifeFunctionTypes = new Set([
  86. 'FunctionExpression',
  87. 'ArrowFunctionExpression',
  88. ]);
  89. const isIife = node =>
  90. iifeFunctionTypes.has(node.type)
  91. && node.parent.type === 'CallExpression'
  92. && node.parent.callee === node;
  93. function checkNode(node, scopeManager) {
  94. const scope = scopeManager.acquire(node);
  95. if (!scope || isArrowFunctionWithThis(scope)) {
  96. return true;
  97. }
  98. let parentNode = node.parent;
  99. // Skip over junk like the block statement inside of a function declaration
  100. // or the various pieces of an arrow function.
  101. if (parentNode.type === 'VariableDeclarator') {
  102. parentNode = parentNode.parent;
  103. }
  104. if (parentNode.type === 'VariableDeclaration') {
  105. parentNode = parentNode.parent;
  106. }
  107. if (parentNode.type === 'BlockStatement') {
  108. parentNode = parentNode.parent;
  109. }
  110. const parentScope = scopeManager.acquire(parentNode);
  111. if (
  112. !parentScope
  113. || parentScope.type === 'global'
  114. || isReactHook(parentScope)
  115. || isIife(parentNode)
  116. ) {
  117. return true;
  118. }
  119. return checkReferences(scope, parentScope, scopeManager);
  120. }
  121. /** @param {import('eslint').Rule.RuleContext} context */
  122. const create = context => {
  123. const {checkArrowFunctions} = {checkArrowFunctions: true, ...context.options[0]};
  124. const sourceCode = context.getSourceCode();
  125. const {scopeManager} = sourceCode;
  126. const functions = [];
  127. return {
  128. ':function'() {
  129. functions.push(false);
  130. },
  131. JSXElement() {
  132. // Turn off this rule if we see a JSX element because scope
  133. // references does not include JSXElement nodes.
  134. if (functions.length > 0) {
  135. functions[functions.length - 1] = true;
  136. }
  137. },
  138. ':function:exit'(node) {
  139. const currentFunctionHasJsx = functions.pop();
  140. if (currentFunctionHasJsx) {
  141. return;
  142. }
  143. if (node.type === 'ArrowFunctionExpression' && !checkArrowFunctions) {
  144. return;
  145. }
  146. if (checkNode(node, scopeManager)) {
  147. return;
  148. }
  149. return {
  150. node,
  151. loc: getFunctionHeadLocation(node, sourceCode),
  152. messageId: MESSAGE_ID,
  153. data: {
  154. functionNameWithKind: getFunctionNameWithKind(node, sourceCode),
  155. },
  156. };
  157. },
  158. };
  159. };
  160. const schema = [
  161. {
  162. type: 'object',
  163. additionalProperties: false,
  164. properties: {
  165. checkArrowFunctions: {
  166. type: 'boolean',
  167. default: true,
  168. },
  169. },
  170. },
  171. ];
  172. /** @type {import('eslint').Rule.RuleModule} */
  173. module.exports = {
  174. create,
  175. meta: {
  176. type: 'suggestion',
  177. docs: {
  178. description: 'Move function definitions to the highest possible scope.',
  179. },
  180. schema,
  181. messages,
  182. },
  183. };