Skip to main content

oparry_validators/
react.rs

1//! React validator - hooks rules, patterns, and best practices
2
3use crate::Validator;
4use oparry_core::{Issue, IssueLevel, Result, ValidationResult};
5use oparry_parser::{ParsedCode, Language};
6use regex::Regex;
7use std::path::Path;
8
9/// React validation configuration
10#[derive(Debug, Clone)]
11pub struct ReactConfig {
12    /// Enforce hooks rules
13    pub enforce_hooks_rules: bool,
14    /// Maximum component lines
15    pub max_component_lines: usize,
16    /// Require prop-types or TypeScript
17    pub require_prop_types: bool,
18    /// Enforce function components
19    pub prefer_function_components: bool,
20    /// Block specific patterns
21    pub blocked_patterns: Vec<String>,
22}
23
24impl Default for ReactConfig {
25    fn default() -> Self {
26        Self {
27            enforce_hooks_rules: true,
28            max_component_lines: 300,
29            require_prop_types: false,
30            prefer_function_components: true,
31            blocked_patterns: vec![
32                "propTypes".to_string(),
33                "createClass".to_string(),
34            ],
35        }
36    }
37}
38
39/// React validator
40pub struct ReactValidator {
41    config: ReactConfig,
42    class_component_regex: Regex,
43}
44
45impl ReactValidator {
46    /// Create new React validator
47    pub fn new(config: ReactConfig) -> Self {
48        Self {
49            config,
50            class_component_regex: Regex::new(r"class\s+(\w+)\s+extends\s+React\.Component").unwrap(),
51        }
52    }
53
54    /// Create with default config
55    pub fn default_config() -> Self {
56        Self::new(ReactConfig::default())
57    }
58
59    /// Check component size
60    fn check_component_size(&self, source: &str, file: &str) -> Vec<Issue> {
61        let mut issues = Vec::new();
62        let line_count = source.lines().count();
63
64        if line_count > self.config.max_component_lines {
65            issues.push(Issue::warning(
66                "react-component-size",
67                format!(
68                    "Component too large: {} lines (max: {})",
69                    line_count, self.config.max_component_lines
70                ),
71            )
72            .with_file(file)
73            .with_suggestion("Split component into smaller pieces"));
74        }
75
76        issues
77    }
78
79    /// Check for class components (if function preferred)
80    fn check_class_components(&self, source: &str, file: &str) -> Vec<Issue> {
81        let mut issues = Vec::new();
82
83        if self.config.prefer_function_components {
84            for caps in self.class_component_regex.captures_iter(source) {
85                if let Some(component) = caps.get(1) {
86                    issues.push(Issue::warning(
87                        "react-class-component",
88                        format!("Class component '{}' should be a function component", component.as_str()),
89                    )
90                    .with_file(file)
91                    .with_suggestion("Convert to function component with hooks"));
92                }
93            }
94        }
95
96        issues
97    }
98}
99
100impl Validator for ReactValidator {
101    fn name(&self) -> &str {
102        "React"
103    }
104
105    fn supports(&self, language: Language) -> bool {
106        language.is_javascript_variant()
107    }
108
109    fn validate_parsed(&self, code: &ParsedCode, file: &Path) -> Result<ValidationResult> {
110        let mut result = ValidationResult::new();
111        let source = code.source();
112
113        let file_str = file.to_string_lossy().to_string();
114
115        // Check all React rules
116        for issue in self.check_component_size(source, &file_str) {
117            result.add_issue(issue);
118        }
119        for issue in self.check_class_components(source, &file_str) {
120            result.add_issue(issue);
121        }
122
123        Ok(result)
124    }
125
126    fn validate_raw(&self, source: &str, file: &Path) -> Result<ValidationResult> {
127        let parsed = ParsedCode::Generic(source.to_string());
128        self.validate_parsed(&parsed, file)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn test_react_validator_valid() {
138        let validator = ReactValidator::default_config();
139        let code = r#"
140            function Button({ children }) {
141                return <button>{children}</button>;
142            }
143        "#;
144
145        let result = validator.validate_raw(code, Path::new("Button.tsx")).unwrap();
146        assert!(result.passed);
147    }
148
149    #[test]
150    fn test_react_class_component() {
151        let validator = ReactValidator::default_config();
152        let code = r#"
153            class Button extends React.Component {
154                render() {
155                    return <button>Click</button>;
156                }
157            }
158        "#;
159
160        let result = validator.validate_raw(code, Path::new("Button.tsx")).unwrap();
161        // Warnings don't fail by default (only in strict mode)
162        assert!(result.warning_count() > 0, "Should detect class component");
163        assert_eq!(result.issues[0].code, "react-class-component");
164    }
165
166    #[test]
167    fn test_react_config_default() {
168        let config = ReactConfig::default();
169        assert!(config.enforce_hooks_rules);
170        assert_eq!(config.max_component_lines, 300);
171        assert!(config.prefer_function_components);
172    }
173
174    #[test]
175    fn test_react_component_size_warning() {
176        let validator = ReactValidator::default_config();
177        let large_component = "fn main() { }\n".repeat(301);
178
179        let result = validator.validate_raw(&large_component, Path::new("Large.tsx")).unwrap();
180        // Warnings don't fail by default (only in strict mode)
181        assert!(result.warning_count() > 0, "Should detect large component");
182        assert_eq!(result.issues[0].code, "react-component-size");
183    }
184
185    #[test]
186    fn test_react_custom_config() {
187        let config = ReactConfig {
188            max_component_lines: 50,
189            prefer_function_components: false,
190            ..Default::default()
191        };
192
193        let validator = ReactValidator::new(config);
194        let code = r#"
195            class Button extends React.Component {
196                render() {
197                    return <button>Click</button>;
198                }
199            }
200        "#;
201
202        let result = validator.validate_raw(code, Path::new("Button.tsx")).unwrap();
203        // Should pass when not enforcing function components
204        assert!(result.passed);
205    }
206
207    #[test]
208    fn test_react_arrow_function_component() {
209        let validator = ReactValidator::default_config();
210        let code = r#"
211            const Button = ({ children }) => {
212                return <button>{children}</button>;
213            };
214        "#;
215
216        let result = validator.validate_raw(code, Path::new("Button.tsx")).unwrap();
217        assert!(result.passed);
218    }
219
220    #[test]
221    fn test_react_validator_supports() {
222        let validator = ReactValidator::default_config();
223        assert!(validator.supports(Language::JavaScript));
224        assert!(validator.supports(Language::TypeScript));
225        assert!(validator.supports(Language::Jsx));
226        assert!(validator.supports(Language::Tsx));
227        assert!(!validator.supports(Language::Rust));
228    }
229
230    #[test]
231    fn test_react_multiple_class_components() {
232        let validator = ReactValidator::default_config();
233        let code = r#"
234            class Button extends React.Component {
235                render() { return <button>Click</button>; }
236            }
237            class Input extends React.Component {
238                render() { return <input />; }
239            }
240        "#;
241
242        let result = validator.validate_raw(code, Path::new("Components.tsx")).unwrap();
243        assert_eq!(result.issues.len(), 2);
244    }
245}