Skip to main content

code_baseline/rules/
tailwind_dark_mode.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4use std::collections::HashSet;
5
6/// Enforces that hardcoded Tailwind color utilities always have a `dark:` counterpart.
7///
8/// For example, `className="bg-white text-black"` will be flagged because there
9/// are no `dark:bg-*` or `dark:text-*` variants present. shadcn semantic token
10/// classes like `bg-background` and `text-foreground` are allowed by default
11/// because they resolve via CSS variables that already handle both themes.
12///
13/// This rule scans JSX/TSX/HTML files for class attributes and analyzes the
14/// Tailwind classes within them.
15pub struct TailwindDarkModeRule {
16    id: String,
17    severity: Severity,
18    message: String,
19    suggest: Option<String>,
20    glob: Option<String>,
21    /// Classes that are exempt (don't need a dark: variant).
22    allowed: HashSet<String>,
23    /// Regex to extract className/class attribute values.
24    class_attr_re: Regex,
25    /// Regex to identify color utility classes.
26    color_utility_re: Regex,
27    /// Regex to find cn/clsx/classNames/cva/twMerge function calls.
28    cn_fn_re: Regex,
29    /// Regex to extract quoted strings inside function calls.
30    cn_str_re: Regex,
31}
32
33/// The Tailwind color utility prefixes that are theme-sensitive.
34const COLOR_PREFIXES: &[&str] = &[
35    "bg-", "text-", "border-", "ring-", "outline-", "shadow-",
36    "divide-", "accent-", "caret-", "fill-", "stroke-",
37    "decoration-", "placeholder-",
38    // Gradient stops
39    "from-", "via-", "to-",
40];
41
42/// Tailwind color names (used to build the detection regex).
43const 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    // Named colors
50    "white", "black",
51];
52
53/// shadcn/ui semantic token classes that already handle light/dark via CSS variables.
54/// These never need a `dark:` variant.
55const 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
72/// Classes that inherently don't need dark: variants.
73const 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        // Build the set of allowed classes (semantic tokens + special values)
82        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        // Add user-provided allowed classes
92        for cls in &config.allowed_classes {
93            allowed.insert(cls.clone());
94        }
95
96        // Regex to find className="..." or class="..." (handles multi-line with `)
97        // Also matches className={cn("...", "...")} and className={clsx(...)}
98        // We capture the full attribute value to extract classes from.
99        let class_attr_re = Regex::new(
100            r#"(?:className|class)\s*=\s*(?:"([^"]*?)"|'([^']*?)'|\{[^}]*?(?:`([^`]*?)`|"([^"]*?)"|'([^']*?)'))"#,
101        ).map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
102
103        // Build regex that matches color utility classes.
104        // Pattern: (bg|text|border|...)-{color}(-{shade})?
105        // Examples: bg-white, text-gray-900, border-slate-200/50
106        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    /// Extract all class strings from a line of source code.
141    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            // Try each capture group (different quote styles)
145            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    /// Check if a color utility class has a corresponding `dark:` variant in the same class list.
155    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        // Collect all dark: prefixed classes
159        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            // Skip dark: prefixed classes themselves
168            if class.starts_with("dark:") || class.starts_with("hover:") || class.starts_with("focus:") {
169                continue;
170            }
171
172            // Skip non-color utility classes
173            if !self.color_utility_re.is_match(class) {
174                continue;
175            }
176
177            // Skip allowed classes (semantic tokens, transparent, etc.)
178            if self.allowed.contains(*class) {
179                continue;
180            }
181
182            // Check if there's a matching dark: variant
183            // We look for any dark: class that shares the same prefix (e.g., dark:bg-*)
184            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
197/// Suggest a semantic token replacement for a raw color class.
198fn suggest_semantic_token(class: &str) -> Option<String> {
199    // Common mappings
200    let parts: Vec<&str> = class.splitn(2, '-').collect();
201    if parts.len() < 2 {
202        return None;
203    }
204    let prefix = parts[0]; // bg, text, border, etc.
205    let color_part = parts[1]; // white, black, gray-100, etc.
206
207    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            // Extract shade
220            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            // Also check for multi-word strings that look like class lists in
256            // cn(), clsx(), classNames() calls — common in shadcn projects.
257            // We do a broader scan for quoted strings inside these function calls.
258            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    /// Extract string arguments from cn(), clsx(), classNames() calls.
301    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                    // Only include if it looks like Tailwind classes (has spaces or dashes)
310                    if s.contains('-') || s.contains(' ') {
311                        results.push(s.to_string());
312                    }
313                }
314            }
315        }
316
317        results
318    }
319}
320
321/// Suggest a dark mode counterpart for a color class.
322fn 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    // Invert common patterns
332    match color_part {
333        "white" => format!("{}-slate-950", prefix),
334        "black" => format!("{}-white", prefix),
335        s => {
336            // Try to invert shade: 100 → 900, 200 → 800, etc.
337            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    // ── BadCard.tsx should flag violations ──
380
381    #[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    // ── GoodCard.tsx should pass clean ──
431
432    #[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    // ── dark: variant suppresses violation ──
465
466    #[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    // ── cn() function calls ──
475
476    #[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    // ── transparent / current always allowed ──
493
494    #[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    // ── allowed_classes config ──
503
504    #[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    // ── Non-class lines should be ignored ──
520
521    #[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    // ── Full file tests ──
529
530    #[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}