1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4use std::collections::HashSet;
5
6pub struct TailwindDarkModeRule {
16 id: String,
17 severity: Severity,
18 message: String,
19 suggest: Option<String>,
20 glob: Option<String>,
21 allowed: HashSet<String>,
23 class_attr_re: Regex,
25 color_utility_re: Regex,
27 cn_fn_re: Regex,
29 cn_str_re: Regex,
31}
32
33const COLOR_PREFIXES: &[&str] = &[
35 "bg-", "text-", "border-", "ring-", "outline-", "shadow-",
36 "divide-", "accent-", "caret-", "fill-", "stroke-",
37 "decoration-", "placeholder-",
38 "from-", "via-", "to-",
40];
41
42const TAILWIND_COLORS: &[&str] = &[
44 "slate", "gray", "zinc", "neutral", "stone",
45 "red", "orange", "amber", "yellow", "lime",
46 "green", "emerald", "teal", "cyan", "sky",
47 "blue", "indigo", "violet", "purple", "fuchsia",
48 "pink", "rose",
49 "white", "black",
51];
52
53const SEMANTIC_TOKEN_SUFFIXES: &[&str] = &[
56 "background", "foreground",
57 "card", "card-foreground",
58 "popover", "popover-foreground",
59 "primary", "primary-foreground",
60 "secondary", "secondary-foreground",
61 "muted", "muted-foreground",
62 "accent", "accent-foreground",
63 "destructive", "destructive-foreground",
64 "border", "input", "ring",
65 "chart-1", "chart-2", "chart-3", "chart-4", "chart-5",
66 "sidebar-background", "sidebar-foreground",
67 "sidebar-primary", "sidebar-primary-foreground",
68 "sidebar-accent", "sidebar-accent-foreground",
69 "sidebar-border", "sidebar-ring",
70];
71
72const ALWAYS_ALLOWED_SUFFIXES: &[&str] = &[
74 "transparent", "current", "inherit", "auto",
75];
76
77impl TailwindDarkModeRule {
78 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
79 let mut allowed = HashSet::new();
80
81 for prefix in COLOR_PREFIXES {
83 for suffix in SEMANTIC_TOKEN_SUFFIXES {
84 allowed.insert(format!("{}{}", prefix, suffix));
85 }
86 for suffix in ALWAYS_ALLOWED_SUFFIXES {
87 allowed.insert(format!("{}{}", prefix, suffix));
88 }
89 }
90
91 for cls in &config.allowed_classes {
93 allowed.insert(cls.clone());
94 }
95
96 let class_attr_re = Regex::new(
100 r#"(?:className|class)\s*=\s*(?:"([^"]*?)"|'([^']*?)'|\{[^}]*?(?:`([^`]*?)`|"([^"]*?)"|'([^']*?)'))"#,
101 ).map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
102
103 let prefix_group = COLOR_PREFIXES.iter()
107 .map(|p| regex::escape(p.trim_end_matches('-')))
108 .collect::<Vec<_>>()
109 .join("|");
110 let color_group = TAILWIND_COLORS.join("|");
111
112 let color_re_str = format!(
113 r"\b({})-({})(?:-(\d{{2,3}}))?(?:/\d+)?\b",
114 prefix_group, color_group
115 );
116 let color_utility_re = Regex::new(&color_re_str)
117 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
118
119 let cn_fn_re = Regex::new(r#"(?:cn|clsx|classNames|cva|twMerge)\s*\("#)
120 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
121 let cn_str_re = Regex::new(r#"['"`]([^'"`]+?)['"`]"#)
122 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
123
124 let default_glob = "**/*.{tsx,jsx,html}".to_string();
125
126 Ok(Self {
127 id: config.id.clone(),
128 severity: config.severity,
129 message: config.message.clone(),
130 suggest: config.suggest.clone(),
131 glob: config.glob.clone().or(Some(default_glob)),
132 allowed,
133 class_attr_re,
134 color_utility_re,
135 cn_fn_re,
136 cn_str_re,
137 })
138 }
139
140 fn extract_class_strings<'a>(&self, line: &'a str) -> Vec<&'a str> {
142 let mut results = Vec::new();
143 for cap in self.class_attr_re.captures_iter(line) {
144 for i in 1..=5 {
146 if let Some(m) = cap.get(i) {
147 results.push(m.as_str());
148 }
149 }
150 }
151 results
152 }
153
154 fn find_missing_dark_variants(&self, class_string: &str) -> Vec<(String, Option<String>)> {
156 let classes: Vec<&str> = class_string.split_whitespace().collect();
157
158 let dark_classes: HashSet<String> = classes.iter()
160 .filter(|c| c.starts_with("dark:"))
161 .map(|c| c.strip_prefix("dark:").unwrap().to_string())
162 .collect();
163
164 let mut violations = Vec::new();
165
166 for class in &classes {
167 if class.starts_with("dark:") || class.starts_with("hover:") || class.starts_with("focus:") {
169 continue;
170 }
171
172 if !self.color_utility_re.is_match(class) {
174 continue;
175 }
176
177 if self.allowed.contains(*class) {
179 continue;
180 }
181
182 let prefix = class.split('-').next().unwrap_or("");
185 let has_dark = dark_classes.iter().any(|dc| dc.starts_with(prefix));
186
187 if !has_dark {
188 let suggestion = suggest_semantic_token(class);
189 violations.push((class.to_string(), suggestion));
190 }
191 }
192
193 violations
194 }
195}
196
197fn suggest_semantic_token(class: &str) -> Option<String> {
199 let parts: Vec<&str> = class.splitn(2, '-').collect();
201 if parts.len() < 2 {
202 return None;
203 }
204 let prefix = parts[0]; let color_part = parts[1]; let token = match color_part {
208 "white" => match prefix {
209 "bg" => Some("bg-background"),
210 "text" => Some("text-foreground"),
211 _ => None,
212 },
213 "black" => match prefix {
214 "bg" => Some("bg-foreground"),
215 "text" => Some("text-background"),
216 _ => None,
217 },
218 s if s.starts_with("gray") || s.starts_with("slate") || s.starts_with("zinc") || s.starts_with("neutral") => {
219 let shade: Option<u32> = s.split('-').nth(1).and_then(|n| n.parse().ok());
221 match (prefix, shade) {
222 ("bg", Some(50..=200)) => Some("bg-muted"),
223 ("bg", Some(800..=950)) => Some("bg-background (in dark theme)"),
224 ("text", Some(400..=600)) => Some("text-muted-foreground"),
225 ("text", Some(700..=950)) => Some("text-foreground"),
226 ("border", _) => Some("border-border"),
227 _ => None,
228 }
229 },
230 _ => None,
231 };
232
233 token.map(|t| format!("Use '{}' instead — it adapts to light/dark automatically", t))
234}
235
236impl Rule for TailwindDarkModeRule {
237 fn id(&self) -> &str {
238 &self.id
239 }
240
241 fn severity(&self) -> Severity {
242 self.severity
243 }
244
245 fn file_glob(&self) -> Option<&str> {
246 self.glob.as_deref()
247 }
248
249 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
250 let mut violations = Vec::new();
251
252 for (line_num, line) in ctx.content.lines().enumerate() {
253 let class_strings = self.extract_class_strings(line);
254
255 let extra_strings = self.extract_cn_strings(line);
259
260 for class_str in class_strings.iter().copied().chain(extra_strings.iter().map(|s| s.as_str())) {
261 let missing = self.find_missing_dark_variants(class_str);
262
263 for (class, token_suggestion) in missing {
264 let msg = if self.message.is_empty() {
265 format!(
266 "Class '{}' sets a color without a dark: variant",
267 class
268 )
269 } else {
270 format!("{}: '{}'", self.message, class)
271 };
272
273 let suggest = token_suggestion
274 .or_else(|| self.suggest.clone())
275 .or_else(|| Some(format!(
276 "Add 'dark:{}' or replace with a semantic token class",
277 suggest_dark_counterpart(&class)
278 )));
279
280 violations.push(Violation {
281 rule_id: self.id.clone(),
282 severity: self.severity,
283 file: ctx.file_path.to_path_buf(),
284 line: Some(line_num + 1),
285 column: line.find(&class).map(|c| c + 1),
286 message: msg,
287 suggest,
288 source_line: Some(line.to_string()),
289 fix: None,
290 });
291 }
292 }
293 }
294
295 violations
296 }
297}
298
299impl TailwindDarkModeRule {
300 fn extract_cn_strings(&self, line: &str) -> Vec<String> {
302 let mut results = Vec::new();
303
304 if let Some(fn_match) = self.cn_fn_re.find(line) {
305 let remainder = &line[fn_match.end()..];
306 for cap in self.cn_str_re.captures_iter(remainder) {
307 if let Some(m) = cap.get(1) {
308 let s = m.as_str();
309 if s.contains('-') || s.contains(' ') {
311 results.push(s.to_string());
312 }
313 }
314 }
315 }
316
317 results
318 }
319}
320
321fn suggest_dark_counterpart(class: &str) -> String {
323 let parts: Vec<&str> = class.splitn(2, '-').collect();
324 if parts.len() < 2 {
325 return class.to_string();
326 }
327
328 let prefix = parts[0];
329 let color_part = parts[1];
330
331 match color_part {
333 "white" => format!("{}-slate-950", prefix),
334 "black" => format!("{}-white", prefix),
335 s => {
336 let color_parts: Vec<&str> = s.rsplitn(2, '-').collect();
338 if color_parts.len() == 2 {
339 if let Ok(shade) = color_parts[0].parse::<u32>() {
340 let inverted = match shade {
341 50 => 950, 100 => 900, 200 => 800, 300 => 700,
342 400 => 600, 500 => 500, 600 => 400, 700 => 300,
343 800 => 200, 900 => 100, 950 => 50,
344 _ => shade,
345 };
346 return format!("{}-{}-{}", prefix, color_parts[1], inverted);
347 }
348 }
349 format!("{}-{}", prefix, s)
350 }
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::config::{RuleConfig, Severity};
358 use crate::rules::{Rule, ScanContext};
359 use std::path::Path;
360
361 fn make_rule() -> TailwindDarkModeRule {
362 let config = RuleConfig {
363 id: "tailwind-dark-mode".into(),
364 severity: Severity::Warning,
365 message: String::new(),
366 ..Default::default()
367 };
368 TailwindDarkModeRule::new(&config).unwrap()
369 }
370
371 fn check(rule: &TailwindDarkModeRule, content: &str) -> Vec<Violation> {
372 let ctx = ScanContext {
373 file_path: Path::new("test.tsx"),
374 content,
375 };
376 rule.check_file(&ctx)
377 }
378
379 #[test]
382 fn bad_card_flags_hardcoded_bg_white() {
383 let rule = make_rule();
384 let line = r#" <div className="bg-white border border-gray-200 rounded-lg shadow-sm p-6">"#;
385 let violations = check(&rule, line);
386 assert!(!violations.is_empty(), "bg-white without dark: should be flagged");
387 assert!(violations.iter().any(|v| v.message.contains("bg-white")));
388 }
389
390 #[test]
391 fn bad_card_flags_hardcoded_text_colors() {
392 let rule = make_rule();
393 let line = r#" <h3 className="text-gray-900 font-semibold text-lg">{name}</h3>"#;
394 let violations = check(&rule, line);
395 assert!(violations.iter().any(|v| v.message.contains("text-gray-900")));
396 }
397
398 #[test]
399 fn bad_card_flags_muted_text() {
400 let rule = make_rule();
401 let line = r#" <p className="text-gray-500 text-sm">{email}</p>"#;
402 let violations = check(&rule, line);
403 assert!(violations.iter().any(|v| v.message.contains("text-gray-500")));
404 }
405
406 #[test]
407 fn bad_card_flags_border_color() {
408 let rule = make_rule();
409 let line = r#" <div className="mt-4 pt-4 border-t border-gray-200">"#;
410 let violations = check(&rule, line);
411 assert!(violations.iter().any(|v| v.message.contains("border-gray-200")));
412 }
413
414 #[test]
415 fn bad_card_flags_button_bg() {
416 let rule = make_rule();
417 let line = r#" <button className="bg-slate-900 text-white px-4 py-2 rounded-md hover:bg-slate-800">"#;
418 let violations = check(&rule, line);
419 assert!(violations.iter().any(|v| v.message.contains("bg-slate-900")));
420 }
421
422 #[test]
423 fn bad_card_flags_destructive_colors() {
424 let rule = make_rule();
425 let line = r#" <div className="bg-red-500 text-white p-4 rounded-md border border-red-600">"#;
426 let violations = check(&rule, line);
427 assert!(violations.iter().any(|v| v.message.contains("bg-red-500")));
428 }
429
430 #[test]
433 fn good_card_semantic_bg_muted_passes() {
434 let rule = make_rule();
435 let line = r#" <div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">"#;
436 let violations = check(&rule, line);
437 assert!(violations.is_empty(), "bg-muted is a semantic token and should pass");
438 }
439
440 #[test]
441 fn good_card_semantic_text_muted_foreground_passes() {
442 let rule = make_rule();
443 let line = r#" <span className="text-muted-foreground text-lg font-bold">"#;
444 let violations = check(&rule, line);
445 assert!(violations.is_empty(), "text-muted-foreground should pass");
446 }
447
448 #[test]
449 fn good_card_semantic_border_passes() {
450 let rule = make_rule();
451 let line = r#" <div className="border-t border-border pt-4">"#;
452 let violations = check(&rule, line);
453 assert!(violations.is_empty(), "border-border should pass");
454 }
455
456 #[test]
457 fn good_card_destructive_semantic_passes() {
458 let rule = make_rule();
459 let line = r#" <div className="bg-destructive text-destructive-foreground p-4 rounded-md border border-destructive">"#;
460 let violations = check(&rule, line);
461 assert!(violations.is_empty(), "destructive semantic tokens should pass");
462 }
463
464 #[test]
467 fn dark_variant_present_no_violation() {
468 let rule = make_rule();
469 let line = r#"<div className="bg-white dark:bg-slate-900 text-black dark:text-white">"#;
470 let violations = check(&rule, line);
471 assert!(violations.is_empty(), "dark: variants present should suppress violations");
472 }
473
474 #[test]
477 fn cn_call_with_hardcoded_colors_flagged() {
478 let rule = make_rule();
479 let line = r#" className={cn("bg-gray-100 text-gray-600")}"#;
480 let violations = check(&rule, line);
481 assert!(!violations.is_empty(), "hardcoded colors inside cn() should be flagged");
482 }
483
484 #[test]
485 fn cn_call_with_semantic_tokens_passes() {
486 let rule = make_rule();
487 let line = r#" className={cn("bg-primary text-primary-foreground")}"#;
488 let violations = check(&rule, line);
489 assert!(violations.is_empty(), "semantic tokens inside cn() should pass");
490 }
491
492 #[test]
495 fn transparent_and_current_always_pass() {
496 let rule = make_rule();
497 let line = r#"<div className="bg-transparent text-current">"#;
498 let violations = check(&rule, line);
499 assert!(violations.is_empty(), "transparent and current should always be allowed");
500 }
501
502 #[test]
505 fn custom_allowed_class_suppresses_violation() {
506 let config = RuleConfig {
507 id: "tailwind-dark-mode".into(),
508 severity: Severity::Warning,
509 message: String::new(),
510 allowed_classes: vec!["bg-white".into()],
511 ..Default::default()
512 };
513 let rule = TailwindDarkModeRule::new(&config).unwrap();
514 let line = r#"<div className="bg-white">"#;
515 let violations = check(&rule, line);
516 assert!(violations.is_empty(), "explicitly allowed class should not be flagged");
517 }
518
519 #[test]
522 fn plain_text_no_violations() {
523 let rule = make_rule();
524 let violations = check(&rule, "const color = 'bg-white';");
525 assert!(violations.is_empty(), "non-className usage should not be flagged");
526 }
527
528 #[test]
531 fn bad_card_full_file() {
532 let rule = make_rule();
533 let content = include_str!("../../examples/BadCard.tsx");
534 let violations = check(&rule, content);
535 assert!(
536 violations.len() >= 5,
537 "BadCard.tsx should have many violations, got {}",
538 violations.len()
539 );
540 }
541
542 #[test]
543 fn good_card_full_file() {
544 let rule = make_rule();
545 let content = include_str!("../../examples/GoodCard.tsx");
546 let violations = check(&rule, content);
547 assert!(
548 violations.is_empty(),
549 "GoodCard.tsx should have no violations, got {}: {:?}",
550 violations.len(),
551 violations.iter().map(|v| &v.message).collect::<Vec<_>>()
552 );
553 }
554}