1use 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
11const TAILWIND_CLASSES: &[&str] = &[
13 "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 "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 "bg-white", "bg-black", "bg-transparent",
26 "text-white", "text-black", "text-transparent",
27 "border-white", "border-black", "border-transparent",
28 "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 "border", "border-0", "border-2", "border-4",
34 "rounded", "rounded-none", "rounded-sm", "rounded-md", "rounded-lg", "rounded-xl", "rounded-full",
35 "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 "block", "inline-block", "hidden",
41 "relative", "absolute", "fixed", "sticky",
43 "shadow", "shadow-sm", "shadow-md", "shadow-lg", "shadow-xl", "shadow-none",
45 "opacity-0", "opacity-50", "opacity-100",
46];
47
48#[derive(Debug, Clone)]
50pub struct TailwindConfig {
51 pub safe_list: Vec<String>,
53 pub block_list: Vec<String>,
55 pub max_arbitrary: usize,
57 pub custom_classes: HashSet<String>,
59 pub blocked_max_widths: Vec<String>,
61 pub blocked_widths: Vec<String>,
63 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
123pub struct TailwindValidator {
125 config: TailwindConfig,
126 class_regex: Regex,
127 arbitrary_regex: Regex,
128}
129
130impl TailwindValidator {
131 pub fn new(config: TailwindConfig) -> Self {
133 Self {
134 config,
135 class_regex: Regex::new(r#"class(?:Name)?\s*=\s*["']([^"']+)["']"#).unwrap(),
137 arbitrary_regex: Regex::new(r"\[[^\]]+\]").unwrap(),
139 }
140 }
141
142 pub fn default_config() -> Self {
144 Self::new(TailwindConfig::default())
145 }
146
147 fn validate_class(&self, class: &str, file: &str, line: usize) -> Option<Issue> {
149 let class = class.trim();
150
151 if class.is_empty() {
153 return None;
154 }
155
156 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 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 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 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 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 for safe in &self.config.safe_list {
217 if self.matches_pattern(class, safe) {
218 return None; }
220 }
221
222 if TAILWIND_CLASSES.contains(&class) {
224 return None; }
226
227 if self.config.custom_classes.contains(class) {
229 return None; }
231
232 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 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 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 if content.contains("extend") {
269 }
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 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 assert!(result.warning_count() > 0, "Should detect invalid classes");
344 assert_eq!(result.issues.len(), 2); }
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 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 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 assert!(validator.validate_class("", "test.tsx", 0).is_none());
469
470 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 assert!(validator.validate_class("flex", "test.tsx", 0).is_none());
476 assert!(validator.validate_class("hidden", "test.tsx", 0).is_none());
477 }
478}