oparry_validators/
react.rs1use crate::Validator;
4use oparry_core::{Issue, IssueLevel, Result, ValidationResult};
5use oparry_parser::{ParsedCode, Language};
6use regex::Regex;
7use std::path::Path;
8
9#[derive(Debug, Clone)]
11pub struct ReactConfig {
12 pub enforce_hooks_rules: bool,
14 pub max_component_lines: usize,
16 pub require_prop_types: bool,
18 pub prefer_function_components: bool,
20 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
39pub struct ReactValidator {
41 config: ReactConfig,
42 class_component_regex: Regex,
43}
44
45impl ReactValidator {
46 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 pub fn default_config() -> Self {
56 Self::new(ReactConfig::default())
57 }
58
59 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 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 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 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 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 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}