Skip to main content

oparry_validators/
tailwind.rs

1//! Tailwind CSS class validator
2
3use crate::Validator;
4use oparry_core::{Issue, IssueLevel, Result, ValidationResult};
5use oparry_parser::{ParsedCode, Language};
6use regex::Regex;
7use std::collections::{HashSet, HashMap};
8use std::path::Path;
9use std::fs;
10
11/// Known Tailwind utility classes (subset for MVP)
12const TAILWIND_CLASSES: &[&str] = &[
13    // Spacing
14    "p-0", "p-1", "p-2", "p-3", "p-4", "p-5", "p-6", "p-8", "p-10", "p-12",
15    "px-0", "px-1", "px-2", "px-3", "px-4", "px-5", "px-6", "px-8",
16    "py-0", "py-1", "py-2", "py-3", "py-4", "py-5", "py-6", "py-8",
17    "m-0", "m-1", "m-2", "m-3", "m-4", "m-auto", "mx-auto", "my-auto",
18    // Layout
19    "flex", "inline-flex", "grid", "inline-grid",
20    "flex-row", "flex-col", "flex-row-reverse", "flex-col-reverse",
21    "justify-start", "justify-end", "justify-center", "justify-between", "justify-around",
22    "items-start", "items-end", "items-center", "items-stretch",
23    "gap-1", "gap-2", "gap-3", "gap-4", "gap-6", "gap-8",
24    // Colors (background, text, border)
25    "bg-white", "bg-black", "bg-transparent",
26    "text-white", "text-black", "text-transparent",
27    "border-white", "border-black", "border-transparent",
28    // Typography
29    "text-xs", "text-sm", "text-base", "text-lg", "text-xl", "text-2xl", "text-3xl",
30    "font-light", "font-normal", "font-medium", "font-semibold", "font-bold",
31    "text-left", "text-center", "text-right",
32    // Borders
33    "border", "border-0", "border-2", "border-4",
34    "rounded", "rounded-none", "rounded-sm", "rounded-md", "rounded-lg", "rounded-xl", "rounded-full",
35    // Sizing
36    "w-full", "w-auto", "w-fit", "w-screen", "w-1/2", "w-1/3", "w-2/3",
37    "h-full", "h-auto", "h-fit", "h-screen",
38    "max-w-full", "max-w-md", "max-w-lg", "max-w-xl", "max-w-2xl", "max-w-4xl", "max-w-6xl",
39    // Display
40    "block", "inline-block", "hidden",
41    // Position
42    "relative", "absolute", "fixed", "sticky",
43    // Effects
44    "shadow", "shadow-sm", "shadow-md", "shadow-lg", "shadow-xl", "shadow-none",
45    "opacity-0", "opacity-50", "opacity-100",
46];
47
48/// Tailwind validator configuration
49#[derive(Debug, Clone)]
50pub struct TailwindConfig {
51    /// Safe list patterns (e.g., "p-*", "m-*")
52    pub safe_list: Vec<String>,
53    /// Block list patterns
54    pub block_list: Vec<String>,
55    /// Maximum arbitrary values
56    pub max_arbitrary: usize,
57    /// Custom classes from tailwind.config.ts
58    pub custom_classes: HashSet<String>,
59    /// Maximum width classes (blocked for consistency)
60    pub blocked_max_widths: Vec<String>,
61    /// Width classes (blocked for consistency)
62    pub blocked_widths: Vec<String>,
63    /// Enforce spacing scale
64    pub enforce_spacing_scale: bool,
65}
66
67impl Default for TailwindConfig {
68    fn default() -> Self {
69        Self {
70            safe_list: vec![
71                "p-*".to_string(),
72                "m-*".to_string(),
73                "w-*".to_string(),
74                "h-*".to_string(),
75                "text-*".to_string(),
76                "bg-*".to_string(),
77            ],
78            block_list: vec![
79                "bg-red-500".to_string(),
80                "bg-yellow-500".to_string(),
81                "w-xl".to_string(),
82                "w-2xl".to_string(),
83                "w-3xl".to_string(),
84                "max-w-xl".to_string(),
85                "max-w-2xl".to_string(),
86                "max-w-3xl".to_string(),
87                "max-w-4xl".to_string(),
88                "max-w-5xl".to_string(),
89                "max-w-6xl".to_string(),
90                "max-w-7xl".to_string(),
91            ],
92            max_arbitrary: 5,
93            custom_classes: HashSet::new(),
94            blocked_max_widths: vec![
95                "max-w-sm".to_string(),
96                "max-w-md".to_string(),
97                "max-w-lg".to_string(),
98                "max-w-xl".to_string(),
99                "max-w-2xl".to_string(),
100                "max-w-3xl".to_string(),
101                "max-w-4xl".to_string(),
102                "max-w-5xl".to_string(),
103                "max-w-6xl".to_string(),
104                "max-w-7xl".to_string(),
105            ],
106            blocked_widths: vec![
107                "w-sm".to_string(),
108                "w-md".to_string(),
109                "w-lg".to_string(),
110                "w-xl".to_string(),
111                "w-2xl".to_string(),
112                "w-3xl".to_string(),
113                "w-4xl".to_string(),
114                "w-5xl".to_string(),
115                "w-6xl".to_string(),
116                "w-7xl".to_string(),
117            ],
118            enforce_spacing_scale: true,
119        }
120    }
121}
122
123/// Tailwind CSS validator
124pub struct TailwindValidator {
125    config: TailwindConfig,
126    class_regex: Regex,
127    arbitrary_regex: Regex,
128}
129
130impl TailwindValidator {
131    /// Create new Tailwind validator
132    pub fn new(config: TailwindConfig) -> Self {
133        Self {
134            config,
135            // Match className="..." or class="..."
136            class_regex: Regex::new(r#"class(?:Name)?\s*=\s*["']([^"']+)["']"#).unwrap(),
137            // Match arbitrary values like [color] or [size:...]
138            arbitrary_regex: Regex::new(r"\[[^\]]+\]").unwrap(),
139        }
140    }
141
142    /// Create with default config
143    pub fn default_config() -> Self {
144        Self::new(TailwindConfig::default())
145    }
146
147    /// Validate a single class name
148    fn validate_class(&self, class: &str, file: &str, line: usize) -> Option<Issue> {
149        let class = class.trim();
150
151        // Empty class - no validation needed
152        if class.is_empty() {
153            return None;
154        }
155
156        // Check for variants (hover:, focus:, etc.) - process first
157        if class.contains(':') {
158            let parts: Vec<&str> = class.split(':').collect();
159            if parts.len() == 2 {
160                return self.validate_class(parts[1], file, line);
161            }
162        }
163
164        // Check for arbitrary values - check BEFORE safe list
165        // Arbitrary values should always be warned even if they match safe patterns
166        if self.arbitrary_regex.is_match(class) {
167            return Some(Issue::warning(
168                "tailwind-arbitrary-value",
169                format!("Arbitrary value '{}' may indicate design inconsistency", class),
170            )
171            .with_file(file)
172            .with_line(line)
173            .with_suggestion("Define a custom class in tailwind.config.ts"));
174        }
175
176        // Check blocked widths (w-xl, w-2xl, etc.)
177        for blocked in &self.config.blocked_widths {
178            if class == blocked || class.starts_with(&format!("{}:", blocked)) {
179                return Some(Issue::error(
180                    "tailwind-blocked-width",
181                    format!("Width class '{}' is not allowed - use container or component", class),
182                )
183                .with_file(file)
184                .with_line(line)
185                .with_suggestion("Use a container class or define custom width in tailwind.config.ts"));
186            }
187        }
188
189        // Check blocked max-widths (max-w-xl, max-w-2xl, etc.)
190        for blocked in &self.config.blocked_max_widths {
191            if class == blocked || class.starts_with(&format!("{}:", blocked)) {
192                return Some(Issue::error(
193                    "tailwind-blocked-max-width",
194                    format!("Max-width class '{}' is not allowed - use container", class),
195                )
196                .with_file(file)
197                .with_line(line)
198                .with_suggestion("Use Container component or define custom max-width"));
199            }
200        }
201
202        // Check block list
203        for blocked in &self.config.block_list {
204            if self.matches_pattern(class, blocked) {
205                return Some(Issue::error(
206                    "tailwind-blocked-class",
207                    format!("Class '{}' is blocked by configuration", class),
208                )
209                .with_file(file)
210                .with_line(line)
211                .with_suggestion("Remove this class or update block_list"));
212            }
213        }
214
215        // Check safe list
216        for safe in &self.config.safe_list {
217            if self.matches_pattern(class, safe) {
218                return None; // Allowed
219            }
220        }
221
222        // Check if it's a known Tailwind class
223        if TAILWIND_CLASSES.contains(&class) {
224            return None; // Valid
225        }
226
227        // Check custom classes
228        if self.config.custom_classes.contains(class) {
229            return None; // Valid custom class
230        }
231
232        // Unknown class
233        Some(Issue::warning(
234            "tailwind-unknown-class",
235            format!("Unknown Tailwind class '{}'", class),
236        )
237        .with_file(file)
238        .with_line(line)
239        .with_suggestion("Check tailwind.config.ts or add to safe_list"))
240    }
241
242    /// Match class against pattern (supports * wildcard)
243    fn matches_pattern(&self, class: &str, pattern: &str) -> bool {
244        if pattern.ends_with('*') {
245            let prefix = &pattern[..pattern.len() - 1];
246            class.starts_with(prefix)
247        } else if pattern.starts_with('*') {
248            let suffix = &pattern[1..];
249            class.ends_with(suffix)
250        } else {
251            class == pattern
252        }
253    }
254
255    /// Parse tailwind.config.ts for custom classes (simplified)
256    fn load_custom_classes(&mut self, config_path: &Path) -> Result<()> {
257        if !config_path.exists() {
258            return Ok(());
259        }
260
261        let content = fs::read_to_string(config_path)
262            .map_err(|e| oparry_core::Error::File {
263                path: config_path.to_path_buf(),
264                source: e,
265            })?;
266
267        // Very basic parsing - in production, would use proper TS parser
268        if content.contains("extend") {
269            // This is where custom classes would be defined
270            // For MVP, we'll just note that customization exists
271        }
272
273        Ok(())
274    }
275}
276
277impl Validator for TailwindValidator {
278    fn name(&self) -> &str {
279        "Tailwind"
280    }
281
282    fn supports(&self, language: Language) -> bool {
283        language.is_javascript_variant()
284    }
285
286    fn validate_parsed(&self, code: &ParsedCode, file: &Path) -> Result<ValidationResult> {
287        let mut result = ValidationResult::new();
288        let source = code.source();
289
290        let file_str = file.to_string_lossy().to_string();
291
292        // Find all className attributes
293        for (line_idx, line) in source.lines().enumerate() {
294            if let Some(caps) = self.class_regex.captures(line) {
295                if let Some(classes_str) = caps.get(1) {
296                    let classes = classes_str.as_str().split_whitespace();
297                    for class in classes {
298                        if let Some(issue) = self.validate_class(class, &file_str, line_idx) {
299                            result.add_issue(issue);
300                        }
301                    }
302                }
303            }
304        }
305
306        Ok(result)
307    }
308
309    fn validate_raw(&self, source: &str, file: &Path) -> Result<ValidationResult> {
310        let parsed = ParsedCode::Generic(source.to_string());
311        self.validate_parsed(&parsed, file)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn test_tailwind_validator_valid() {
321        let validator = TailwindValidator::default_config();
322        let code = r#"
323            <div className="flex items-center gap-4 p-4">
324                <button className="px-4 py-2 bg-white rounded">Click</button>
325            </div>
326        "#;
327
328        let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
329        assert!(result.passed);
330    }
331
332    #[test]
333    fn test_tailwind_validator_invalid() {
334        let validator = TailwindValidator::default_config();
335        let code = r#"
336            <div className="flex invalid-class">
337                <button className="bg-red-500">Click</button>
338            </div>
339        "#;
340
341        let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
342        // Warnings don't fail by default (only in strict mode)
343        assert!(result.warning_count() > 0, "Should detect invalid classes");
344        assert_eq!(result.issues.len(), 2); // invalid-class + bg-red-500
345    }
346
347    #[test]
348    fn test_pattern_matching() {
349        let validator = TailwindValidator::default_config();
350        assert!(validator.matches_pattern("p-4", "p-*"));
351        assert!(validator.matches_pattern("text-xl", "text-*"));
352        assert!(!validator.matches_pattern("bg-red-500", "p-*"));
353    }
354
355    #[test]
356    fn test_blocked_width_classes() {
357        let validator = TailwindValidator::default_config();
358        let code = r#"<div className="w-xl"></div>"#;
359
360        let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
361        assert!(!result.passed);
362        assert_eq!(result.issues[0].code, "tailwind-blocked-width");
363    }
364
365    #[test]
366    fn test_blocked_max_width_classes() {
367        let validator = TailwindValidator::default_config();
368        let code = r#"<div className="max-w-md"></div>"#;
369
370        let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
371        assert!(!result.passed);
372        assert_eq!(result.issues[0].code, "tailwind-blocked-max-width");
373    }
374
375    #[test]
376    fn test_class_regex() {
377        let validator = TailwindValidator::default_config();
378        let line = r#"<div className="w-[123px]"></div>"#;
379        assert!(validator.class_regex.is_match(line));
380        if let Some(caps) = validator.class_regex.captures(line) {
381            assert_eq!(caps.get(1).map(|m| m.as_str()), Some("w-[123px]"));
382        }
383    }
384
385    #[test]
386    fn test_arbitrary_values() {
387        let validator = TailwindValidator::default_config();
388        let code = r#"<div className="w-[123px]"></div>"#;
389
390        let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
391        println!("Issues: {:?}", result.issues);
392        println!("Warning count: {}", result.warning_count());
393        // Warnings don't fail by default (only in strict mode)
394        assert!(result.warning_count() > 0, "Should detect arbitrary value");
395        assert_eq!(result.issues[0].code, "tailwind-arbitrary-value");
396    }
397
398    #[test]
399    fn test_variant_classes() {
400        let validator = TailwindValidator::default_config();
401        let code = r#"<div className="hover:bg-white"></div>"#;
402
403        let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
404        // Should pass - hover: prefix is valid
405        assert!(result.passed);
406    }
407
408    #[test]
409    fn test_custom_classes() {
410        let mut config = TailwindConfig::default();
411        config.custom_classes = vec!["my-custom-class".to_string()].into_iter().collect();
412        let validator = TailwindValidator::new(config);
413        let code = r#"<div className="my-custom-class"></div>"#;
414
415        let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
416        assert!(result.passed);
417    }
418
419    #[test]
420    fn test_multiple_class_attributes() {
421        let validator = TailwindValidator::default_config();
422        let code = r#"
423            <div className="flex">
424                <div className="invalid-1"></div>
425                <div className="invalid-2"></div>
426            </div>
427        "#;
428
429        let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
430        assert_eq!(result.issues.len(), 2);
431    }
432
433    #[test]
434    fn test_class_attribute_vs_classname() {
435        let validator = TailwindValidator::default_config();
436        let code = r#"
437            <div class="flex items-center"></div>
438            <div className="flex items-center"></div>
439        "#;
440
441        let result = validator.validate_raw(code, Path::new("test.tsx")).unwrap();
442        assert!(result.passed);
443    }
444
445    #[test]
446    fn test_tailwind_config_default() {
447        let config = TailwindConfig::default();
448        assert!(config.enforce_spacing_scale);
449        assert_eq!(config.max_arbitrary, 5);
450        assert!(!config.block_list.is_empty());
451    }
452
453    #[test]
454    fn test_validator_supports() {
455        let validator = TailwindValidator::default_config();
456        assert!(validator.supports(Language::JavaScript));
457        assert!(validator.supports(Language::TypeScript));
458        assert!(validator.supports(Language::Jsx));
459        assert!(validator.supports(Language::Tsx));
460        assert!(!validator.supports(Language::Rust));
461    }
462
463    #[test]
464    fn test_validate_class_edge_cases() {
465        let validator = TailwindValidator::default_config();
466
467        // Empty class
468        assert!(validator.validate_class("", "test.tsx", 0).is_none());
469
470        // Valid spacing classes
471        assert!(validator.validate_class("p-0", "test.tsx", 0).is_none());
472        assert!(validator.validate_class("m-auto", "test.tsx", 0).is_none());
473
474        // Valid display classes
475        assert!(validator.validate_class("flex", "test.tsx", 0).is_none());
476        assert!(validator.validate_class("hidden", "test.tsx", 0).is_none());
477    }
478}