UNPKG

d2-ui

Version:
898 lines (825 loc) 28.5 kB
/** * @fileoverview Prevent missing props validation in a React component definition * @author Yannick Croissant */ 'use strict'; // As for exceptions for props.children or props.className (and alike) look at // https://github.com/yannickcr/eslint-plugin-react/issues/7 var Components = require('../util/Components'); var variable = require('../util/variable'); // ------------------------------------------------------------------------------ // Constants // ------------------------------------------------------------------------------ var DIRECT_PROPS_REGEX = /^props\s*(\.|\[)/; // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ module.exports = Components.detect(function(context, components, utils) { var sourceCode = context.getSourceCode(); var configuration = context.options[0] || {}; var ignored = configuration.ignore || []; var customValidators = configuration.customValidators || []; // Used to track the type annotations in scope. // Necessary because babel's scopes do not track type annotations. var stack = null; var MISSING_MESSAGE = '\'{{name}}\' is missing in props validation'; /** * Helper for accessing the current scope in the stack. * @param {string} key The name of the identifier to access. If omitted, returns the full scope. * @param {ASTNode} value If provided sets the new value for the identifier. * @returns {Object|ASTNode} Either the whole scope or the ASTNode associated with the given identifier. */ function typeScope(key, value) { if (arguments.length === 0) { return stack[stack.length - 1]; } else if (arguments.length === 1) { return stack[stack.length - 1][key]; } stack[stack.length - 1][key] = value; return value; } /** * Checks if we are using a prop * @param {ASTNode} node The AST node being checked. * @returns {Boolean} True if we are using a prop, false if not. */ function isPropTypesUsage(node) { var isClassUsage = ( (utils.getParentES6Component() || utils.getParentES5Component()) && node.object.type === 'ThisExpression' && node.property.name === 'props' ); var isStatelessFunctionUsage = node.object.name === 'props'; return isClassUsage || isStatelessFunctionUsage; } /** * Checks if we are declaring a `props` class property with a flow type annotation. * @param {ASTNode} node The AST node being checked. * @returns {Boolean} True if the node is a type annotated props declaration, false if not. */ function isAnnotatedClassPropsDeclaration(node) { if (node && node.type === 'ClassProperty') { var tokens = context.getFirstTokens(node, 2); if ( node.typeAnnotation && ( tokens[0].value === 'props' || (tokens[1] && tokens[1].value === 'props') ) ) { return true; } } return false; } /** * Checks if we are declaring a `props` argument with a flow type annotation. * @param {ASTNode} node The AST node being checked. * @returns {Boolean} True if the node is a type annotated props declaration, false if not. */ function isAnnotatedFunctionPropsDeclaration(node) { if (node && node.params && node.params.length) { var tokens = context.getFirstTokens(node.params[0], 2); var isAnnotated = node.params[0].typeAnnotation; var isDestructuredProps = node.params[0].type === 'ObjectPattern'; var isProps = tokens[0].value === 'props' || (tokens[1] && tokens[1].value === 'props'); if (isAnnotated && (isDestructuredProps || isProps)) { return true; } } return false; } /** * Checks if we are declaring a prop * @param {ASTNode} node The AST node being checked. * @returns {Boolean} True if we are declaring a prop, false if not. */ function isPropTypesDeclaration(node) { // Special case for class properties // (babel-eslint does not expose property name so we have to rely on tokens) if (node && node.type === 'ClassProperty') { var tokens = context.getFirstTokens(node, 2); if ( tokens[0].value === 'propTypes' || (tokens[1] && tokens[1].value === 'propTypes') ) { return true; } return false; } return Boolean( node && node.name === 'propTypes' ); } /** * Checks if the prop is ignored * @param {String} name Name of the prop to check. * @returns {Boolean} True if the prop is ignored, false if not. */ function isIgnored(name) { return ignored.indexOf(name) !== -1; } /** * Checks if prop should be validated by plugin-react-proptypes * @param {String} validator Name of validator to check. * @returns {Boolean} True if validator should be checked by custom validator. */ function hasCustomValidator(validator) { return customValidators.indexOf(validator) !== -1; } /** * Checks if the component must be validated * @param {Object} component The component to process * @returns {Boolean} True if the component must be validated, false if not. */ function mustBeValidated(component) { return Boolean( component && component.usedPropTypes && !component.ignorePropsValidation ); } /** * Internal: Checks if the prop is declared * @param {Object} declaredPropTypes Description of propTypes declared in the current component * @param {String[]} keyList Dot separated name of the prop to check. * @returns {Boolean} True if the prop is declared, false if not. */ function _isDeclaredInComponent(declaredPropTypes, keyList) { for (var i = 0, j = keyList.length; i < j; i++) { var key = keyList[i]; var propType = ( // Check if this key is declared declaredPropTypes[key] || // If not, check if this type accepts any key declaredPropTypes.__ANY_KEY__ ); if (!propType) { // If it's a computed property, we can't make any further analysis, but is valid return key === '__COMPUTED_PROP__'; } if (propType === true) { return true; } // Consider every children as declared if (propType.children === true) { return true; } if (propType.acceptedProperties) { return key in propType.acceptedProperties; } if (propType.type === 'union') { // If we fall in this case, we know there is at least one complex type in the union if (i + 1 >= j) { // this is the last key, accept everything return true; } // non trivial, check all of them var unionTypes = propType.children; var unionPropType = {}; for (var k = 0, z = unionTypes.length; k < z; k++) { unionPropType[key] = unionTypes[k]; var isValid = _isDeclaredInComponent( unionPropType, keyList.slice(i) ); if (isValid) { return true; } } // every possible union were invalid return false; } declaredPropTypes = propType.children; } return true; } /** * Checks if the prop is declared * @param {ASTNode} node The AST node being checked. * @param {String[]} names List of names of the prop to check. * @returns {Boolean} True if the prop is declared, false if not. */ function isDeclaredInComponent(node, names) { while (node) { var component = components.get(node); var isDeclared = component && component.confidence === 2 && _isDeclaredInComponent(component.declaredPropTypes || {}, names) ; if (isDeclared) { return true; } node = node.parent; } return false; } /** * Checks if the prop has spread operator. * @param {ASTNode} node The AST node being marked. * @returns {Boolean} True if the prop has spread operator, false if not. */ function hasSpreadOperator(node) { var tokens = sourceCode.getTokens(node); return tokens.length && tokens[0].value === '...'; } /** * Retrieve the name of a key node * @param {ASTNode} node The AST node with the key. * @return {string} the name of the key */ function getKeyValue(node) { if (node.type === 'ObjectTypeProperty') { var tokens = context.getFirstTokens(node, 1); return tokens[0].value; } var key = node.key || node.argument; return key.type === 'Identifier' ? key.name : key.value; } /** * Iterates through a properties node, like a customized forEach. * @param {Object[]} properties Array of properties to iterate. * @param {Function} fn Function to call on each property, receives property key and property value. (key, value) => void */ function iterateProperties(properties, fn) { if (properties.length && typeof fn === 'function') { for (var i = 0, j = properties.length; i < j; i++) { var node = properties[i]; var key = getKeyValue(node); var value = node.value; fn(key, value); } } } /** * Creates the representation of the React propTypes for the component. * The representation is used to verify nested used properties. * @param {ASTNode} value Node of the React.PropTypes for the desired property * @return {Object|Boolean} The representation of the declaration, true means * the property is declared without the need for further analysis. */ function buildReactDeclarationTypes(value) { if ( value && value.callee && value.callee.object && hasCustomValidator(value.callee.object.name) ) { return true; } if ( value && value.type === 'MemberExpression' && value.property && value.property.name && value.property.name === 'isRequired' ) { value = value.object; } // Verify React.PropTypes that are functions if ( value && value.type === 'CallExpression' && value.callee && value.callee.property && value.callee.property.name && value.arguments && value.arguments.length > 0 ) { var callName = value.callee.property.name; var argument = value.arguments[0]; switch (callName) { case 'shape': if (argument.type !== 'ObjectExpression') { // Invalid proptype or cannot analyse statically return true; } var shapeTypeDefinition = { type: 'shape', children: {} }; iterateProperties(argument.properties, function(childKey, childValue) { shapeTypeDefinition.children[childKey] = buildReactDeclarationTypes(childValue); }); return shapeTypeDefinition; case 'arrayOf': case 'objectOf': return { type: 'object', children: { __ANY_KEY__: buildReactDeclarationTypes(argument) } }; case 'oneOfType': if ( !argument.elements || !argument.elements.length ) { // Invalid proptype or cannot analyse statically return true; } var unionTypeDefinition = { type: 'union', children: [] }; for (var i = 0, j = argument.elements.length; i < j; i++) { var type = buildReactDeclarationTypes(argument.elements[i]); // keep only complex type if (type !== true) { if (type.children === true) { // every child is accepted for one type, abort type analysis unionTypeDefinition.children = true; return unionTypeDefinition; } } unionTypeDefinition.children.push(type); } if (unionTypeDefinition.length === 0) { // no complex type found, simply accept everything return true; } return unionTypeDefinition; case 'instanceOf': return { type: 'instance', // Accept all children because we can't know what type they are children: true }; case 'oneOf': default: return true; } } // Unknown property or accepts everything (any, object, ...) return true; } /** * Creates the representation of the React props type annotation for the component. * The representation is used to verify nested used properties. * @param {ASTNode} annotation Type annotation for the props class property. * @return {Object|Boolean} The representation of the declaration, true means * the property is declared without the need for further analysis. */ function buildTypeAnnotationDeclarationTypes(annotation) { switch (annotation.type) { case 'GenericTypeAnnotation': if (typeScope(annotation.id.name)) { return buildTypeAnnotationDeclarationTypes(typeScope(annotation.id.name)); } return true; case 'ObjectTypeAnnotation': var shapeTypeDefinition = { type: 'shape', children: {} }; iterateProperties(annotation.properties, function(childKey, childValue) { shapeTypeDefinition.children[childKey] = buildTypeAnnotationDeclarationTypes(childValue); }); return shapeTypeDefinition; case 'UnionTypeAnnotation': var unionTypeDefinition = { type: 'union', children: [] }; for (var i = 0, j = annotation.types.length; i < j; i++) { var type = buildTypeAnnotationDeclarationTypes(annotation.types[i]); // keep only complex type if (type !== true) { if (type.children === true) { // every child is accepted for one type, abort type analysis unionTypeDefinition.children = true; return unionTypeDefinition; } } unionTypeDefinition.children.push(type); } if (unionTypeDefinition.children.length === 0) { // no complex type found, simply accept everything return true; } return unionTypeDefinition; case 'ArrayTypeAnnotation': return { type: 'object', children: { __ANY_KEY__: buildTypeAnnotationDeclarationTypes(annotation.elementType) } }; default: // Unknown or accepts everything. return true; } } /** * Check if we are in a class constructor * @return {boolean} true if we are in a class constructor, false if not */ function inConstructor() { var scope = context.getScope(); while (scope) { if (scope.block && scope.block.parent && scope.block.parent.kind === 'constructor') { return true; } scope = scope.upper; } return false; } /** * Retrieve the name of a property node * @param {ASTNode} node The AST node with the property. * @return {string} the name of the property or undefined if not found */ function getPropertyName(node) { var isDirectProp = DIRECT_PROPS_REGEX.test(sourceCode.getText(node)); var isInClassComponent = utils.getParentES6Component() || utils.getParentES5Component(); var isNotInConstructor = !inConstructor(node); if (isDirectProp && isInClassComponent && isNotInConstructor) { return void 0; } if (!isDirectProp) { node = node.parent; } var property = node.property; if (property) { switch (property.type) { case 'Identifier': if (node.computed) { return '__COMPUTED_PROP__'; } return property.name; case 'MemberExpression': return void 0; case 'Literal': // Accept computed properties that are literal strings if (typeof property.value === 'string') { return property.value; } // falls through default: if (node.computed) { return '__COMPUTED_PROP__'; } break; } } return void 0; } /** * Mark a prop type as used * @param {ASTNode} node The AST node being marked. */ function markPropTypesAsUsed(node, parentNames) { parentNames = parentNames || []; var type; var name; var allNames; var properties; switch (node.type) { case 'MemberExpression': name = getPropertyName(node); if (name) { allNames = parentNames.concat(name); if (node.parent.type === 'MemberExpression') { markPropTypesAsUsed(node.parent, allNames); } // Do not mark computed props as used. type = name !== '__COMPUTED_PROP__' ? 'direct' : null; } else if ( node.parent.id && node.parent.id.properties && node.parent.id.properties.length && getKeyValue(node.parent.id.properties[0]) ) { type = 'destructuring'; properties = node.parent.id.properties; } break; case 'ArrowFunctionExpression': case 'FunctionDeclaration': case 'FunctionExpression': type = 'destructuring'; properties = node.params[0].properties; break; case 'VariableDeclarator': for (var i = 0, j = node.id.properties.length; i < j; i++) { // let {props: {firstname}} = this var thisDestructuring = ( (node.id.properties[i].key.name === 'props' || node.id.properties[i].key.value === 'props') && node.id.properties[i].value.type === 'ObjectPattern' ); // let {firstname} = props var statelessDestructuring = node.init.name === 'props' && utils.getParentStatelessComponent(); if (thisDestructuring) { properties = node.id.properties[i].value.properties; } else if (statelessDestructuring) { properties = node.id.properties; } else { continue; } type = 'destructuring'; break; } break; default: throw new Error(node.type + ' ASTNodes are not handled by markPropTypesAsUsed'); } var component = components.get(utils.getParentComponent()); var usedPropTypes = component && component.usedPropTypes || []; switch (type) { case 'direct': // Ignore Object methods if (Object.prototype[name]) { break; } var isDirectProp = DIRECT_PROPS_REGEX.test(sourceCode.getText(node)); usedPropTypes.push({ name: name, allNames: allNames, node: !isDirectProp && !inConstructor(node) ? node.parent.property : node.property }); break; case 'destructuring': for (var k = 0, l = properties.length; k < l; k++) { if (hasSpreadOperator(properties[k]) || properties[k].computed) { continue; } var propName = getKeyValue(properties[k]); var currentNode = node; allNames = []; while (currentNode.property && currentNode.property.name !== 'props') { allNames.unshift(currentNode.property.name); currentNode = currentNode.object; } allNames.push(propName); if (propName) { usedPropTypes.push({ name: propName, allNames: allNames, node: properties[k] }); } } break; default: break; } components.set(node, { usedPropTypes: usedPropTypes }); } /** * Mark a prop type as declared * @param {ASTNode} node The AST node being checked. * @param {propTypes} node The AST node containing the proptypes */ function markPropTypesAsDeclared(node, propTypes) { var component = components.get(node); var declaredPropTypes = component && component.declaredPropTypes || {}; var ignorePropsValidation = false; switch (propTypes && propTypes.type) { case 'ObjectTypeAnnotation': iterateProperties(propTypes.properties, function(key, value) { declaredPropTypes[key] = buildTypeAnnotationDeclarationTypes(value); }); break; case 'ObjectExpression': iterateProperties(propTypes.properties, function(key, value) { if (!value) { ignorePropsValidation = true; return; } declaredPropTypes[key] = buildReactDeclarationTypes(value); }); break; case 'MemberExpression': var curDeclaredPropTypes = declaredPropTypes; // Walk the list of properties, until we reach the assignment // ie: ClassX.propTypes.a.b.c = ... while ( propTypes && propTypes.parent && propTypes.parent.type !== 'AssignmentExpression' && propTypes.property && curDeclaredPropTypes ) { var propName = propTypes.property.name; if (propName in curDeclaredPropTypes) { curDeclaredPropTypes = curDeclaredPropTypes[propName].children; propTypes = propTypes.parent; } else { // This will crash at runtime because we haven't seen this key before // stop this and do not declare it propTypes = null; } } if (propTypes && propTypes.parent && propTypes.property) { curDeclaredPropTypes[propTypes.property.name] = buildReactDeclarationTypes(propTypes.parent.right); } break; case 'Identifier': var variablesInScope = variable.variablesInScope(context); for (var i = 0, j = variablesInScope.length; i < j; i++) { if (variablesInScope[i].name !== propTypes.name) { continue; } var defInScope = variablesInScope[i].defs[variablesInScope[i].defs.length - 1]; markPropTypesAsDeclared(node, defInScope.node && defInScope.node.init); return; } ignorePropsValidation = true; break; case null: break; default: ignorePropsValidation = true; break; } components.set(node, { declaredPropTypes: declaredPropTypes, ignorePropsValidation: ignorePropsValidation }); } /** * Reports undeclared proptypes for a given component * @param {Object} component The component to process */ function reportUndeclaredPropTypes(component) { var allNames; for (var i = 0, j = component.usedPropTypes.length; i < j; i++) { allNames = component.usedPropTypes[i].allNames; if ( isIgnored(allNames[0]) || isDeclaredInComponent(component.node, allNames) ) { continue; } context.report( component.usedPropTypes[i].node, MISSING_MESSAGE, { name: allNames.join('.').replace(/\.__COMPUTED_PROP__/g, '[]') } ); } } /** * Resolve the type annotation for a given node. * Flow annotations are sometimes wrapped in outer `TypeAnnotation` * and `NullableTypeAnnotation` nodes which obscure the annotation we're * interested in. * This method also resolves type aliases where possible. * * @param {ASTNode} node The annotation or a node containing the type annotation. * @returns {ASTNode} The resolved type annotation for the node. */ function resolveTypeAnnotation(node) { var annotation = node.typeAnnotation || node; while (annotation && (annotation.type === 'TypeAnnotation' || annotation.type === 'NullableTypeAnnotation')) { annotation = annotation.typeAnnotation; } if (annotation.type === 'GenericTypeAnnotation' && typeScope(annotation.id.name)) { return typeScope(annotation.id.name); } return annotation; } /** * @param {ASTNode} node We expect either an ArrowFunctionExpression, * FunctionDeclaration, or FunctionExpression */ function markDestructuredFunctionArgumentsAsUsed(node) { var destructuring = node.params && node.params[0] && node.params[0].type === 'ObjectPattern'; if (destructuring && components.get(node)) { markPropTypesAsUsed(node); } } /** * @param {ASTNode} node We expect either an ArrowFunctionExpression, * FunctionDeclaration, or FunctionExpression */ function markAnnotatedFunctionArgumentsAsDeclared(node) { if (!node.params || !node.params.length || !isAnnotatedFunctionPropsDeclaration(node)) { return; } markPropTypesAsDeclared(node, resolveTypeAnnotation(node.params[0])); } /** * @param {ASTNode} node We expect either an ArrowFunctionExpression, * FunctionDeclaration, or FunctionExpression */ function handleStatelessComponent(node) { markDestructuredFunctionArgumentsAsUsed(node); markAnnotatedFunctionArgumentsAsDeclared(node); } // -------------------------------------------------------------------------- // Public // -------------------------------------------------------------------------- return { ClassProperty: function(node) { if (isAnnotatedClassPropsDeclaration(node)) { markPropTypesAsDeclared(node, resolveTypeAnnotation(node)); } else if (isPropTypesDeclaration(node)) { markPropTypesAsDeclared(node, node.value); } }, VariableDeclarator: function(node) { var destructuring = node.init && node.id && node.id.type === 'ObjectPattern'; // let {props: {firstname}} = this var thisDestructuring = destructuring && node.init.type === 'ThisExpression'; // let {firstname} = props var statelessDestructuring = destructuring && node.init.name === 'props' && utils.getParentStatelessComponent(); if (!thisDestructuring && !statelessDestructuring) { return; } markPropTypesAsUsed(node); }, FunctionDeclaration: handleStatelessComponent, ArrowFunctionExpression: handleStatelessComponent, FunctionExpression: handleStatelessComponent, MemberExpression: function(node) { var type; if (isPropTypesUsage(node)) { type = 'usage'; } else if (isPropTypesDeclaration(node.property)) { type = 'declaration'; } switch (type) { case 'usage': markPropTypesAsUsed(node); break; case 'declaration': var component = utils.getRelatedComponent(node); if (!component) { return; } markPropTypesAsDeclared(component.node, node.parent.right || node.parent); break; default: break; } }, MethodDefinition: function(node) { if (!isPropTypesDeclaration(node.key)) { return; } var i = node.value.body.body.length - 1; for (; i >= 0; i--) { if (node.value.body.body[i].type === 'ReturnStatement') { break; } } if (i >= 0) { markPropTypesAsDeclared(node, node.value.body.body[i].argument); } }, ObjectExpression: function(node) { // Search for the proptypes declaration node.properties.forEach(function(property) { if (!isPropTypesDeclaration(property.key)) { return; } markPropTypesAsDeclared(node, property.value); }); }, TypeAlias: function(node) { typeScope(node.id.name, node.right); }, Program: function() { stack = [{}]; }, BlockStatement: function () { stack.push(Object.create(typeScope())); }, 'BlockStatement:exit': function () { stack.pop(); }, 'Program:exit': function() { stack = null; var list = components.list(); // Report undeclared proptypes for all classes for (var component in list) { if (!list.hasOwnProperty(component) || !mustBeValidated(list[component])) { continue; } reportUndeclaredPropTypes(list[component]); } } }; }); module.exports.schema = [{ type: 'object', properties: { ignore: { type: 'array', items: { type: 'string' } }, customValidators: { type: 'array', items: { type: 'string' } } }, additionalProperties: false }];