219 lines
5.3 KiB
JavaScript
219 lines
5.3 KiB
JavaScript
'use strict';
|
|
const {getFunctionHeadLocation, getFunctionNameWithKind} = require('@eslint-community/eslint-utils');
|
|
const getReferences = require('./utils/get-references.js');
|
|
const {isNodeMatches} = require('./utils/is-node-matches.js');
|
|
|
|
const MESSAGE_ID = 'consistent-function-scoping';
|
|
const messages = {
|
|
[MESSAGE_ID]: 'Move {{functionNameWithKind}} to the outer scope.',
|
|
};
|
|
|
|
const isSameScope = (scope1, scope2) =>
|
|
scope1 && scope2 && (scope1 === scope2 || scope1.block === scope2.block);
|
|
|
|
function checkReferences(scope, parent, scopeManager) {
|
|
const hitReference = references => references.some(reference => {
|
|
if (isSameScope(parent, reference.from)) {
|
|
return true;
|
|
}
|
|
|
|
const {resolved} = reference;
|
|
const [definition] = resolved.defs;
|
|
|
|
// Skip recursive function name
|
|
if (definition?.type === 'FunctionName' && resolved.name === definition.name.name) {
|
|
return false;
|
|
}
|
|
|
|
return isSameScope(parent, resolved.scope);
|
|
});
|
|
|
|
const hitDefinitions = definitions => definitions.some(definition => {
|
|
const scope = scopeManager.acquire(definition.node);
|
|
return isSameScope(parent, scope);
|
|
});
|
|
|
|
// This check looks for neighboring function definitions
|
|
const hitIdentifier = identifiers => identifiers.some(identifier => {
|
|
// Only look at identifiers that live in a FunctionDeclaration
|
|
if (
|
|
!identifier.parent
|
|
|| identifier.parent.type !== 'FunctionDeclaration'
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const identifierScope = scopeManager.acquire(identifier);
|
|
|
|
// If we have a scope, the earlier checks should have worked so ignore them here
|
|
/* c8 ignore next 3 */
|
|
if (identifierScope) {
|
|
return false;
|
|
}
|
|
|
|
const identifierParentScope = scopeManager.acquire(identifier.parent);
|
|
/* c8 ignore next 3 */
|
|
if (!identifierParentScope) {
|
|
return false;
|
|
}
|
|
|
|
// Ignore identifiers from our own scope
|
|
if (isSameScope(scope, identifierParentScope)) {
|
|
return false;
|
|
}
|
|
|
|
// Look at the scope above the function definition to see if lives
|
|
// next to the reference being checked
|
|
return isSameScope(parent, identifierParentScope.upper);
|
|
});
|
|
|
|
return getReferences(scope)
|
|
.map(({resolved}) => resolved)
|
|
.filter(Boolean)
|
|
.some(variable =>
|
|
hitReference(variable.references)
|
|
|| hitDefinitions(variable.defs)
|
|
|| hitIdentifier(variable.identifiers),
|
|
);
|
|
}
|
|
|
|
// https://reactjs.org/docs/hooks-reference.html
|
|
const reactHooks = [
|
|
'useState',
|
|
'useEffect',
|
|
'useContext',
|
|
'useReducer',
|
|
'useCallback',
|
|
'useMemo',
|
|
'useRef',
|
|
'useImperativeHandle',
|
|
'useLayoutEffect',
|
|
'useDebugValue',
|
|
].flatMap(hookName => [hookName, `React.${hookName}`]);
|
|
|
|
const isReactHook = scope =>
|
|
scope.block?.parent?.callee
|
|
&& isNodeMatches(scope.block.parent.callee, reactHooks);
|
|
|
|
const isArrowFunctionWithThis = scope =>
|
|
scope.type === 'function'
|
|
&& scope.block?.type === 'ArrowFunctionExpression'
|
|
&& (scope.thisFound || scope.childScopes.some(scope => isArrowFunctionWithThis(scope)));
|
|
|
|
const iifeFunctionTypes = new Set([
|
|
'FunctionExpression',
|
|
'ArrowFunctionExpression',
|
|
]);
|
|
const isIife = node =>
|
|
iifeFunctionTypes.has(node.type)
|
|
&& node.parent.type === 'CallExpression'
|
|
&& node.parent.callee === node;
|
|
|
|
function checkNode(node, scopeManager) {
|
|
const scope = scopeManager.acquire(node);
|
|
|
|
if (!scope || isArrowFunctionWithThis(scope)) {
|
|
return true;
|
|
}
|
|
|
|
let parentNode = node.parent;
|
|
|
|
// Skip over junk like the block statement inside of a function declaration
|
|
// or the various pieces of an arrow function.
|
|
|
|
if (parentNode.type === 'VariableDeclarator') {
|
|
parentNode = parentNode.parent;
|
|
}
|
|
|
|
if (parentNode.type === 'VariableDeclaration') {
|
|
parentNode = parentNode.parent;
|
|
}
|
|
|
|
if (parentNode.type === 'BlockStatement') {
|
|
parentNode = parentNode.parent;
|
|
}
|
|
|
|
const parentScope = scopeManager.acquire(parentNode);
|
|
if (
|
|
!parentScope
|
|
|| parentScope.type === 'global'
|
|
|| isReactHook(parentScope)
|
|
|| isIife(parentNode)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return checkReferences(scope, parentScope, scopeManager);
|
|
}
|
|
|
|
/** @param {import('eslint').Rule.RuleContext} context */
|
|
const create = context => {
|
|
const {checkArrowFunctions} = {checkArrowFunctions: true, ...context.options[0]};
|
|
const sourceCode = context.getSourceCode();
|
|
const {scopeManager} = sourceCode;
|
|
|
|
const functions = [];
|
|
|
|
return {
|
|
':function'() {
|
|
functions.push(false);
|
|
},
|
|
JSXElement() {
|
|
// Turn off this rule if we see a JSX element because scope
|
|
// references does not include JSXElement nodes.
|
|
if (functions.length > 0) {
|
|
functions[functions.length - 1] = true;
|
|
}
|
|
},
|
|
':function:exit'(node) {
|
|
const currentFunctionHasJsx = functions.pop();
|
|
if (currentFunctionHasJsx) {
|
|
return;
|
|
}
|
|
|
|
if (node.type === 'ArrowFunctionExpression' && !checkArrowFunctions) {
|
|
return;
|
|
}
|
|
|
|
if (checkNode(node, scopeManager)) {
|
|
return;
|
|
}
|
|
|
|
return {
|
|
node,
|
|
loc: getFunctionHeadLocation(node, sourceCode),
|
|
messageId: MESSAGE_ID,
|
|
data: {
|
|
functionNameWithKind: getFunctionNameWithKind(node, sourceCode),
|
|
},
|
|
};
|
|
},
|
|
};
|
|
};
|
|
|
|
const schema = [
|
|
{
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
checkArrowFunctions: {
|
|
type: 'boolean',
|
|
default: true,
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
module.exports = {
|
|
create,
|
|
meta: {
|
|
type: 'suggestion',
|
|
docs: {
|
|
description: 'Move function definitions to the highest possible scope.',
|
|
},
|
|
schema,
|
|
messages,
|
|
},
|
|
};
|