Skip to main content

oparry_validators/
components.rs

1//! Component usage validator (shadcn/ui, etc.)
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/// Component validation configuration
10#[derive(Debug, Clone)]
11pub struct ComponentConfig {
12    /// Enforce shadcn/ui usage
13    pub enforce_shadcn: bool,
14    /// shadcn/ui components path
15    pub shadcn_path: String,
16    /// Known shadcn/ui components
17    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
41/// Component validator
42pub struct ComponentValidator {
43    config: ComponentConfig,
44    jsx_element_regex: Regex,
45    import_regex: Regex,
46}
47
48impl ComponentValidator {
49    /// Create new component validator
50    pub fn new(config: ComponentConfig) -> Self {
51        Self {
52            config,
53            // Match JSX element names
54            jsx_element_regex: Regex::new(r"<([A-Z][a-zA-Z0-9]*)").unwrap(),
55            // Match imports
56            import_regex: Regex::new(r#"import\s+\{[^}]*\}\s+from\s+['"]([^'"]+)['"]"#).unwrap(),
57        }
58    }
59
60    /// Create with default config
61    pub fn default_config() -> Self {
62        Self::new(ComponentConfig::default())
63    }
64
65    /// Check if component is a known shadcn component
66    fn is_known_shadcn_component(&self, name: &str) -> bool {
67        self.config.known_components.contains(&name.to_string())
68    }
69
70    /// Validate component import
71    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        // Check if correct import exists
84        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        // Collect all imports
121        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        // Find all JSX components
131        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        // Warnings don't fail by default (only in strict mode)
183        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        // CustomWidget is not in known_components, so no error
239        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        // Warnings don't fail by default (only in strict mode)
284        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        // Should have 3 issues for missing imports
304        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        // When enforce_shadcn is false, we still validate (config not used in validation logic yet)
321        let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
322        // Warnings don't fail by default (only in strict mode)
323        assert!(result.warning_count() > 0, "Should still detect missing import");
324    }
325}