|
10 | 10 |
|
11 | 11 | module.exports = function(context) {
|
12 | 12 |
|
13 |
| - var declaredPropTypes = []; |
14 |
| - var usedPropTypes = []; |
15 |
| - var ignorePropsValidation = false; |
| 13 | + var components = {}; |
16 | 14 |
|
| 15 | + var MISSING_MESSAGE = '\'{{name}}\' is missing in props validation'; |
| 16 | + var MISSING_MESSAGE_NAMED_COMP = '\'{{name}}\' is missing in props validation for {{component}}'; |
| 17 | + |
| 18 | + var defaultClassName = 'eslintReactComponent'; |
| 19 | + |
| 20 | + /** |
| 21 | + * Get the component id from an ASTNode |
| 22 | + * @param {ASTNode} node The AST node being checked. |
| 23 | + * @returns {String} The component id. |
| 24 | + */ |
| 25 | + function getComponentId(node) { |
| 26 | + if ( |
| 27 | + node.type === 'MemberExpression' && |
| 28 | + node.property && node.property.name === 'propTypes' && |
| 29 | + node.object && components[node.object.name] |
| 30 | + ) { |
| 31 | + return node.object.name; |
| 32 | + } |
| 33 | + |
| 34 | + var scope = context.getScope(); |
| 35 | + while (scope && scope.type !== 'class') { |
| 36 | + scope = scope.upper; |
| 37 | + } |
| 38 | + |
| 39 | + if (scope) { |
| 40 | + return scope.block.id.name; |
| 41 | + } |
| 42 | + |
| 43 | + return defaultClassName; |
| 44 | + } |
| 45 | + |
| 46 | + /** |
| 47 | + * Get the component from an ASTNode |
| 48 | + * @param {ASTNode} node The AST node being checked. |
| 49 | + * @returns {Object} The component object. |
| 50 | + */ |
| 51 | + function getComponent(node) { |
| 52 | + var id = getComponentId(node); |
| 53 | + if (!components[id]) { |
| 54 | + components[id] = { |
| 55 | + name: id, |
| 56 | + node: node, |
| 57 | + declaredPropTypes: [], |
| 58 | + usedPropTypes: [], |
| 59 | + isComponentDefinition: false, |
| 60 | + ignorePropsValidation: false |
| 61 | + }; |
| 62 | + } |
| 63 | + return components[id]; |
| 64 | + } |
| 65 | + |
| 66 | + /** |
| 67 | + * Detect if we are in a React component by checking the render method |
| 68 | + * @param {ASTNode} node The AST node being checked. |
| 69 | + */ |
| 70 | + function detectReactComponent(node) { |
| 71 | + var scope = context.getScope(); |
| 72 | + if ( |
| 73 | + (node.argument.type === 'Literal' && (node.argument.value !== null && node.argument.value !== false)) && |
| 74 | + (node.argument.type !== 'JSXElement') && |
| 75 | + (scope.block.parent.key.name === 'render') |
| 76 | + ) { |
| 77 | + return; |
| 78 | + } |
| 79 | + var component = getComponent(node); |
| 80 | + component.isComponentDefinition = true; |
| 81 | + } |
| 82 | + |
| 83 | + /** |
| 84 | + * Checks if we are inside a component definition |
| 85 | + * @param {ASTNode} node The AST node being checked. |
| 86 | + * @returns {Boolean} True if we are inside a component definition, false if not. |
| 87 | + */ |
17 | 88 | function isComponentDefinition(node) {
|
18 |
| - return ( |
| 89 | + var isES5Component = Boolean( |
| 90 | + node.parent && |
| 91 | + node.parent.callee && |
| 92 | + node.parent.callee.object && |
| 93 | + node.parent.callee.property && |
| 94 | + node.parent.callee.object.name === 'React' && |
| 95 | + node.parent.callee.property.name === 'createClass' |
| 96 | + ); |
| 97 | + var isES6Component = getComponent(node).isComponentDefinition; |
| 98 | + return isES5Component || isES6Component; |
| 99 | + } |
| 100 | + |
| 101 | + /** |
| 102 | + * Checks if we are using a prop |
| 103 | + * @param {ASTNode} node The AST node being checked. |
| 104 | + * @returns {Boolean} True if we are using a prop, false if not. |
| 105 | + */ |
| 106 | + function isPropTypesUsage(node) { |
| 107 | + return Boolean( |
| 108 | + node.object.type === 'ThisExpression' && |
| 109 | + node.property.name === 'props' |
| 110 | + ); |
| 111 | + } |
| 112 | + |
| 113 | + /** |
| 114 | + * Checks if we are declaring a prop |
| 115 | + * @param {ASTNode} node The AST node being checked. |
| 116 | + * @returns {Boolean} True if we are declaring a prop, false if not. |
| 117 | + */ |
| 118 | + function isPropTypesDeclaration(node) { |
| 119 | + return Boolean( |
19 | 120 | node &&
|
20 |
| - node.callee && |
21 |
| - node.callee.object && |
22 |
| - node.callee.property && |
23 |
| - node.callee.object.name === 'React' && |
24 |
| - node.callee.property.name === 'createClass' |
| 121 | + node.name === 'propTypes' |
25 | 122 | );
|
26 | 123 | }
|
27 | 124 |
|
| 125 | + /** |
| 126 | + * Mark a prop type as used |
| 127 | + * @param {ASTNode} node The AST node being marked. |
| 128 | + */ |
| 129 | + function markPropTypesAsUsed(node) { |
| 130 | + var component = getComponent(node); |
| 131 | + var type; |
| 132 | + if (node.parent.property && node.parent.property.name) { |
| 133 | + type = 'direct'; |
| 134 | + } else if ( |
| 135 | + node.parent.parent.declarations && |
| 136 | + node.parent.parent.declarations[0].id.properties && |
| 137 | + node.parent.parent.declarations[0].id.properties[0].key.name |
| 138 | + ) { |
| 139 | + type = 'destructuring'; |
| 140 | + } |
| 141 | + |
| 142 | + switch (type) { |
| 143 | + case 'direct': |
| 144 | + component.usedPropTypes.push({ |
| 145 | + name: node.parent.property.name, |
| 146 | + node: node |
| 147 | + }); |
| 148 | + break; |
| 149 | + case 'destructuring': |
| 150 | + for (var i = 0, j = node.parent.parent.declarations[0].id.properties.length; i < j; i++) { |
| 151 | + component.usedPropTypes.push({ |
| 152 | + name: node.parent.parent.declarations[0].id.properties[i].key.name, |
| 153 | + node: node |
| 154 | + }); |
| 155 | + } |
| 156 | + break; |
| 157 | + default: |
| 158 | + break; |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | + /** |
| 163 | + * Mark a prop type as declared |
| 164 | + * @param {ASTNode} node The AST node being checked. |
| 165 | + * @param {propTypes} node The AST node containing the proptypes |
| 166 | + */ |
| 167 | + function markPropTypesAsDeclared(node, propTypes) { |
| 168 | + var component = getComponent(node); |
| 169 | + switch (propTypes.type) { |
| 170 | + case 'ObjectExpression': |
| 171 | + for (var i = 0, j = propTypes.properties.length; i < j; i++) { |
| 172 | + component.declaredPropTypes.push(propTypes.properties[i].key.name); |
| 173 | + } |
| 174 | + break; |
| 175 | + case 'MemberExpression': |
| 176 | + component.declaredPropTypes.push(propTypes.property.name); |
| 177 | + break; |
| 178 | + default: |
| 179 | + component.ignorePropsValidation = true; |
| 180 | + break; |
| 181 | + } |
| 182 | + } |
| 183 | + |
| 184 | + /** |
| 185 | + * Reports undeclared proptypes for a given component |
| 186 | + * @param {String} id The id of the component to process |
| 187 | + */ |
| 188 | + function reportUndeclaredPropTypes(id) { |
| 189 | + if (!components[id] || components[id].ignorePropsValidation === true) { |
| 190 | + return; |
| 191 | + } |
| 192 | + for (var i = 0, j = components[id].usedPropTypes.length; i < j; i++) { |
| 193 | + var isDeclared = components[id].declaredPropTypes.indexOf(components[id].usedPropTypes[i].name) !== -1; |
| 194 | + var isChildren = components[id].usedPropTypes[i].name === 'children'; |
| 195 | + if (isDeclared || isChildren) { |
| 196 | + continue; |
| 197 | + } |
| 198 | + context.report( |
| 199 | + components[id].usedPropTypes[i].node, |
| 200 | + id === defaultClassName ? MISSING_MESSAGE : MISSING_MESSAGE_NAMED_COMP, { |
| 201 | + name: components[id].usedPropTypes[i].name, |
| 202 | + component: id |
| 203 | + } |
| 204 | + ); |
| 205 | + } |
| 206 | + } |
| 207 | + |
28 | 208 | // --------------------------------------------------------------------------
|
29 | 209 | // Public
|
30 | 210 | // --------------------------------------------------------------------------
|
31 | 211 |
|
32 | 212 | return {
|
33 | 213 |
|
34 | 214 | MemberExpression: function(node) {
|
35 |
| - if (node.object.type !== 'ThisExpression' || node.property.name !== 'props' || !node.parent.property) { |
36 |
| - return; |
| 215 | + var type; |
| 216 | + if (isPropTypesUsage(node)) { |
| 217 | + type = 'usage'; |
| 218 | + } else if (isPropTypesDeclaration(node.property)) { |
| 219 | + type = 'declaration'; |
| 220 | + } |
| 221 | + |
| 222 | + switch (type) { |
| 223 | + case 'usage': |
| 224 | + markPropTypesAsUsed(node); |
| 225 | + break; |
| 226 | + case 'declaration': |
| 227 | + markPropTypesAsDeclared(node, node.parent.right || node.parent); |
| 228 | + break; |
| 229 | + default: |
| 230 | + break; |
37 | 231 | }
|
38 |
| - usedPropTypes.push(node.parent.property.name); |
39 | 232 | },
|
40 | 233 |
|
41 | 234 | ObjectExpression: function(node) {
|
42 |
| - |
43 |
| - if (!isComponentDefinition(node.parent)) { |
| 235 | + if (!isComponentDefinition(node)) { |
44 | 236 | return;
|
45 | 237 | }
|
46 | 238 |
|
| 239 | + // Search for the propTypes declaration |
47 | 240 | node.properties.forEach(function(property) {
|
48 |
| - var keyName = property.key.name || property.key.value; |
49 |
| - if (keyName !== 'propTypes') { |
50 |
| - return; |
51 |
| - } |
52 |
| - if (property.value.type !== 'ObjectExpression') { |
53 |
| - ignorePropsValidation = true; |
| 241 | + if (!isPropTypesDeclaration(property.key)) { |
54 | 242 | return;
|
55 | 243 | }
|
56 |
| - |
57 |
| - for (var i = 0, j = property.value.properties.length; i < j; i++) { |
58 |
| - declaredPropTypes.push(property.value.properties[i].key.name); |
59 |
| - } |
| 244 | + markPropTypesAsDeclared(node, property.value); |
60 | 245 | });
|
61 | 246 | },
|
62 | 247 |
|
63 | 248 | 'ObjectExpression:exit': function(node) {
|
64 |
| - |
65 |
| - if (!isComponentDefinition(node.parent)) { |
| 249 | + if (!isComponentDefinition(node)) { |
66 | 250 | return;
|
67 | 251 | }
|
68 | 252 |
|
69 |
| - for (var i = 0, j = usedPropTypes.length; !ignorePropsValidation && i < j; i++) { |
70 |
| - if (declaredPropTypes.indexOf(usedPropTypes[i]) !== -1 || usedPropTypes[i] === 'children') { |
| 253 | + // Report undeclared proptypes for all ES5 classes |
| 254 | + reportUndeclaredPropTypes(defaultClassName); |
| 255 | + |
| 256 | + // Reset the ES5 default object |
| 257 | + if (components[defaultClassName]) { |
| 258 | + components[defaultClassName].declaredPropTypes.length = 0; |
| 259 | + components[defaultClassName].usedPropTypes.length = 0; |
| 260 | + components[defaultClassName].isComponentDefinition = false; |
| 261 | + components[defaultClassName].ignorePropsValidation = false; |
| 262 | + } |
| 263 | + }, |
| 264 | + |
| 265 | + 'Program:exit': function() { |
| 266 | + // Report undeclared proptypes for all ES6 classes |
| 267 | + for (var component in components) { |
| 268 | + if (!components.hasOwnProperty(component)) { |
71 | 269 | continue;
|
72 | 270 | }
|
73 |
| - context.report(node, '\'' + usedPropTypes[i] + '\' is missing in props validation'); |
| 271 | + reportUndeclaredPropTypes(component); |
74 | 272 | }
|
| 273 | + }, |
75 | 274 |
|
76 |
| - declaredPropTypes.length = 0; |
77 |
| - usedPropTypes.length = 0; |
78 |
| - ignorePropsValidation = false; |
79 |
| - } |
| 275 | + ReturnStatement: detectReactComponent |
80 | 276 | };
|
81 | 277 |
|
82 | 278 | };
|
0 commit comments