oparry_validators/
components.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 ComponentConfig {
12 pub enforce_shadcn: bool,
14 pub shadcn_path: String,
16 pub known_components: Vec<String>,
18}
19
20impl Default for ComponentConfig {
21 fn default() -> Self {
22 Self {
23 enforce_shadcn: true,
24 shadcn_path: "@/components/ui".to_string(),
25 known_components: vec![
26 "Button".to_string(),
27 "Card".to_string(),
28 "Input".to_string(),
29 "Label".to_string(),
30 "Select".to_string(),
31 "Checkbox".to_string(),
32 "Dialog".to_string(),
33 "DropdownMenu".to_string(),
34 "Toast".to_string(),
35 "Tabs".to_string(),
36 ],
37 }
38 }
39}
40
41pub struct ComponentValidator {
43 config: ComponentConfig,
44 jsx_element_regex: Regex,
45 import_regex: Regex,
46}
47
48impl ComponentValidator {
49 pub fn new(config: ComponentConfig) -> Self {
51 Self {
52 config,
53 jsx_element_regex: Regex::new(r"<([A-Z][a-zA-Z0-9]*)").unwrap(),
55 import_regex: Regex::new(r#"import\s+\{[^}]*\}\s+from\s+['"]([^'"]+)['"]"#).unwrap(),
57 }
58 }
59
60 pub fn default_config() -> Self {
62 Self::new(ComponentConfig::default())
63 }
64
65 fn is_known_shadcn_component(&self, name: &str) -> bool {
67 self.config.known_components.contains(&name.to_string())
68 }
69
70 fn validate_component_import(
72 &self,
73 component: &str,
74 imports: &[String],
75 file: &str,
76 ) -> Option<Issue> {
77 if !self.is_known_shadcn_component(component) {
78 return None;
79 }
80
81 let expected_import = format!("{}/{}", self.config.shadcn_path, component.to_lowercase());
82
83 let has_correct_import = imports.iter().any(|imp| {
85 imp.contains(&component.to_lowercase())
86 && imp.contains(&self.config.shadcn_path)
87 });
88
89 if !has_correct_import {
90 return Some(Issue::warning(
91 "component-shadcn-import",
92 format!("Component '{}' should be imported from shadcn/ui", component),
93 )
94 .with_file(file)
95 .with_suggestion(&format!(
96 "import {{ {} }} from '{}'",
97 component, expected_import
98 )));
99 }
100
101 None
102 }
103}
104
105impl Validator for ComponentValidator {
106 fn name(&self) -> &str {
107 "Components"
108 }
109
110 fn supports(&self, language: Language) -> bool {
111 language.is_javascript_variant()
112 }
113
114 fn validate_parsed(&self, code: &ParsedCode, file: &Path) -> Result<ValidationResult> {
115 let mut result = ValidationResult::new();
116 let source = code.source();
117
118 let file_str = file.to_string_lossy().to_string();
119
120 let mut imports = Vec::new();
122 for line in source.lines() {
123 if let Some(caps) = self.import_regex.captures(line) {
124 if let Some(path) = caps.get(1) {
125 imports.push(path.as_str().to_string());
126 }
127 }
128 }
129
130 for (line_idx, line) in source.lines().enumerate() {
132 for caps in self.jsx_element_regex.captures_iter(line) {
133 if let Some(component) = caps.get(1) {
134 let component_name = component.as_str();
135 if let Some(issue) =
136 self.validate_component_import(component_name, &imports, &file_str)
137 {
138 result.add_issue(issue.with_line(line_idx));
139 }
140 }
141 }
142 }
143
144 Ok(result)
145 }
146
147 fn validate_raw(&self, source: &str, file: &Path) -> Result<ValidationResult> {
148 let parsed = ParsedCode::Generic(source.to_string());
149 self.validate_parsed(&parsed, file)
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn test_component_validator_valid() {
159 let validator = ComponentValidator::default_config();
160 let code = r#"
161 import { Button } from '@/components/ui/button';
162
163 export function Form() {
164 return <Button>Submit</Button>;
165 }
166 "#;
167
168 let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
169 assert!(result.passed);
170 }
171
172 #[test]
173 fn test_component_validator_missing_import() {
174 let validator = ComponentValidator::default_config();
175 let code = r#"
176 export function Form() {
177 return <Button>Submit</Button>;
178 }
179 "#;
180
181 let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
182 assert!(result.warning_count() > 0, "Should detect missing shadcn import");
184 assert_eq!(result.issues[0].code, "component-shadcn-import");
185 }
186
187 #[test]
188 fn test_component_config_default() {
189 let config = ComponentConfig::default();
190 assert!(config.enforce_shadcn);
191 assert_eq!(config.shadcn_path, "@/components/ui");
192 assert!(!config.known_components.is_empty());
193 }
194
195 #[test]
196 fn test_component_known_components() {
197 let validator = ComponentValidator::default_config();
198
199 assert!(validator.is_known_shadcn_component("Button"));
200 assert!(validator.is_known_shadcn_component("Card"));
201 assert!(validator.is_known_shadcn_component("Input"));
202 assert!(validator.is_known_shadcn_component("Dialog"));
203
204 assert!(!validator.is_known_shadcn_component("MyCustomComponent"));
205 assert!(!validator.is_known_shadcn_component("div"));
206 }
207
208 #[test]
209 fn test_component_multiple_imports() {
210 let validator = ComponentValidator::default_config();
211 let code = r#"
212 import { Button } from '@/components/ui/button';
213 import { Card } from '@/components/ui/card';
214
215 export function Form() {
216 return (
217 <Card>
218 <Button>Submit</Button>
219 </Card>
220 );
221 }
222 "#;
223
224 let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
225 assert!(result.passed);
226 }
227
228 #[test]
229 fn test_component_non_shadcn_component() {
230 let validator = ComponentValidator::default_config();
231 let code = r#"
232 export function Form() {
233 return <CustomWidget>Submit</CustomWidget>;
234 }
235 "#;
236
237 let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
238 assert!(result.passed);
240 }
241
242 #[test]
243 fn test_component_validator_supports() {
244 let validator = ComponentValidator::default_config();
245 assert!(validator.supports(Language::JavaScript));
246 assert!(validator.supports(Language::TypeScript));
247 assert!(validator.supports(Language::Jsx));
248 assert!(validator.supports(Language::Tsx));
249 assert!(!validator.supports(Language::Rust));
250 }
251
252 #[test]
253 fn test_component_custom_shadcn_path() {
254 let config = ComponentConfig {
255 shadcn_path: "@ui/components".to_string(),
256 ..Default::default()
257 };
258 let validator = ComponentValidator::new(config);
259 let code = r#"
260 import { Button } from '@ui/components/button';
261
262 export function Form() {
263 return <Button>Submit</Button>;
264 }
265 "#;
266
267 let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
268 assert!(result.passed);
269 }
270
271 #[test]
272 fn test_component_wrong_import_path() {
273 let validator = ComponentValidator::default_config();
274 let code = r#"
275 import { Button } from './Button';
276
277 export function Form() {
278 return <Button>Submit</Button>;
279 }
280 "#;
281
282 let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
283 assert!(result.warning_count() > 0, "Should detect wrong import path");
285 }
286
287 #[test]
288 fn test_component_multiple_known_components() {
289 let validator = ComponentValidator::default_config();
290 let code = r#"
291 export function Form() {
292 return (
293 <>
294 <Button>Submit</Button>
295 <Input />
296 <Label>Password</Label>
297 </>
298 );
299 }
300 "#;
301
302 let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
303 assert_eq!(result.issues.len(), 3);
305 }
306
307 #[test]
308 fn test_component_config_not_enforcing() {
309 let config = ComponentConfig {
310 enforce_shadcn: false,
311 ..Default::default()
312 };
313 let validator = ComponentValidator::new(config);
314 let code = r#"
315 export function Form() {
316 return <Button>Submit</Button>;
317 }
318 "#;
319
320 let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
322 assert!(result.warning_count() > 0, "Should still detect missing import");
324 }
325}