From b8e0a4ec85024c2d5860a723b0da377374ad6649 Mon Sep 17 00:00:00 2001 From: Charles Pick Date: Thu, 7 Jan 2016 00:25:36 +0000 Subject: [PATCH] Support flow type annotations for component props. --- lib/rules/prop-types.js | 154 +++++++++++++++- tests/lib/rules/prop-types.js | 327 ++++++++++++++++++++++++++++++++++ 2 files changed, 477 insertions(+), 4 deletions(-) diff --git a/lib/rules/prop-types.js b/lib/rules/prop-types.js index ed78afd07c..5058a9e534 100644 --- a/lib/rules/prop-types.js +++ b/lib/rules/prop-types.js @@ -19,9 +19,28 @@ module.exports = Components.detect(function(context, components, utils) { 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. @@ -36,6 +55,26 @@ module.exports = Components.detect(function(context, components, utils) { 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 isAnnotatedPropsDeclaration(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 prop * @param {ASTNode} node The AST node being checked. @@ -189,6 +228,10 @@ module.exports = Components.detect(function(context, components, utils) { * @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; } @@ -214,7 +257,7 @@ module.exports = Components.detect(function(context, components, utils) { /** * 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 propery + * @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. */ @@ -315,6 +358,65 @@ module.exports = Components.detect(function(context, components, utils) { 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 @@ -488,6 +590,11 @@ module.exports = Components.detect(function(context, components, utils) { 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) { declaredPropTypes[key] = buildReactDeclarationTypes(value); @@ -567,16 +674,38 @@ module.exports = Components.detect(function(context, components, utils) { } } + /** + * 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; + } + // -------------------------------------------------------------------------- // Public // -------------------------------------------------------------------------- return { ClassProperty: function(node) { - if (!isPropTypesDeclaration(node)) { - return; + if (isAnnotatedPropsDeclaration(node)) { + markPropTypesAsDeclared(node, resolveTypeAnnotation(node)); + } else if (isPropTypesDeclaration(node)) { + markPropTypesAsDeclared(node, node.value); } - markPropTypesAsDeclared(node, node.value); }, VariableDeclarator: function(node) { @@ -643,7 +772,24 @@ module.exports = Components.detect(function(context, components, utils) { }); }, + 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) { diff --git a/tests/lib/rules/prop-types.js b/tests/lib/rules/prop-types.js index 63d5bc2364..2855861fb0 100644 --- a/tests/lib/rules/prop-types.js +++ b/tests/lib/rules/prop-types.js @@ -845,6 +845,182 @@ ruleTester.run('prop-types', rule, { '}' ].join('\n'), parser: 'babel-eslint' + }, { + code: [ + 'class Hello extends React.Component {', + ' props: {', + ' name: string;', + ' };', + ' render () {', + ' return
Hello {this.props.name}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'class Hello extends React.Component {', + ' props: {', + ' name: Object;', + ' };', + ' render () {', + ' return
Hello {this.props.name.firstname}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'type Props = {name: Object;};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' return
Hello {this.props.name.firstname}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'import type Props from "fake";', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' return
Hello {this.props.name.firstname}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'class Hello extends React.Component {', + ' props: {', + ' name: {', + ' firstname: string;', + ' }', + ' };', + ' render () {', + ' return
Hello {this.props.name.firstname}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'type Props = {name: {firstname: string;};};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' return
Hello {this.props.name.firstname}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'type Props = {name: {firstname: string; lastname: string;};};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' return
Hello {this.props.name}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'type Person = {name: {firstname: string;}};', + 'class Hello extends React.Component {', + ' props: {people: Person[];};', + ' render () {', + ' var names = [];', + ' for (var i = 0; i < this.props.people.length; i++) {', + ' names.push(this.props.people[i].name.firstname);', + ' }', + ' return
Hello {names.join(', ')}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'type Person = {name: {firstname: string;}};', + 'type Props = {people: Person[];};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' var names = [];', + ' for (var i = 0; i < this.props.people.length; i++) {', + ' names.push(this.props.people[i].name.firstname);', + ' }', + ' return
Hello {names.join(', ')}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'type Person = {name: {firstname: string;}};', + 'type Props = {people: Person[]|Person;};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' var names = [];', + ' if (Array.isArray(this.props.people)) {', + ' for (var i = 0; i < this.props.people.length; i++) {', + ' names.push(this.props.people[i].name.firstname);', + ' }', + ' } else {', + ' names.push(this.props.people.name.firstname);', + ' }', + ' return
Hello {names.join(', ')}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'type Props = {ok: string | boolean;};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' return
Hello {this.props.ok}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'type Props = {result: {ok: string | boolean;}|{ok: number | Array}};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' return
Hello {this.props.result.ok}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'type Props = {result?: {ok?: ?string | boolean;}|{ok?: ?number | Array}};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' return
Hello {this.props.result.ok}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' + }, { + code: [ + 'class Hello extends React.Component {', + ' props = {a: 123};', + ' render () {', + ' return
Hello
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint' } ], @@ -1468,6 +1644,157 @@ ruleTester.run('prop-types', rule, { errors: [ {message: '\'lastname\' is missing in props validation'} ] + }, { + code: [ + 'class Hello extends React.Component {', + ' props: {};', + ' render () {', + ' return
Hello {this.props.name}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint', + errors: [ + {message: '\'name\' is missing in props validation'} + ] + }, { + code: [ + 'class Hello extends React.Component {', + ' props: {', + ' name: Object;', + ' };', + ' render () {', + ' return
Hello {this.props.firstname}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint', + errors: [ + {message: '\'firstname\' is missing in props validation'} + ] + }, { + code: [ + 'type Props = {name: Object;};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' return
Hello {this.props.firstname}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint', + errors: [ + {message: '\'firstname\' is missing in props validation'} + ] + }, { + code: [ + 'class Hello extends React.Component {', + ' props: {', + ' name: {', + ' firstname: string;', + ' }', + ' };', + ' render () {', + ' return
Hello {this.props.name.lastname}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint', + errors: [ + {message: '\'name.lastname\' is missing in props validation'} + ] + }, { + code: [ + 'type Props = {name: {firstname: string;};};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' return
Hello {this.props.name.lastname}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint', + errors: [ + {message: '\'name.lastname\' is missing in props validation'} + ] + }, { + code: [ + 'class Hello extends React.Component {', + ' props: {person: {name: {firstname: string;};};};', + ' render () {', + ' return
Hello {this.props.person.name.lastname}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint', + errors: [ + {message: '\'person.name.lastname\' is missing in props validation'} + ] + }, { + code: [ + 'type Props = {person: {name: {firstname: string;};};};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' return
Hello {this.props.person.name.lastname}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint', + errors: [ + {message: '\'person.name.lastname\' is missing in props validation'} + ] + }, { + code: [ + 'type Person = {name: {firstname: string;}};', + 'class Hello extends React.Component {', + ' props: {people: Person[];};', + ' render () {', + ' var names = [];', + ' for (var i = 0; i < this.props.people.length; i++) {', + ' names.push(this.props.people[i].name.lastname);', + ' }', + ' return
Hello {names.join(', ')}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint', + errors: [ + {message: '\'people[].name.lastname\' is missing in props validation'} + ] + }, { + code: [ + 'type Person = {name: {firstname: string;}};', + 'type Props = {people: Person[];};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' var names = [];', + ' for (var i = 0; i < this.props.people.length; i++) {', + ' names.push(this.props.people[i].name.lastname);', + ' }', + ' return
Hello {names.join(', ')}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint', + errors: [ + {message: '\'people[].name.lastname\' is missing in props validation'} + ] + }, { + code: [ + 'type Props = {result?: {ok: string | boolean;}|{ok: number | Array}};', + 'class Hello extends React.Component {', + ' props: Props;', + ' render () {', + ' return
Hello {this.props.result.notok}
;', + ' }', + '}' + ].join('\n'), + parser: 'babel-eslint', + errors: [ + {message: '\'result.notok\' is missing in props validation'} + ] } ] });