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.
 
 
 

896 lines
27 KiB

/**
* @fileoverview Utility class and functions for React components detection
* @author Yannick Croissant
*/
'use strict';
const arrayIncludes = require('array-includes');
const fromEntries = require('object.fromentries');
const values = require('object.values');
const variableUtil = require('./variable');
const pragmaUtil = require('./pragma');
const astUtil = require('./ast');
const componentUtil = require('./componentUtil');
const propTypesUtil = require('./propTypes');
const jsxUtil = require('./jsx');
const usedPropTypesUtil = require('./usedPropTypes');
const defaultPropsUtil = require('./defaultProps');
const isFirstLetterCapitalized = require('./isFirstLetterCapitalized');
const isDestructuredFromPragmaImport = require('./isDestructuredFromPragmaImport');
function getId(node) {
return node && node.range.join(':');
}
function usedPropTypesAreEquivalent(propA, propB) {
if (propA.name === propB.name) {
if (!propA.allNames && !propB.allNames) {
return true;
}
if (Array.isArray(propA.allNames) && Array.isArray(propB.allNames) && propA.allNames.join('') === propB.allNames.join('')) {
return true;
}
return false;
}
return false;
}
function mergeUsedPropTypes(propsList, newPropsList) {
const propsToAdd = newPropsList.filter((newProp) => {
const newPropIsAlreadyInTheList = propsList.some((prop) => usedPropTypesAreEquivalent(prop, newProp));
return !newPropIsAlreadyInTheList;
});
return propsList.concat(propsToAdd);
}
const USE_HOOK_PREFIX_REGEX = /^use[A-Z]/;
const Lists = new WeakMap();
const ReactImports = new WeakMap();
/**
* Components
*/
class Components {
constructor() {
Lists.set(this, {});
ReactImports.set(this, {});
}
/**
* Add a node to the components list, or update it if it's already in the list
*
* @param {ASTNode} node The AST node being added.
* @param {Number} confidence Confidence in the component detection (0=banned, 1=maybe, 2=yes)
* @returns {Object} Added component object
*/
add(node, confidence) {
const id = getId(node);
const list = Lists.get(this);
if (list[id]) {
if (confidence === 0 || list[id].confidence === 0) {
list[id].confidence = 0;
} else {
list[id].confidence = Math.max(list[id].confidence, confidence);
}
return list[id];
}
list[id] = {
node,
confidence,
};
return list[id];
}
/**
* Find a component in the list using its node
*
* @param {ASTNode} node The AST node being searched.
* @returns {Object} Component object, undefined if the component is not found or has confidence value of 0.
*/
get(node) {
const id = getId(node);
const item = Lists.get(this)[id];
if (item && item.confidence >= 1) {
return item;
}
return null;
}
/**
* Update a component in the list
*
* @param {ASTNode} node The AST node being updated.
* @param {Object} props Additional properties to add to the component.
*/
set(node, props) {
const list = Lists.get(this);
let component = list[getId(node)];
while (!component) {
node = node.parent;
if (!node) {
return;
}
component = list[getId(node)];
}
Object.assign(
component,
props,
{
usedPropTypes: mergeUsedPropTypes(
component.usedPropTypes || [],
props.usedPropTypes || []
),
}
);
}
/**
* Return the components list
* Components for which we are not confident are not returned
*
* @returns {Object} Components list
*/
list() {
const thisList = Lists.get(this);
const list = {};
const usedPropTypes = {};
// Find props used in components for which we are not confident
Object.keys(thisList).filter((i) => thisList[i].confidence < 2).forEach((i) => {
let component = null;
let node = null;
node = thisList[i].node;
while (!component && node.parent) {
node = node.parent;
// Stop moving up if we reach a decorator
if (node.type === 'Decorator') {
break;
}
component = this.get(node);
}
if (component) {
const newUsedProps = (thisList[i].usedPropTypes || []).filter((propType) => !propType.node || propType.node.kind !== 'init');
const componentId = getId(component.node);
usedPropTypes[componentId] = mergeUsedPropTypes(usedPropTypes[componentId] || [], newUsedProps);
}
});
// Assign used props in not confident components to the parent component
Object.keys(thisList).filter((j) => thisList[j].confidence >= 2).forEach((j) => {
const id = getId(thisList[j].node);
list[j] = thisList[j];
if (usedPropTypes[id]) {
list[j].usedPropTypes = mergeUsedPropTypes(list[j].usedPropTypes || [], usedPropTypes[id]);
}
});
return list;
}
/**
* Return the length of the components list
* Components for which we are not confident are not counted
*
* @returns {Number} Components list length
*/
length() {
const list = Lists.get(this);
return Object.keys(list).filter((i) => list[i].confidence >= 2).length;
}
/**
* Return the node naming the default React import
* It can be used to determine the local name of import, even if it's imported
* with an unusual name.
*
* @returns {ASTNode} React default import node
*/
getDefaultReactImports() {
return ReactImports.get(this).defaultReactImports;
}
/**
* Return the nodes of all React named imports
*
* @returns {Object} The list of React named imports
*/
getNamedReactImports() {
return ReactImports.get(this).namedReactImports;
}
/**
* Add the default React import specifier to the scope
*
* @param {ASTNode} specifier The AST Node of the default React import
* @returns {void}
*/
addDefaultReactImport(specifier) {
const info = ReactImports.get(this);
ReactImports.set(this, Object.assign({}, info, {
defaultReactImports: (info.defaultReactImports || []).concat(specifier),
}));
}
/**
* Add a named React import specifier to the scope
*
* @param {ASTNode} specifier The AST Node of a named React import
* @returns {void}
*/
addNamedReactImport(specifier) {
const info = ReactImports.get(this);
ReactImports.set(this, Object.assign({}, info, {
namedReactImports: (info.namedReactImports || []).concat(specifier),
}));
}
}
function getWrapperFunctions(context, pragma) {
const componentWrapperFunctions = context.settings.componentWrapperFunctions || [];
// eslint-disable-next-line arrow-body-style
return componentWrapperFunctions.map((wrapperFunction) => {
return typeof wrapperFunction === 'string'
? { property: wrapperFunction }
: Object.assign({}, wrapperFunction, {
object: wrapperFunction.object === '<pragma>' ? pragma : wrapperFunction.object,
});
}).concat([
{ property: 'forwardRef', object: pragma },
{ property: 'memo', object: pragma },
]);
}
// eslint-disable-next-line valid-jsdoc
/**
* Merge many eslint rules into one
* @param {{[_: string]: Function}[]} rules the returned values for eslint rule.create(context)
* @returns {{[_: string]: Function}} merged rule
*/
function mergeRules(rules) {
/** @type {Map<string, Function[]>} */
const handlersByKey = new Map();
rules.forEach((rule) => {
Object.keys(rule).forEach((key) => {
const fns = handlersByKey.get(key);
if (!fns) {
handlersByKey.set(key, [rule[key]]);
} else {
fns.push(rule[key]);
}
});
});
/** @type {{[key: string]: Function}} */
const rule = {};
handlersByKey.forEach((fns, key) => {
rule[key] = function mergedHandler(node) {
fns.forEach((fn) => {
fn(node);
});
};
});
return rule;
}
function componentRule(rule, context) {
const pragma = pragmaUtil.getFromContext(context);
const sourceCode = context.getSourceCode();
const components = new Components();
const wrapperFunctions = getWrapperFunctions(context, pragma);
// Utilities for component detection
const utils = {
/**
* Check if variable is destructured from pragma import
*
* @param {string} variable The variable name to check
* @returns {Boolean} True if createElement is destructured from the pragma
*/
isDestructuredFromPragmaImport(variable) {
return isDestructuredFromPragmaImport(variable, context);
},
isReturningJSX(ASTNode, strict) {
return jsxUtil.isReturningJSX(ASTNode, context, strict, true);
},
isReturningJSXOrNull(ASTNode, strict) {
return jsxUtil.isReturningJSX(ASTNode, context, strict);
},
isReturningOnlyNull(ASTNode) {
return jsxUtil.isReturningOnlyNull(ASTNode, context);
},
getPragmaComponentWrapper(node) {
let isPragmaComponentWrapper;
let currentNode = node;
let prevNode;
do {
currentNode = currentNode.parent;
isPragmaComponentWrapper = this.isPragmaComponentWrapper(currentNode);
if (isPragmaComponentWrapper) {
prevNode = currentNode;
}
} while (isPragmaComponentWrapper);
return prevNode;
},
getComponentNameFromJSXElement(node) {
if (node.type !== 'JSXElement') {
return null;
}
if (node.openingElement && node.openingElement.name && node.openingElement.name.name) {
return node.openingElement.name.name;
}
return null;
},
/**
* Getting the first JSX element's name.
* @param {object} node
* @returns {string | null}
*/
getNameOfWrappedComponent(node) {
if (node.length < 1) {
return null;
}
const body = node[0].body;
if (!body) {
return null;
}
if (body.type === 'JSXElement') {
return this.getComponentNameFromJSXElement(body);
}
if (body.type === 'BlockStatement') {
const jsxElement = body.body.find((item) => item.type === 'ReturnStatement');
return jsxElement
&& jsxElement.argument
&& this.getComponentNameFromJSXElement(jsxElement.argument);
}
return null;
},
/**
* Get the list of names of components created till now
* @returns {string | boolean}
*/
getDetectedComponents() {
const list = components.list();
return values(list).filter((val) => {
if (val.node.type === 'ClassDeclaration') {
return true;
}
if (
val.node.type === 'ArrowFunctionExpression'
&& val.node.parent
&& val.node.parent.type === 'VariableDeclarator'
&& val.node.parent.id
) {
return true;
}
return false;
}).map((val) => {
if (val.node.type === 'ArrowFunctionExpression') return val.node.parent.id.name;
return val.node.id && val.node.id.name;
});
},
/**
* It will check wheater memo/forwardRef is wrapping existing component or
* creating a new one.
* @param {object} node
* @returns {boolean}
*/
nodeWrapsComponent(node) {
const childComponent = this.getNameOfWrappedComponent(node.arguments);
const componentList = this.getDetectedComponents();
return !!childComponent && arrayIncludes(componentList, childComponent);
},
isPragmaComponentWrapper(node) {
if (!node || node.type !== 'CallExpression') {
return false;
}
return wrapperFunctions.some((wrapperFunction) => {
if (node.callee.type === 'MemberExpression') {
return wrapperFunction.object
&& wrapperFunction.object === node.callee.object.name
&& wrapperFunction.property === node.callee.property.name
&& !this.nodeWrapsComponent(node);
}
return wrapperFunction.property === node.callee.name
&& (!wrapperFunction.object
// Functions coming from the current pragma need special handling
|| (wrapperFunction.object === pragma && this.isDestructuredFromPragmaImport(node.callee.name))
);
});
},
/**
* Find a return statement in the current node
*
* @param {ASTNode} node The AST node being checked
*/
findReturnStatement: astUtil.findReturnStatement,
/**
* Get the parent component node from the current scope
*
* @returns {ASTNode} component node, null if we are not in a component
*/
getParentComponent() {
return (
componentUtil.getParentES6Component(context)
|| componentUtil.getParentES5Component(context)
|| utils.getParentStatelessComponent()
);
},
/**
* @param {ASTNode} node
* @returns {boolean}
*/
isInAllowedPositionForComponent(node) {
switch (node.parent.type) {
case 'VariableDeclarator':
case 'AssignmentExpression':
case 'Property':
case 'ReturnStatement':
case 'ExportDefaultDeclaration':
case 'ArrowFunctionExpression': {
return true;
}
case 'SequenceExpression': {
return utils.isInAllowedPositionForComponent(node.parent)
&& node === node.parent.expressions[node.parent.expressions.length - 1];
}
default:
return false;
}
},
/**
* Get node if node is a stateless component, or node.parent in cases like
* `React.memo` or `React.forwardRef`. Otherwise returns `undefined`.
* @param {ASTNode} node
* @returns {ASTNode | undefined}
*/
getStatelessComponent(node) {
const parent = node.parent;
if (
node.type === 'FunctionDeclaration'
&& (!node.id || isFirstLetterCapitalized(node.id.name))
&& utils.isReturningJSXOrNull(node)
) {
return node;
}
if (node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') {
const isMethod = parent.type === 'Property' && parent.method;
const isPropertyAssignment = parent.type === 'AssignmentExpression'
&& parent.left.type === 'MemberExpression';
const isModuleExportsAssignment = isPropertyAssignment
&& parent.left.object.name === 'module'
&& parent.left.property.name === 'exports';
if (node.parent.type === 'ExportDefaultDeclaration') {
if (utils.isReturningJSX(node)) {
return node;
}
return undefined;
}
if (node.parent.type === 'VariableDeclarator' && utils.isReturningJSXOrNull(node)) {
if (isFirstLetterCapitalized(node.parent.id.name)) {
return node;
}
return undefined;
}
// case: const any = () => { return (props) => null }
// case: const any = () => (props) => null
if (
(node.parent.type === 'ReturnStatement' || (node.parent.type === 'ArrowFunctionExpression' && node.parent.expression))
&& !utils.isReturningJSX(node)
&& !utils.isReturningOnlyNull(node)
) {
return undefined;
}
// for case abc = { [someobject.somekey]: props => { ... return not-jsx } }
if (node.parent && node.parent.key && node.parent.key.type === 'MemberExpression' && !utils.isReturningJSX(node) && !utils.isReturningOnlyNull(node)) {
return undefined;
}
// Case like `React.memo(() => <></>)` or `React.forwardRef(...)`
const pragmaComponentWrapper = utils.getPragmaComponentWrapper(node);
if (pragmaComponentWrapper && utils.isReturningJSXOrNull(node)) {
return pragmaComponentWrapper;
}
if (!(utils.isInAllowedPositionForComponent(node) && utils.isReturningJSXOrNull(node))) {
return undefined;
}
if (utils.isParentComponentNotStatelessComponent(node)) {
return undefined;
}
if (isMethod && !isFirstLetterCapitalized(node.parent.key.name)) {
return utils.isReturningJSX(node) ? node : undefined;
}
if (node.id) {
return isFirstLetterCapitalized(node.id.name) ? node : undefined;
}
if (
isPropertyAssignment
&& !isModuleExportsAssignment
&& !isFirstLetterCapitalized(parent.left.property.name)
) {
return undefined;
}
return node;
}
return undefined;
},
/**
* Get the parent stateless component node from the current scope
*
* @returns {ASTNode} component node, null if we are not in a component
*/
getParentStatelessComponent() {
let scope = context.getScope();
while (scope) {
const node = scope.block;
const statelessComponent = utils.getStatelessComponent(node);
if (statelessComponent) {
return statelessComponent;
}
scope = scope.upper;
}
return null;
},
/**
* Get the related component from a node
*
* @param {ASTNode} node The AST node being checked (must be a MemberExpression).
* @returns {ASTNode} component node, null if we cannot find the component
*/
getRelatedComponent(node) {
let i;
let j;
let k;
let l;
let componentNode;
// Get the component path
const componentPath = [];
while (node) {
if (node.property && node.property.type === 'Identifier') {
componentPath.push(node.property.name);
}
if (node.object && node.object.type === 'Identifier') {
componentPath.push(node.object.name);
}
node = node.object;
}
componentPath.reverse();
const componentName = componentPath.slice(0, componentPath.length - 1).join('.');
// Find the variable in the current scope
const variableName = componentPath.shift();
if (!variableName) {
return null;
}
let variableInScope;
const variables = variableUtil.variablesInScope(context);
for (i = 0, j = variables.length; i < j; i++) {
if (variables[i].name === variableName) {
variableInScope = variables[i];
break;
}
}
if (!variableInScope) {
return null;
}
// Try to find the component using variable references
const refs = variableInScope.references;
refs.some((ref) => {
let refId = ref.identifier;
if (refId.parent && refId.parent.type === 'MemberExpression') {
refId = refId.parent;
}
if (sourceCode.getText(refId) !== componentName) {
return false;
}
if (refId.type === 'MemberExpression') {
componentNode = refId.parent.right;
} else if (
refId.parent
&& refId.parent.type === 'VariableDeclarator'
&& refId.parent.init
&& refId.parent.init.type !== 'Identifier'
) {
componentNode = refId.parent.init;
}
return true;
});
if (componentNode) {
// Return the component
return components.add(componentNode, 1);
}
// Try to find the component using variable declarations
const defs = variableInScope.defs;
const defInScope = defs.find((def) => (
def.type === 'ClassName'
|| def.type === 'FunctionName'
|| def.type === 'Variable'
));
if (!defInScope || !defInScope.node) {
return null;
}
componentNode = defInScope.node.init || defInScope.node;
// Traverse the node properties to the component declaration
for (i = 0, j = componentPath.length; i < j; i++) {
if (!componentNode.properties) {
continue; // eslint-disable-line no-continue
}
for (k = 0, l = componentNode.properties.length; k < l; k++) {
if (componentNode.properties[k].key && componentNode.properties[k].key.name === componentPath[i]) {
componentNode = componentNode.properties[k];
break;
}
}
if (!componentNode || !componentNode.value) {
return null;
}
componentNode = componentNode.value;
}
// Return the component
return components.add(componentNode, 1);
},
isParentComponentNotStatelessComponent(node) {
return !!(
node.parent
&& node.parent.key
&& node.parent.key.type === 'Identifier'
// custom component functions must start with a capital letter (returns false otherwise)
&& node.parent.key.name.charAt(0) === node.parent.key.name.charAt(0).toLowerCase()
// react render function cannot have params
&& !!(node.params || []).length
);
},
/**
* Identify whether a node (CallExpression) is a call to a React hook
*
* @param {ASTNode} node The AST node being searched. (expects CallExpression)
* @param {('useCallback'|'useContext'|'useDebugValue'|'useEffect'|'useImperativeHandle'|'useLayoutEffect'|'useMemo'|'useReducer'|'useRef'|'useState')[]} [expectedHookNames] React hook names to which search is limited.
* @returns {Boolean} True if the node is a call to a React hook
*/
isReactHookCall(node, expectedHookNames) {
if (node.type !== 'CallExpression') {
return false;
}
const defaultReactImports = components.getDefaultReactImports();
const namedReactImports = components.getNamedReactImports();
const defaultReactImportName = defaultReactImports
&& defaultReactImports[0]
&& defaultReactImports[0].local.name;
const reactHookImportSpecifiers = namedReactImports
&& namedReactImports.filter((specifier) => USE_HOOK_PREFIX_REGEX.test(specifier.imported.name));
const reactHookImportNames = reactHookImportSpecifiers
&& fromEntries(reactHookImportSpecifiers.map((specifier) => [specifier.local.name, specifier.imported.name]));
const isPotentialReactHookCall = defaultReactImportName
&& node.callee.type === 'MemberExpression'
&& node.callee.object.type === 'Identifier'
&& node.callee.object.name === defaultReactImportName
&& node.callee.property.type === 'Identifier'
&& node.callee.property.name.match(USE_HOOK_PREFIX_REGEX);
const isPotentialHookCall = reactHookImportNames
&& node.callee.type === 'Identifier'
&& node.callee.name.match(USE_HOOK_PREFIX_REGEX);
const scope = (isPotentialReactHookCall || isPotentialHookCall) && context.getScope();
const reactResolvedDefs = isPotentialReactHookCall
&& scope.references
&& scope.references.find(
(reference) => reference.identifier.name === defaultReactImportName
).resolved.defs;
const isReactShadowed = isPotentialReactHookCall && reactResolvedDefs
&& reactResolvedDefs.some((reactDef) => reactDef.type !== 'ImportBinding');
const potentialHookReference = isPotentialHookCall
&& scope.references
&& scope.references.find(
(reference) => reactHookImportNames[reference.identifier.name]
);
const hookResolvedDefs = potentialHookReference && potentialHookReference.resolved.defs;
const localHookName = (isPotentialReactHookCall && node.callee.property.name)
|| (isPotentialHookCall && potentialHookReference && node.callee.name);
const isHookShadowed = isPotentialHookCall
&& hookResolvedDefs
&& hookResolvedDefs.some(
(hookDef) => hookDef.name.name === localHookName
&& hookDef.type !== 'ImportBinding'
);
const isHookCall = (isPotentialReactHookCall && !isReactShadowed)
|| (isPotentialHookCall && localHookName && !isHookShadowed);
if (!isHookCall) {
return false;
}
if (!expectedHookNames) {
return true;
}
return arrayIncludes(
expectedHookNames,
(reactHookImportNames && reactHookImportNames[localHookName]) || localHookName
);
},
};
// Component detection instructions
const detectionInstructions = {
CallExpression(node) {
if (!utils.isPragmaComponentWrapper(node)) {
return;
}
if (node.arguments.length > 0 && astUtil.isFunctionLikeExpression(node.arguments[0])) {
components.add(node, 2);
}
},
ClassExpression(node) {
if (!componentUtil.isES6Component(node, context)) {
return;
}
components.add(node, 2);
},
ClassDeclaration(node) {
if (!componentUtil.isES6Component(node, context)) {
return;
}
components.add(node, 2);
},
ObjectExpression(node) {
if (!componentUtil.isES5Component(node, context)) {
return;
}
components.add(node, 2);
},
FunctionExpression(node) {
if (node.async) {
components.add(node, 0);
return;
}
const component = utils.getParentComponent();
if (
!component
|| (component.parent && component.parent.type === 'JSXExpressionContainer')
) {
// Ban the node if we cannot find a parent component
components.add(node, 0);
return;
}
components.add(component, 2);
},
FunctionDeclaration(node) {
if (node.async) {
components.add(node, 0);
return;
}
node = utils.getParentComponent();
if (!node) {
return;
}
components.add(node, 2);
},
ArrowFunctionExpression(node) {
if (node.async) {
components.add(node, 0);
return;
}
const component = utils.getParentComponent();
if (
!component
|| (component.parent && component.parent.type === 'JSXExpressionContainer')
) {
// Ban the node if we cannot find a parent component
components.add(node, 0);
return;
}
components.add(component, 2);
},
ThisExpression(node) {
const component = utils.getParentStatelessComponent();
if (!component || !/Function/.test(component.type) || !node.parent.property) {
return;
}
// Ban functions accessing a property on a ThisExpression
components.add(node, 0);
},
};
// Detect React import specifiers
const reactImportInstructions = {
ImportDeclaration(node) {
const isReactImported = node.source.type === 'Literal' && node.source.value === 'react';
if (!isReactImported) {
return;
}
node.specifiers.forEach((specifier) => {
if (specifier.type === 'ImportDefaultSpecifier') {
components.addDefaultReactImport(specifier);
}
if (specifier.type === 'ImportSpecifier') {
components.addNamedReactImport(specifier);
}
});
},
};
const ruleInstructions = rule(context, components, utils);
const propTypesInstructions = propTypesUtil(context, components, utils);
const usedPropTypesInstructions = usedPropTypesUtil(context, components, utils);
const defaultPropsInstructions = defaultPropsUtil(context, components, utils);
const mergedRule = mergeRules([
detectionInstructions,
propTypesInstructions,
usedPropTypesInstructions,
defaultPropsInstructions,
reactImportInstructions,
ruleInstructions,
]);
return mergedRule;
}
module.exports = Object.assign(Components, {
detect(rule) {
return componentRule.bind(this, rule);
},
});