eslint-config-chain-able
Version:
an opinionated ESLint configuration
600 lines (505 loc) • 19.8 kB
JavaScript
/**
* @fileOverview Enforce a defaultProps definition for every prop that is not a required prop.
* @author Vitor Balocco
*/
;
var has = require('has');
var Components = require('../util/Components');
var variableUtil = require('../util/variable');
var annotations = require('../util/annotations');
const QUOTES_REGEX = /^["']|["']$/g;
// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
module.exports = {
meta: {
docs: {
description: 'Enforce a defaultProps definition for every prop that is not a required prop.',
category: 'Best Practices'
},
schema: []
},
create: Components.detect(function(context, components, utils) {
var sourceCode = context.getSourceCode();
/**
* Get properties name
* @param {Object} node - Property.
* @returns {String} Property name.
*/
function getPropertyName(node) {
if (node.key || ['MethodDefinition', 'Property'].indexOf(node.type) !== -1) {
return node.key.name;
} else if (node.type === 'MemberExpression') {
return node.property.name;
// Special case for class properties
// (babel-eslint@5 does not expose property name so we have to rely on tokens)
} else if (node.type === 'ClassProperty') {
var tokens = context.getFirstTokens(node, 2);
return tokens[1] && tokens[1].type === 'Identifier' ? tokens[1].value : tokens[0].value;
}
return '';
}
/**
* Checks if the Identifier node passed in looks like a propTypes declaration.
* @param {ASTNode} node The node to check. Must be an Identifier node.
* @returns {Boolean} `true` if the node is a propTypes declaration, `false` if not
*/
function isPropTypesDeclaration(node) {
return getPropertyName(node) === 'propTypes';
}
/**
* Checks if the Identifier node passed in looks like a defaultProps declaration.
* @param {ASTNode} node The node to check. Must be an Identifier node.
* @returns {Boolean} `true` if the node is a defaultProps declaration, `false` if not
*/
function isDefaultPropsDeclaration(node) {
return (getPropertyName(node) === 'defaultProps' || getPropertyName(node) === 'getDefaultProps');
}
/**
* Checks if the PropTypes MemberExpression node passed in declares a required propType.
* @param {ASTNode} propTypeExpression node to check. Must be a `PropTypes` MemberExpression.
* @returns {Boolean} `true` if this PropType is required, `false` if not.
*/
function isRequiredPropType(propTypeExpression) {
return propTypeExpression.type === 'MemberExpression' && propTypeExpression.property.name === 'isRequired';
}
/**
* Find a variable by name in the current scope.
* @param {string} name Name of the variable to look for.
* @returns {ASTNode|null} Return null if the variable could not be found, ASTNode otherwise.
*/
function findVariableByName(name) {
var variable = variableUtil.variablesInScope(context).find(function(item) {
return item.name === name;
});
if (!variable || !variable.defs[0] || !variable.defs[0].node) {
return null;
}
if (variable.defs[0].node.type === 'TypeAlias') {
return variable.defs[0].node.right;
}
return variable.defs[0].node.init;
}
/**
* Try to resolve the node passed in to a variable in the current scope. If the node passed in is not
* an Identifier, then the node is simply returned.
* @param {ASTNode} node The node to resolve.
* @returns {ASTNode|null} Return null if the value could not be resolved, ASTNode otherwise.
*/
function resolveNodeValue(node) {
if (node.type === 'Identifier') {
return findVariableByName(node.name);
}
return node;
}
/**
* Tries to find the definition of a GenericTypeAnnotation in the current scope.
* @param {ASTNode} node The node GenericTypeAnnotation node to resolve.
* @return {ASTNode|null} Return null if definition cannot be found, ASTNode otherwise.
*/
function resolveGenericTypeAnnotation(node) {
if (node.type !== 'GenericTypeAnnotation' || node.id.type !== 'Identifier') {
return null;
}
return findVariableByName(node.id.name);
}
function resolveUnionTypeAnnotation(node) {
// Go through all the union and resolve any generic types.
return node.types.map(function(annotation) {
if (annotation.type === 'GenericTypeAnnotation') {
return resolveGenericTypeAnnotation(annotation);
}
return annotation;
});
}
/**
* Extracts a PropType from an ObjectExpression node.
* @param {ASTNode} objectExpression ObjectExpression node.
* @returns {Object[]} Array of PropType object representations, to be consumed by `addPropTypesToComponent`.
*/
function getPropTypesFromObjectExpression(objectExpression) {
var props = objectExpression.properties.filter(function(property) {
return property.type !== 'ExperimentalSpreadProperty';
});
return props.map(function(property) {
return {
name: sourceCode.getText(property.key).replace(QUOTES_REGEX, ''),
isRequired: isRequiredPropType(property.value),
node: property
};
});
}
/**
* Extracts a PropType from a TypeAnnotation node.
* @param {ASTNode} node TypeAnnotation node.
* @returns {Object[]} Array of PropType object representations, to be consumed by `addPropTypesToComponent`.
*/
function getPropTypesFromTypeAnnotation(node) {
var properties;
switch (node.typeAnnotation.type) {
case 'GenericTypeAnnotation':
var annotation = resolveGenericTypeAnnotation(node.typeAnnotation);
if (annotation && annotation.id) {
annotation = findVariableByName(annotation.id.name);
}
properties = annotation ? (annotation.properties || []) : [];
break;
case 'UnionTypeAnnotation':
var union = resolveUnionTypeAnnotation(node.typeAnnotation);
properties = union.reduce(function(acc, curr) {
if (!curr) {
return acc;
}
return acc.concat(curr.properties);
}, []);
break;
case 'ObjectTypeAnnotation':
properties = node.typeAnnotation.properties;
break;
default:
properties = [];
break;
}
var props = properties.filter(function(property) {
return property.type === 'ObjectTypeProperty';
});
return props.map(function(property) {
// the `key` property is not present in ObjectTypeProperty nodes, so we need to get the key name manually.
var tokens = context.getFirstTokens(property, 1);
var name = tokens[0].value;
return {
name: name,
isRequired: !property.optional,
node: property
};
});
}
/**
* Extracts a DefaultProp from an ObjectExpression node.
* @param {ASTNode} objectExpression ObjectExpression node.
* @returns {Object|string} Object representation of a defaultProp, to be consumed by
* `addDefaultPropsToComponent`, or string "unresolved", if the defaultProps
* from this ObjectExpression can't be resolved.
*/
function getDefaultPropsFromObjectExpression(objectExpression) {
var hasSpread = objectExpression.properties.find(function(property) {
return property.type === 'ExperimentalSpreadProperty';
});
if (hasSpread) {
return 'unresolved';
}
return objectExpression.properties.map(function(property) {
return sourceCode.getText(property.key).replace(QUOTES_REGEX, '');
});
}
/**
* Marks a component's DefaultProps declaration as "unresolved". A component's DefaultProps is
* marked as "unresolved" if we cannot safely infer the values of its defaultProps declarations
* without risking false negatives.
* @param {Object} component The component to mark.
* @returns {void}
*/
function markDefaultPropsAsUnresolved(component) {
components.set(component.node, {
defaultProps: 'unresolved'
});
}
/**
* Adds propTypes to the component passed in.
* @param {ASTNode} component The component to add the propTypes to.
* @param {Object[]} propTypes propTypes to add to the component.
* @returns {void}
*/
function addPropTypesToComponent(component, propTypes) {
var props = component.propTypes || [];
components.set(component.node, {
propTypes: props.concat(propTypes)
});
}
/**
* Adds defaultProps to the component passed in.
* @param {ASTNode} component The component to add the defaultProps to.
* @param {String[]|String} defaultProps defaultProps to add to the component or the string "unresolved"
* if this component has defaultProps that can't be resolved.
* @returns {void}
*/
function addDefaultPropsToComponent(component, defaultProps) {
// Early return if this component's defaultProps is already marked as "unresolved".
if (component.defaultProps === 'unresolved') {
return;
}
if (defaultProps === 'unresolved') {
markDefaultPropsAsUnresolved(component);
return;
}
var defaults = component.defaultProps || {};
defaultProps.forEach(function(defaultProp) {
defaults[defaultProp] = true;
});
components.set(component.node, {
defaultProps: defaults
});
}
/**
* Tries to find a props type annotation in a stateless component.
* @param {ASTNode} node The AST node to look for a props type annotation.
* @return {void}
*/
function handleStatelessComponent(node) {
if (!node.params || !node.params.length || !annotations.isAnnotatedFunctionPropsDeclaration(node, context)) {
return;
}
// find component this props annotation belongs to
var component = components.get(utils.getParentStatelessComponent());
if (!component) {
return;
}
addPropTypesToComponent(component, getPropTypesFromTypeAnnotation(node.params[0].typeAnnotation, context));
}
function handlePropTypeAnnotationClassProperty(node) {
// find component this props annotation belongs to
var component = components.get(utils.getParentES6Component());
if (!component) {
return;
}
addPropTypesToComponent(component, getPropTypesFromTypeAnnotation(node.typeAnnotation, context));
}
function isPropTypeAnnotation(node) {
return (getPropertyName(node) === 'props' && !!node.typeAnnotation);
}
/**
* Reports all propTypes passed in that don't have a defaultProp counterpart.
* @param {Object[]} propTypes List of propTypes to check.
* @param {Object} defaultProps Object of defaultProps to check. Keys are the props names.
* @return {void}
*/
function reportPropTypesWithoutDefault(propTypes, defaultProps) {
// If this defaultProps is "unresolved", then we should ignore this component and not report
// any errors for it, to avoid false-positives with e.g. external defaultProps declarations or spread operators.
if (defaultProps === 'unresolved') {
return;
}
propTypes.forEach(function(prop) {
if (prop.isRequired) {
return;
}
if (defaultProps[prop.name]) {
return;
}
context.report(
prop.node,
'propType "{{name}}" is not required, but has no corresponding defaultProp declaration.',
{name: prop.name}
);
});
}
// --------------------------------------------------------------------------
// Public API
// --------------------------------------------------------------------------
return {
MemberExpression: function(node) {
var isPropType = isPropTypesDeclaration(node);
var isDefaultProp = isDefaultPropsDeclaration(node);
if (!isPropType && !isDefaultProp) {
return;
}
// find component this propTypes/defaultProps belongs to
var component = utils.getRelatedComponent(node);
if (!component) {
return;
}
// e.g.:
// MyComponent.propTypes = {
// foo: PropTypes.string.isRequired,
// bar: PropTypes.string
// };
//
// or:
//
// MyComponent.propTypes = myPropTypes;
if (node.parent.type === 'AssignmentExpression') {
var expression = resolveNodeValue(node.parent.right);
if (!expression || expression.type !== 'ObjectExpression') {
// If a value can't be found, we mark the defaultProps declaration as "unresolved", because
// we should ignore this component and not report any errors for it, to avoid false-positives
// with e.g. external defaultProps declarations.
if (isDefaultProp) {
markDefaultPropsAsUnresolved(component);
}
return;
}
if (isPropType) {
addPropTypesToComponent(component, getPropTypesFromObjectExpression(expression));
} else {
addDefaultPropsToComponent(component, getDefaultPropsFromObjectExpression(expression));
}
return;
}
// e.g.:
// MyComponent.propTypes.baz = PropTypes.string;
if (node.parent.type === 'MemberExpression' && node.parent.parent.type === 'AssignmentExpression') {
if (isPropType) {
addPropTypesToComponent(component, [{
name: node.parent.property.name,
isRequired: isRequiredPropType(node.parent.parent.right),
node: node.parent.parent
}]);
} else {
addDefaultPropsToComponent(component, [node.parent.property.name]);
}
return;
}
},
// e.g.:
// class Hello extends React.Component {
// static get propTypes() {
// return {
// name: PropTypes.string
// };
// }
// static get defaultProps() {
// return {
// name: 'Dean'
// };
// }
// render() {
// return <div>Hello {this.props.name}</div>;
// }
// }
MethodDefinition: function(node) {
if (!node.static || node.kind !== 'get') {
return;
}
var isPropType = isPropTypesDeclaration(node);
var isDefaultProp = isDefaultPropsDeclaration(node);
if (!isPropType && !isDefaultProp) {
return;
}
// find component this propTypes/defaultProps belongs to
var component = components.get(utils.getParentES6Component());
if (!component) {
return;
}
var returnStatement = utils.findReturnStatement(node);
if (!returnStatement) {
return;
}
var expression = resolveNodeValue(returnStatement.argument);
if (!expression || expression.type !== 'ObjectExpression') {
return;
}
if (isPropType) {
addPropTypesToComponent(component, getPropTypesFromObjectExpression(expression));
} else {
addDefaultPropsToComponent(component, getDefaultPropsFromObjectExpression(expression));
}
},
// e.g.:
// class Greeting extends React.Component {
// render() {
// return (
// <h1>Hello, {this.props.foo} {this.props.bar}</h1>
// );
// }
// static propTypes = {
// foo: PropTypes.string,
// bar: PropTypes.string.isRequired
// };
// }
ClassProperty: function(node) {
if (isPropTypeAnnotation(node)) {
handlePropTypeAnnotationClassProperty(node);
return;
}
if (!node.static) {
return;
}
if (!node.value) {
return;
}
var isPropType = getPropertyName(node) === 'propTypes';
var isDefaultProp = getPropertyName(node) === 'defaultProps' || getPropertyName(node) === 'getDefaultProps';
if (!isPropType && !isDefaultProp) {
return;
}
// find component this propTypes/defaultProps belongs to
var component = components.get(utils.getParentES6Component());
if (!component) {
return;
}
var expression = resolveNodeValue(node.value);
if (!expression || expression.type !== 'ObjectExpression') {
return;
}
if (isPropType) {
addPropTypesToComponent(component, getPropTypesFromObjectExpression(expression));
} else {
addDefaultPropsToComponent(component, getDefaultPropsFromObjectExpression(expression));
}
},
// e.g.:
// createReactClass({
// render: function() {
// return <div>{this.props.foo}</div>;
// },
// propTypes: {
// foo: PropTypes.string.isRequired,
// },
// getDefaultProps: function() {
// return {
// foo: 'default'
// };
// }
// });
ObjectExpression: function(node) {
// find component this propTypes/defaultProps belongs to
var component = utils.isES5Component(node) && components.get(node);
if (!component) {
return;
}
// Search for the proptypes declaration
node.properties.forEach(function(property) {
if (property.type === 'ExperimentalSpreadProperty') {
return;
}
var isPropType = isPropTypesDeclaration(property);
var isDefaultProp = isDefaultPropsDeclaration(property);
if (!isPropType && !isDefaultProp) {
return;
}
if (isPropType && property.value.type === 'ObjectExpression') {
addPropTypesToComponent(component, getPropTypesFromObjectExpression(property.value));
return;
}
if (isDefaultProp && property.value.type === 'FunctionExpression') {
var returnStatement = utils.findReturnStatement(property);
if (!returnStatement || returnStatement.argument.type !== 'ObjectExpression') {
return;
}
addDefaultPropsToComponent(component, getDefaultPropsFromObjectExpression(returnStatement.argument));
}
});
},
// Check for type annotations in stateless components
FunctionDeclaration: handleStatelessComponent,
ArrowFunctionExpression: handleStatelessComponent,
FunctionExpression: handleStatelessComponent,
'Program:exit': function() {
var list = components.list();
for (var component in list) {
if (!has(list, component)) {
continue;
}
// If no propTypes could be found, we don't report anything.
if (!list[component].propTypes) {
return;
}
reportPropTypesWithoutDefault(
list[component].propTypes,
list[component].defaultProps || {}
);
}
}
};
})
};