1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::{collect_class_attributes, parse_file};
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4use regex::Regex;
5use std::collections::HashSet;
6
7pub struct TailwindDarkModeRule {
17 id: String,
18 severity: Severity,
19 message: String,
20 suggest: Option<String>,
21 glob: Option<String>,
22 allowed: HashSet<String>,
24 class_attr_re: Regex,
26 color_utility_re: Regex,
28 cn_fn_re: Regex,
30 cn_str_re: Regex,
32}
33
34const COLOR_PREFIXES: &[&str] = &[
36 "bg-", "text-", "border-", "ring-", "outline-", "shadow-",
37 "divide-", "accent-", "caret-", "fill-", "stroke-",
38 "decoration-", "placeholder-",
39 "from-", "via-", "to-",
41];
42
43const TAILWIND_COLORS: &[&str] = &[
45 "slate", "gray", "zinc", "neutral", "stone",
46 "red", "orange", "amber", "yellow", "lime",
47 "green", "emerald", "teal", "cyan", "sky",
48 "blue", "indigo", "violet", "purple", "fuchsia",
49 "pink", "rose",
50 "white", "black",
52];
53
54const SEMANTIC_TOKEN_SUFFIXES: &[&str] = &[
57 "background", "foreground",
58 "card", "card-foreground",
59 "popover", "popover-foreground",
60 "primary", "primary-foreground",
61 "secondary", "secondary-foreground",
62 "muted", "muted-foreground",
63 "accent", "accent-foreground",
64 "destructive", "destructive-foreground",
65 "border", "input", "ring",
66 "chart-1", "chart-2", "chart-3", "chart-4", "chart-5",
67 "sidebar-background", "sidebar-foreground",
68 "sidebar-primary", "sidebar-primary-foreground",
69 "sidebar-accent", "sidebar-accent-foreground",
70 "sidebar-border", "sidebar-ring",
71];
72
73const ALWAYS_ALLOWED_SUFFIXES: &[&str] = &[
75 "transparent", "current", "inherit", "auto",
76];
77
78impl TailwindDarkModeRule {
79 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
80 let mut allowed = HashSet::new();
81
82 for prefix in COLOR_PREFIXES {
84 for suffix in SEMANTIC_TOKEN_SUFFIXES {
85 allowed.insert(format!("{}{}", prefix, suffix));
86 }
87 for suffix in ALWAYS_ALLOWED_SUFFIXES {
88 allowed.insert(format!("{}{}", prefix, suffix));
89 }
90 }
91
92 for cls in &config.allowed_classes {
94 allowed.insert(cls.clone());
95 }
96
97 let class_attr_re = Regex::new(
101 r#"(?:className|class)\s*=\s*(?:"([^"]*?)"|'([^']*?)'|\{[^}]*?(?:`([^`]*?)`|"([^"]*?)"|'([^']*?)'))"#,
102 ).map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
103
104 let prefix_group = COLOR_PREFIXES.iter()
108 .map(|p| regex::escape(p.trim_end_matches('-')))
109 .collect::<Vec<_>>()
110 .join("|");
111 let color_group = TAILWIND_COLORS.join("|");
112
113 let color_re_str = format!(
114 r"\b({})-({})(?:-(\d{{2,3}}))?(?:/\d+)?\b",
115 prefix_group, color_group
116 );
117 let color_utility_re = Regex::new(&color_re_str)
118 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
119
120 let cn_fn_re = Regex::new(r#"(?:cn|clsx|classNames|cva|twMerge)\s*\("#)
121 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
122 let cn_str_re = Regex::new(r#"['"`]([^'"`]+?)['"`]"#)
123 .map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
124
125 let default_glob = "**/*.{tsx,jsx,html}".to_string();
126
127 Ok(Self {
128 id: config.id.clone(),
129 severity: config.severity,
130 message: config.message.clone(),
131 suggest: config.suggest.clone(),
132 glob: config.glob.clone().or(Some(default_glob)),
133 allowed,
134 class_attr_re,
135 color_utility_re,
136 cn_fn_re,
137 cn_str_re,
138 })
139 }
140
141 fn extract_class_strings<'a>(&self, line: &'a str) -> Vec<&'a str> {
143 let mut results = Vec::new();
144 for cap in self.class_attr_re.captures_iter(line) {
145 for i in 1..=5 {
147 if let Some(m) = cap.get(i) {
148 results.push(m.as_str());
149 }
150 }
151 }
152 results
153 }
154
155 fn find_missing_dark_variants(&self, class_string: &str) -> Vec<(String, Option<String>)> {
157 let classes: Vec<&str> = class_string.split_whitespace().collect();
158
159 let dark_classes: HashSet<String> = classes.iter()
161 .filter(|c| c.starts_with("dark:"))
162 .map(|c| c.strip_prefix("dark:").unwrap().to_string())
163 .collect();
164
165 let mut violations = Vec::new();
166
167 for class in &classes {
168 if class.starts_with("dark:") || class.starts_with("hover:") || class.starts_with("focus:") {
170 continue;
171 }
172
173 if !self.color_utility_re.is_match(class) {
175 continue;
176 }
177
178 if self.allowed.contains(*class) {
180 continue;
181 }
182
183 let prefix = class.split('-').next().unwrap_or("");
186 let has_dark = dark_classes.iter().any(|dc| dc.starts_with(prefix));
187
188 if !has_dark {
189 let suggestion = suggest_semantic_token(class);
190 violations.push((class.to_string(), suggestion));
191 }
192 }
193
194 violations
195 }
196}
197
198fn suggest_semantic_token(class: &str) -> Option<String> {
200 let parts: Vec<&str> = class.splitn(2, '-').collect();
202 if parts.len() < 2 {
203 return None;
204 }
205 let prefix = parts[0]; let color_part = parts[1]; let token = match color_part {
209 "white" => match prefix {
210 "bg" => Some("bg-background"),
211 "text" => Some("text-foreground"),
212 _ => None,
213 },
214 "black" => match prefix {
215 "bg" => Some("bg-foreground"),
216 "text" => Some("text-background"),
217 _ => None,
218 },
219 s if s.starts_with("gray") || s.starts_with("slate") || s.starts_with("zinc") || s.starts_with("neutral") => {
220 let shade: Option<u32> = s.split('-').nth(1).and_then(|n| n.parse().ok());
222 match (prefix, shade) {
223 ("bg", Some(50..=200)) => Some("bg-muted"),
224 ("bg", Some(800..=950)) => Some("bg-background (in dark theme)"),
225 ("text", Some(400..=600)) => Some("text-muted-foreground"),
226 ("text", Some(700..=950)) => Some("text-foreground"),
227 ("border", _) => Some("border-border"),
228 _ => None,
229 }
230 },
231 _ => None,
232 };
233
234 token.map(|t| format!("Use '{}' instead — it adapts to light/dark automatically", t))
235}
236
237impl Rule for TailwindDarkModeRule {
238 fn id(&self) -> &str {
239 &self.id
240 }
241
242 fn severity(&self) -> Severity {
243 self.severity
244 }
245
246 fn file_glob(&self) -> Option<&str> {
247 self.glob.as_deref()
248 }
249
250 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
251 if let Some(tree) = parse_file(ctx.file_path, ctx.content) {
252 return self.check_with_ast(&tree, ctx);
253 }
254 self.check_with_regex(ctx)
255 }
256}
257
258impl TailwindDarkModeRule {
259 fn check_with_ast(&self, tree: &tree_sitter::Tree, ctx: &ScanContext) -> Vec<Violation> {
260 let mut violations = Vec::new();
261 let source = ctx.content.as_bytes();
262
263 for attr_fragments in collect_class_attributes(tree, source) {
264 let full_class_string: String = attr_fragments
265 .iter()
266 .map(|f| f.value.as_str())
267 .collect::<Vec<_>>()
268 .join(" ");
269
270 let missing = self.find_missing_dark_variants(&full_class_string);
271
272 for (class, token_suggestion) in missing {
273 let (line, col) = attr_fragments
274 .iter()
275 .find_map(|f| {
276 f.value.split_whitespace().find(|&c| c == class).map(|_| {
277 let offset = f.value.find(&class).unwrap_or(0);
278 (f.line + 1, f.col + offset + 1)
279 })
280 })
281 .unwrap_or((1, 1));
282
283 let msg = if self.message.is_empty() {
284 format!("Class '{}' sets a color without a dark: variant", class)
285 } else {
286 format!("{}: '{}'", self.message, class)
287 };
288
289 let suggest = token_suggestion
290 .or_else(|| self.suggest.clone())
291 .or_else(|| {
292 Some(format!(
293 "Add 'dark:{}' or replace with a semantic token class",
294 suggest_dark_counterpart(&class)
295 ))
296 });
297
298 let source_line = ctx.content.lines().nth(line - 1).map(|l| l.to_string());
299
300 violations.push(Violation {
301 rule_id: self.id.clone(),
302 severity: self.severity,
303 file: ctx.file_path.to_path_buf(),
304 line: Some(line),
305 column: Some(col),
306 message: msg,
307 suggest,
308 source_line,
309 fix: None,
310 });
311 }
312 }
313
314 violations
315 }
316
317 fn check_with_regex(&self, ctx: &ScanContext) -> Vec<Violation> {
318 let mut violations = Vec::new();
319
320 for (line_num, line) in ctx.content.lines().enumerate() {
321 let class_strings = self.extract_class_strings(line);
322 let extra_strings = self.extract_cn_strings(line);
323
324 for class_str in class_strings
325 .iter()
326 .copied()
327 .chain(extra_strings.iter().map(|s| s.as_str()))
328 {
329 let missing = self.find_missing_dark_variants(class_str);
330
331 for (class, token_suggestion) in missing {
332 let msg = if self.message.is_empty() {
333 format!("Class '{}' sets a color without a dark: variant", class)
334 } else {
335 format!("{}: '{}'", self.message, class)
336 };
337
338 let suggest = token_suggestion
339 .or_else(|| self.suggest.clone())
340 .or_else(|| {
341 Some(format!(
342 "Add 'dark:{}' or replace with a semantic token class",
343 suggest_dark_counterpart(&class)
344 ))
345 });
346
347 violations.push(Violation {
348 rule_id: self.id.clone(),
349 severity: self.severity,
350 file: ctx.file_path.to_path_buf(),
351 line: Some(line_num + 1),
352 column: line.find(&class).map(|c| c + 1),
353 message: msg,
354 suggest,
355 source_line: Some(line.to_string()),
356 fix: None,
357 });
358 }
359 }
360 }
361
362 violations
363 }
364
365 fn extract_cn_strings(&self, line: &str) -> Vec<String> {
367 let mut results = Vec::new();
368
369 if let Some(fn_match) = self.cn_fn_re.find(line) {
370 let remainder = &line[fn_match.end()..];
371 for cap in self.cn_str_re.captures_iter(remainder) {
372 if let Some(m) = cap.get(1) {
373 let s = m.as_str();
374 if s.contains('-') || s.contains(' ') {
375 results.push(s.to_string());
376 }
377 }
378 }
379 }
380
381 results
382 }
383}
384
385fn suggest_dark_counterpart(class: &str) -> String {
387 let parts: Vec<&str> = class.splitn(2, '-').collect();
388 if parts.len() < 2 {
389 return class.to_string();
390 }
391
392 let prefix = parts[0];
393 let color_part = parts[1];
394
395 match color_part {
397 "white" => format!("{}-slate-950", prefix),
398 "black" => format!("{}-white", prefix),
399 s => {
400 let color_parts: Vec<&str> = s.rsplitn(2, '-').collect();
402 if color_parts.len() == 2 {
403 if let Ok(shade) = color_parts[0].parse::<u32>() {
404 let inverted = match shade {
405 50 => 950, 100 => 900, 200 => 800, 300 => 700,
406 400 => 600, 500 => 500, 600 => 400, 700 => 300,
407 800 => 200, 900 => 100, 950 => 50,
408 _ => shade,
409 };
410 return format!("{}-{}-{}", prefix, color_parts[1], inverted);
411 }
412 }
413 format!("{}-{}", prefix, s)
414 }
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use crate::config::{RuleConfig, Severity};
422 use crate::rules::{Rule, ScanContext};
423 use std::path::Path;
424
425 fn make_rule() -> TailwindDarkModeRule {
426 let config = RuleConfig {
427 id: "tailwind-dark-mode".into(),
428 severity: Severity::Warning,
429 message: String::new(),
430 ..Default::default()
431 };
432 TailwindDarkModeRule::new(&config).unwrap()
433 }
434
435 fn check(rule: &TailwindDarkModeRule, content: &str) -> Vec<Violation> {
436 let ctx = ScanContext {
437 file_path: Path::new("test.tsx"),
438 content,
439 };
440 rule.check_file(&ctx)
441 }
442
443 #[test]
446 fn bad_card_flags_hardcoded_bg_white() {
447 let rule = make_rule();
448 let line = r#" <div className="bg-white border border-gray-200 rounded-lg shadow-sm p-6">"#;
449 let violations = check(&rule, line);
450 assert!(!violations.is_empty(), "bg-white without dark: should be flagged");
451 assert!(violations.iter().any(|v| v.message.contains("bg-white")));
452 }
453
454 #[test]
455 fn bad_card_flags_hardcoded_text_colors() {
456 let rule = make_rule();
457 let line = r#" <h3 className="text-gray-900 font-semibold text-lg">{name}</h3>"#;
458 let violations = check(&rule, line);
459 assert!(violations.iter().any(|v| v.message.contains("text-gray-900")));
460 }
461
462 #[test]
463 fn bad_card_flags_muted_text() {
464 let rule = make_rule();
465 let line = r#" <p className="text-gray-500 text-sm">{email}</p>"#;
466 let violations = check(&rule, line);
467 assert!(violations.iter().any(|v| v.message.contains("text-gray-500")));
468 }
469
470 #[test]
471 fn bad_card_flags_border_color() {
472 let rule = make_rule();
473 let line = r#" <div className="mt-4 pt-4 border-t border-gray-200">"#;
474 let violations = check(&rule, line);
475 assert!(violations.iter().any(|v| v.message.contains("border-gray-200")));
476 }
477
478 #[test]
479 fn bad_card_flags_button_bg() {
480 let rule = make_rule();
481 let line = r#" <button className="bg-slate-900 text-white px-4 py-2 rounded-md hover:bg-slate-800">"#;
482 let violations = check(&rule, line);
483 assert!(violations.iter().any(|v| v.message.contains("bg-slate-900")));
484 }
485
486 #[test]
487 fn bad_card_flags_destructive_colors() {
488 let rule = make_rule();
489 let line = r#" <div className="bg-red-500 text-white p-4 rounded-md border border-red-600">"#;
490 let violations = check(&rule, line);
491 assert!(violations.iter().any(|v| v.message.contains("bg-red-500")));
492 }
493
494 #[test]
497 fn good_card_semantic_bg_muted_passes() {
498 let rule = make_rule();
499 let line = r#" <div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center">"#;
500 let violations = check(&rule, line);
501 assert!(violations.is_empty(), "bg-muted is a semantic token and should pass");
502 }
503
504 #[test]
505 fn good_card_semantic_text_muted_foreground_passes() {
506 let rule = make_rule();
507 let line = r#" <span className="text-muted-foreground text-lg font-bold">"#;
508 let violations = check(&rule, line);
509 assert!(violations.is_empty(), "text-muted-foreground should pass");
510 }
511
512 #[test]
513 fn good_card_semantic_border_passes() {
514 let rule = make_rule();
515 let line = r#" <div className="border-t border-border pt-4">"#;
516 let violations = check(&rule, line);
517 assert!(violations.is_empty(), "border-border should pass");
518 }
519
520 #[test]
521 fn good_card_destructive_semantic_passes() {
522 let rule = make_rule();
523 let line = r#" <div className="bg-destructive text-destructive-foreground p-4 rounded-md border border-destructive">"#;
524 let violations = check(&rule, line);
525 assert!(violations.is_empty(), "destructive semantic tokens should pass");
526 }
527
528 #[test]
531 fn dark_variant_present_no_violation() {
532 let rule = make_rule();
533 let line = r#"<div className="bg-white dark:bg-slate-900 text-black dark:text-white">"#;
534 let violations = check(&rule, line);
535 assert!(violations.is_empty(), "dark: variants present should suppress violations");
536 }
537
538 #[test]
541 fn cn_call_with_hardcoded_colors_flagged() {
542 let rule = make_rule();
543 let line = r#"<div className={cn("bg-gray-100 text-gray-600")} />"#;
544 let violations = check(&rule, line);
545 assert!(!violations.is_empty(), "hardcoded colors inside cn() should be flagged");
546 }
547
548 #[test]
549 fn cn_call_with_semantic_tokens_passes() {
550 let rule = make_rule();
551 let line = r#"<div className={cn("bg-primary text-primary-foreground")} />"#;
552 let violations = check(&rule, line);
553 assert!(violations.is_empty(), "semantic tokens inside cn() should pass");
554 }
555
556 #[test]
559 fn transparent_and_current_always_pass() {
560 let rule = make_rule();
561 let line = r#"<div className="bg-transparent text-current">"#;
562 let violations = check(&rule, line);
563 assert!(violations.is_empty(), "transparent and current should always be allowed");
564 }
565
566 #[test]
569 fn custom_allowed_class_suppresses_violation() {
570 let config = RuleConfig {
571 id: "tailwind-dark-mode".into(),
572 severity: Severity::Warning,
573 message: String::new(),
574 allowed_classes: vec!["bg-white".into()],
575 ..Default::default()
576 };
577 let rule = TailwindDarkModeRule::new(&config).unwrap();
578 let line = r#"<div className="bg-white">"#;
579 let violations = check(&rule, line);
580 assert!(violations.is_empty(), "explicitly allowed class should not be flagged");
581 }
582
583 #[test]
586 fn plain_text_no_violations() {
587 let rule = make_rule();
588 let violations = check(&rule, "const color = 'bg-white';");
589 assert!(violations.is_empty(), "non-className usage should not be flagged");
590 }
591
592 #[test]
595 fn bad_card_full_file() {
596 let rule = make_rule();
597 let content = include_str!("../../examples/BadCard.tsx");
598 let violations = check(&rule, content);
599 assert!(
600 violations.len() >= 5,
601 "BadCard.tsx should have many violations, got {}",
602 violations.len()
603 );
604 }
605
606 #[test]
607 fn good_card_full_file() {
608 let rule = make_rule();
609 let content = include_str!("../../examples/GoodCard.tsx");
610 let violations = check(&rule, content);
611 assert!(
612 violations.is_empty(),
613 "GoodCard.tsx should have no violations, got {}: {:?}",
614 violations.len(),
615 violations.iter().map(|v| &v.message).collect::<Vec<_>>()
616 );
617 }
618
619 #[test]
622 fn multiline_cn_all_args_detected() {
623 let rule = make_rule();
624 let content = r#"<span className={cn(
625 "px-2 py-1 rounded-full text-xs font-medium",
626 status === 'active' && "bg-green-100 text-green-800",
627 status === 'inactive' && "bg-gray-100 text-gray-600",
628 )} />"#;
629 let violations = check(&rule, content);
630 assert!(
631 violations.len() >= 4,
632 "multi-line cn() should detect all hardcoded colors, got {}",
633 violations.len()
634 );
635 }
636
637 #[test]
638 fn dark_variant_across_cn_args_no_violation() {
639 let rule = make_rule();
640 let content = r#"<div className={cn("bg-white", "dark:bg-slate-900")} />"#;
641 let violations = check(&rule, content);
642 assert!(
643 violations.is_empty(),
644 "dark: in separate cn() arg should suppress violation for same attribute"
645 );
646 }
647
648 #[test]
649 fn ternary_both_branches_checked() {
650 let rule = make_rule();
651 let content = r#"<div className={active ? "bg-white" : "bg-gray-100"} />"#;
652 let violations = check(&rule, content);
653 assert!(
654 violations.len() >= 2,
655 "both ternary branches should be checked, got {}",
656 violations.len()
657 );
658 }
659
660 #[test]
661 fn data_object_no_false_positive() {
662 let rule = make_rule();
663 let content = r#"const config = { className: "bg-white text-gray-900" };"#;
664 let violations = check(&rule, content);
665 assert!(
666 violations.is_empty(),
667 "non-JSX className key should not trigger violations"
668 );
669 }
670}