Skip to main content

code_baseline/rules/
tailwind_dark_mode.rs

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
7/// Enforces that hardcoded Tailwind color utilities always have a `dark:` counterpart.
8///
9/// For example, `className="bg-white text-black"` will be flagged because there
10/// are no `dark:bg-*` or `dark:text-*` variants present. shadcn semantic token
11/// classes like `bg-background` and `text-foreground` are allowed by default
12/// because they resolve via CSS variables that already handle both themes.
13///
14/// This rule scans JSX/TSX/HTML files for class attributes and analyzes the
15/// Tailwind classes within them.
16pub struct TailwindDarkModeRule {
17    id: String,
18    severity: Severity,
19    message: String,
20    suggest: Option<String>,
21    glob: Option<String>,
22    /// Classes that are exempt (don't need a dark: variant).
23    allowed: HashSet<String>,
24    /// Regex to extract className/class attribute values.
25    class_attr_re: Regex,
26    /// Regex to identify color utility classes.
27    color_utility_re: Regex,
28    /// Regex to find cn/clsx/classNames/cva/twMerge function calls.
29    cn_fn_re: Regex,
30    /// Regex to extract quoted strings inside function calls.
31    cn_str_re: Regex,
32}
33
34/// The Tailwind color utility prefixes that are theme-sensitive.
35const COLOR_PREFIXES: &[&str] = &[
36    "bg-", "text-", "border-", "ring-", "outline-", "shadow-",
37    "divide-", "accent-", "caret-", "fill-", "stroke-",
38    "decoration-", "placeholder-",
39    // Gradient stops
40    "from-", "via-", "to-",
41];
42
43/// Tailwind color names (used to build the detection regex).
44const 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    // Named colors
51    "white", "black",
52];
53
54/// shadcn/ui semantic token classes that already handle light/dark via CSS variables.
55/// These never need a `dark:` variant.
56const 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
73/// Classes that inherently don't need dark: variants.
74const 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        // Build the set of allowed classes (semantic tokens + special values)
83        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        // Add user-provided allowed classes
93        for cls in &config.allowed_classes {
94            allowed.insert(cls.clone());
95        }
96
97        // Regex to find className="..." or class="..." (handles multi-line with `)
98        // Also matches className={cn("...", "...")} and className={clsx(...)}
99        // We capture the full attribute value to extract classes from.
100        let class_attr_re = Regex::new(
101            r#"(?:className|class)\s*=\s*(?:"([^"]*?)"|'([^']*?)'|\{[^}]*?(?:`([^`]*?)`|"([^"]*?)"|'([^']*?)'))"#,
102        ).map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
103
104        // Build regex that matches color utility classes.
105        // Pattern: (bg|text|border|...)-{color}(-{shade})?
106        // Examples: bg-white, text-gray-900, border-slate-200/50
107        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    /// Extract all class strings from a line of source code.
142    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            // Try each capture group (different quote styles)
146            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    /// Check if a color utility class has a corresponding `dark:` variant in the same class list.
156    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        // Collect all dark: prefixed classes
160        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            // Skip dark: prefixed classes themselves
169            if class.starts_with("dark:") || class.starts_with("hover:") || class.starts_with("focus:") {
170                continue;
171            }
172
173            // Skip non-color utility classes
174            if !self.color_utility_re.is_match(class) {
175                continue;
176            }
177
178            // Skip allowed classes (semantic tokens, transparent, etc.)
179            if self.allowed.contains(*class) {
180                continue;
181            }
182
183            // Check if there's a matching dark: variant
184            // We look for any dark: class that shares the same prefix (e.g., dark:bg-*)
185            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
198/// Suggest a semantic token replacement for a raw color class.
199fn suggest_semantic_token(class: &str) -> Option<String> {
200    // Common mappings
201    let parts: Vec<&str> = class.splitn(2, '-').collect();
202    if parts.len() < 2 {
203        return None;
204    }
205    let prefix = parts[0]; // bg, text, border, etc.
206    let color_part = parts[1]; // white, black, gray-100, etc.
207
208    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            // Extract shade
221            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    /// Extract string arguments from cn(), clsx(), classNames() calls.
366    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
385/// Suggest a dark mode counterpart for a color class.
386fn 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    // Invert common patterns
396    match color_part {
397        "white" => format!("{}-slate-950", prefix),
398        "black" => format!("{}-white", prefix),
399        s => {
400            // Try to invert shade: 100 → 900, 200 → 800, etc.
401            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    // ── BadCard.tsx should flag violations ──
444
445    #[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    // ── GoodCard.tsx should pass clean ──
495
496    #[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    // ── dark: variant suppresses violation ──
529
530    #[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    // ── cn() function calls ──
539
540    #[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    // ── transparent / current always allowed ──
557
558    #[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    // ── allowed_classes config ──
567
568    #[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    // ── Non-class lines should be ignored ──
584
585    #[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    // ── Full file tests ──
593
594    #[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    // ── AST-specific tests ──
620
621    #[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}