Skip to main content

code_baseline/rules/
tailwind_theme_tokens.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4use std::collections::HashMap;
5
6/// Enforces usage of shadcn/ui semantic token classes instead of raw Tailwind
7/// color utilities.
8///
9/// In a properly themed shadcn project, you should use classes like `bg-background`,
10/// `text-foreground`, `bg-muted`, `border-border` etc. that resolve to CSS custom
11/// properties — not raw Tailwind colors like `bg-white`, `text-gray-900`, `border-slate-200`.
12///
13/// This rule catches raw color classes and suggests the semantic replacement.
14///
15/// By default it ships with a comprehensive mapping. You can extend or override
16/// it via the `token_map` config field: `["bg-white=bg-background", "text-black=text-foreground"]`.
17pub struct TailwindThemeTokensRule {
18    id: String,
19    severity: Severity,
20    message: String,
21    glob: Option<String>,
22    /// Map from banned raw class → suggested semantic token class.
23    token_map: HashMap<String, String>,
24    /// Regex to find color utility classes in source.
25    color_re: Regex,
26    /// Regex to extract class attribute values.
27    class_context_re: Regex,
28}
29
30/// Default mapping of raw Tailwind color classes → shadcn semantic tokens.
31fn default_token_map() -> HashMap<String, String> {
32    let mut map = HashMap::new();
33
34    // ── Background colors ──
35    // Light theme backgrounds
36    map.insert("bg-white".into(), "bg-background".into());
37    map.insert("bg-slate-50".into(), "bg-muted".into());
38    map.insert("bg-gray-50".into(), "bg-muted".into());
39    map.insert("bg-zinc-50".into(), "bg-muted".into());
40    map.insert("bg-neutral-50".into(), "bg-muted".into());
41    map.insert("bg-slate-100".into(), "bg-muted".into());
42    map.insert("bg-gray-100".into(), "bg-muted".into());
43    map.insert("bg-zinc-100".into(), "bg-muted".into());
44    map.insert("bg-neutral-100".into(), "bg-muted".into());
45
46    // Dark theme backgrounds (when used as dark: overrides)
47    map.insert("bg-slate-900".into(), "bg-background".into());
48    map.insert("bg-gray-900".into(), "bg-background".into());
49    map.insert("bg-zinc-900".into(), "bg-background".into());
50    map.insert("bg-neutral-900".into(), "bg-background".into());
51    map.insert("bg-slate-950".into(), "bg-background".into());
52    map.insert("bg-gray-950".into(), "bg-background".into());
53    map.insert("bg-zinc-950".into(), "bg-background".into());
54    map.insert("bg-neutral-950".into(), "bg-background".into());
55    map.insert("bg-black".into(), "bg-foreground or bg-background".into());
56
57    // Card backgrounds
58    map.insert("bg-slate-200".into(), "bg-card or bg-muted".into());
59    map.insert("bg-gray-200".into(), "bg-card or bg-muted".into());
60    map.insert("bg-zinc-200".into(), "bg-card or bg-muted".into());
61
62    // ── Text colors ──
63    map.insert("text-black".into(), "text-foreground".into());
64    map.insert("text-white".into(), "text-foreground (in dark) or text-primary-foreground".into());
65    map.insert("text-slate-900".into(), "text-foreground".into());
66    map.insert("text-gray-900".into(), "text-foreground".into());
67    map.insert("text-zinc-900".into(), "text-foreground".into());
68    map.insert("text-neutral-900".into(), "text-foreground".into());
69    map.insert("text-slate-950".into(), "text-foreground".into());
70    map.insert("text-gray-950".into(), "text-foreground".into());
71    map.insert("text-zinc-950".into(), "text-foreground".into());
72
73    // Muted text
74    map.insert("text-slate-500".into(), "text-muted-foreground".into());
75    map.insert("text-gray-500".into(), "text-muted-foreground".into());
76    map.insert("text-zinc-500".into(), "text-muted-foreground".into());
77    map.insert("text-neutral-500".into(), "text-muted-foreground".into());
78    map.insert("text-slate-400".into(), "text-muted-foreground".into());
79    map.insert("text-gray-400".into(), "text-muted-foreground".into());
80    map.insert("text-zinc-400".into(), "text-muted-foreground".into());
81    map.insert("text-neutral-400".into(), "text-muted-foreground".into());
82    map.insert("text-slate-600".into(), "text-muted-foreground".into());
83    map.insert("text-gray-600".into(), "text-muted-foreground".into());
84    map.insert("text-zinc-600".into(), "text-muted-foreground".into());
85
86    // ── Border colors ──
87    map.insert("border-slate-200".into(), "border-border".into());
88    map.insert("border-gray-200".into(), "border-border".into());
89    map.insert("border-zinc-200".into(), "border-border".into());
90    map.insert("border-neutral-200".into(), "border-border".into());
91    map.insert("border-slate-300".into(), "border-border".into());
92    map.insert("border-gray-300".into(), "border-border".into());
93    map.insert("border-zinc-300".into(), "border-border".into());
94    map.insert("border-slate-700".into(), "border-border".into());
95    map.insert("border-gray-700".into(), "border-border".into());
96    map.insert("border-zinc-700".into(), "border-border".into());
97    map.insert("border-slate-800".into(), "border-border".into());
98    map.insert("border-gray-800".into(), "border-border".into());
99    map.insert("border-zinc-800".into(), "border-border".into());
100
101    // ── Ring colors ──
102    map.insert("ring-slate-200".into(), "ring-ring".into());
103    map.insert("ring-gray-200".into(), "ring-ring".into());
104    map.insert("ring-slate-400".into(), "ring-ring".into());
105    map.insert("ring-gray-400".into(), "ring-ring".into());
106    map.insert("ring-slate-700".into(), "ring-ring".into());
107
108    // ── Divide colors ──
109    map.insert("divide-slate-200".into(), "divide-border".into());
110    map.insert("divide-gray-200".into(), "divide-border".into());
111    map.insert("divide-zinc-200".into(), "divide-border".into());
112
113    // ── Primary action colors (common patterns) ──
114    // These are project-specific so we map the common shadcn defaults
115    map.insert("bg-slate-900".to_string(), "bg-primary".into());
116    map.insert("text-slate-50".into(), "text-primary-foreground".into());
117    map.insert("text-gray-50".into(), "text-primary-foreground".into());
118
119    // ── Destructive patterns ──
120    map.insert("bg-red-500".into(), "bg-destructive".into());
121    map.insert("bg-red-600".into(), "bg-destructive".into());
122    map.insert("text-red-500".into(), "text-destructive".into());
123    map.insert("text-red-600".into(), "text-destructive".into());
124    map.insert("border-red-500".into(), "border-destructive".into());
125
126    // ── Accent/secondary ──
127    map.insert("bg-slate-100".to_string(), "bg-accent or bg-secondary".into());
128    map.insert("bg-gray-100".to_string(), "bg-accent or bg-secondary".into());
129
130    map
131}
132
133impl TailwindThemeTokensRule {
134    pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
135        let mut token_map = default_token_map();
136
137        // Override/extend with user-provided mappings
138        for entry in &config.token_map {
139            let parts: Vec<&str> = entry.splitn(2, '=').collect();
140            if parts.len() == 2 {
141                token_map.insert(parts[0].trim().to_string(), parts[1].trim().to_string());
142            }
143        }
144
145        // Remove any explicitly allowed classes from the ban map
146        for cls in &config.allowed_classes {
147            token_map.remove(cls);
148        }
149
150        // Build regex to detect any Tailwind color utility
151        let color_re = Regex::new(
152            r"\b(bg|text|border|ring|outline|shadow|divide|accent|caret|fill|stroke|decoration|placeholder|from|via|to)-(white|black|slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)(?:-(\d{2,3}))?(?:/\d+)?\b"
153        ).map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
154
155        let class_context_re = Regex::new(
156            r#"(?:className|class)\s*=|(?:cn|clsx|classNames|cva|twMerge)\s*\("#,
157        ).map_err(|e| RuleBuildError::InvalidRegex(config.id.clone(), e))?;
158
159        let default_glob = "**/*.{tsx,jsx,html}".to_string();
160
161        Ok(Self {
162            id: config.id.clone(),
163            severity: config.severity,
164            message: config.message.clone(),
165            glob: config.glob.clone().or(Some(default_glob)),
166            token_map,
167            color_re,
168            class_context_re,
169        })
170    }
171
172    /// Check if a line appears to contain Tailwind classes
173    /// (within className, class, cn(), clsx(), etc.)
174    fn line_has_class_context(&self, line: &str) -> bool {
175        self.class_context_re.is_match(line)
176    }
177}
178
179impl Rule for TailwindThemeTokensRule {
180    fn id(&self) -> &str {
181        &self.id
182    }
183
184    fn severity(&self) -> Severity {
185        self.severity
186    }
187
188    fn file_glob(&self) -> Option<&str> {
189        self.glob.as_deref()
190    }
191
192    fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
193        let mut violations = Vec::new();
194
195        for (line_num, line) in ctx.content.lines().enumerate() {
196            // Only check lines that are plausibly setting CSS classes
197            if !self.line_has_class_context(line) {
198                continue;
199            }
200
201            // Find all color utility classes on this line
202            for cap in self.color_re.captures_iter(line) {
203                let full_match = cap.get(0).unwrap().as_str();
204
205                // Skip classes that use dark: prefix — those are intentional overrides
206                let match_start = cap.get(0).unwrap().start();
207                if match_start >= 5 {
208                    let prefix = &line[match_start.saturating_sub(5)..match_start];
209                    if prefix.ends_with("dark:") {
210                        continue;
211                    }
212                }
213
214                // Check if this raw class is in our ban map
215                if let Some(replacement) = self.token_map.get(full_match) {
216                    let msg = if self.message.is_empty() {
217                        format!(
218                            "Raw color class '{}' — use semantic token '{}' for theme support",
219                            full_match, replacement
220                        )
221                    } else {
222                        format!("{}: '{}' → '{}'", self.message, full_match, replacement)
223                    };
224
225                    violations.push(Violation {
226                        rule_id: self.id.clone(),
227                        severity: self.severity,
228                        file: ctx.file_path.to_path_buf(),
229                        line: Some(line_num + 1),
230                        column: Some(cap.get(0).unwrap().start() + 1),
231                        message: msg,
232                        suggest: Some(format!("Replace '{}' with '{}'", full_match, replacement)),
233                        source_line: Some(line.to_string()),
234                        fix: Some(crate::rules::Fix {
235                            old: full_match.to_string(),
236                            new: replacement.clone(),
237                        }),
238                    });
239                }
240            }
241        }
242
243        violations
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::config::{RuleConfig, Severity};
251    use crate::rules::{Rule, ScanContext};
252    use std::path::Path;
253
254    fn make_rule() -> TailwindThemeTokensRule {
255        let config = RuleConfig {
256            id: "tailwind-theme-tokens".into(),
257            severity: Severity::Warning,
258            message: String::new(),
259            ..Default::default()
260        };
261        TailwindThemeTokensRule::new(&config).unwrap()
262    }
263
264    fn check(rule: &TailwindThemeTokensRule, content: &str) -> Vec<Violation> {
265        let ctx = ScanContext {
266            file_path: Path::new("test.tsx"),
267            content,
268        };
269        rule.check_file(&ctx)
270    }
271
272    // ── BadCard.tsx lines should flag violations ──
273
274    #[test]
275    fn flags_bg_white() {
276        let rule = make_rule();
277        let line = r#"    <div className="bg-white border border-gray-200 rounded-lg">"#;
278        let violations = check(&rule, line);
279        assert!(violations.iter().any(|v| v.message.contains("bg-white")));
280    }
281
282    #[test]
283    fn flags_text_gray_900() {
284        let rule = make_rule();
285        let line = r#"          <h3 className="text-gray-900 font-semibold">{name}</h3>"#;
286        let violations = check(&rule, line);
287        assert!(violations.iter().any(|v| v.message.contains("text-gray-900")));
288    }
289
290    #[test]
291    fn flags_text_gray_500_as_muted() {
292        let rule = make_rule();
293        let line = r#"          <p className="text-gray-500 text-sm">{email}</p>"#;
294        let violations = check(&rule, line);
295        let v = violations.iter().find(|v| v.message.contains("text-gray-500"));
296        assert!(v.is_some(), "text-gray-500 should be flagged");
297        assert!(
298            v.unwrap().suggest.as_ref().unwrap().contains("text-muted-foreground"),
299            "should suggest text-muted-foreground"
300        );
301    }
302
303    #[test]
304    fn flags_border_gray_200() {
305        let rule = make_rule();
306        let line = r#"    <div className="border border-gray-200 rounded">"#;
307        let violations = check(&rule, line);
308        let v = violations.iter().find(|v| v.message.contains("border-gray-200"));
309        assert!(v.is_some());
310        assert!(v.unwrap().suggest.as_ref().unwrap().contains("border-border"));
311    }
312
313    #[test]
314    fn flags_bg_red_500_as_destructive() {
315        let rule = make_rule();
316        let line = r#"    <div className="bg-red-500 text-white p-4">"#;
317        let violations = check(&rule, line);
318        let v = violations.iter().find(|v| v.message.contains("bg-red-500"));
319        assert!(v.is_some());
320        assert!(v.unwrap().suggest.as_ref().unwrap().contains("bg-destructive"));
321    }
322
323    #[test]
324    fn flags_bg_slate_900() {
325        let rule = make_rule();
326        let line = r#"        <button className="bg-slate-900 text-white px-4 py-2">"#;
327        let violations = check(&rule, line);
328        assert!(violations.iter().any(|v| v.message.contains("bg-slate-900")));
329    }
330
331    // ── GoodCard.tsx lines should pass clean ──
332
333    #[test]
334    fn semantic_bg_muted_passes() {
335        let rule = make_rule();
336        let line = r#"          <div className="w-12 h-12 bg-muted flex items-center">"#;
337        let violations = check(&rule, line);
338        assert!(violations.is_empty(), "bg-muted should not be flagged");
339    }
340
341    #[test]
342    fn semantic_text_muted_foreground_passes() {
343        let rule = make_rule();
344        let line = r#"            <span className="text-muted-foreground text-lg">"#;
345        let violations = check(&rule, line);
346        assert!(violations.is_empty());
347    }
348
349    #[test]
350    fn semantic_border_border_passes() {
351        let rule = make_rule();
352        let line = r#"        <div className="border-t border-border pt-4">"#;
353        let violations = check(&rule, line);
354        assert!(violations.is_empty());
355    }
356
357    #[test]
358    fn semantic_destructive_tokens_pass() {
359        let rule = make_rule();
360        let line = r#"    <div className="bg-destructive text-destructive-foreground border border-destructive">"#;
361        let violations = check(&rule, line);
362        assert!(violations.is_empty());
363    }
364
365    #[test]
366    fn semantic_primary_tokens_pass() {
367        let rule = make_rule();
368        let line = r#"      className={cn("bg-primary text-primary-foreground")}"#;
369        let violations = check(&rule, line);
370        assert!(violations.is_empty());
371    }
372
373    // ── dark: prefixed classes are skipped ──
374
375    #[test]
376    fn dark_prefix_skipped() {
377        let rule = make_rule();
378        let line = r#"<div className="bg-white dark:bg-slate-900">"#;
379        let violations = check(&rule, line);
380        // bg-white should be flagged, but dark:bg-slate-900 should NOT
381        assert!(
382            !violations.iter().any(|v| v.message.contains("dark:bg-slate-900")),
383            "dark: prefixed classes should be skipped"
384        );
385    }
386
387    // ── Non-class context is ignored ──
388
389    #[test]
390    fn non_class_context_ignored() {
391        let rule = make_rule();
392        let line = r#"const myColor = "bg-white";"#;
393        let violations = check(&rule, line);
394        assert!(violations.is_empty(), "color outside className context should be ignored");
395    }
396
397    // ── cn()/clsx() context is detected ──
398
399    #[test]
400    fn cn_call_context_detected() {
401        let rule = make_rule();
402        let line = r#"      className={cn("bg-gray-100 text-gray-600")}"#;
403        let violations = check(&rule, line);
404        assert!(!violations.is_empty(), "raw colors inside cn() should be flagged");
405    }
406
407    // ── Custom token_map overrides ──
408
409    #[test]
410    fn custom_token_map_override() {
411        let config = RuleConfig {
412            id: "tailwind-theme-tokens".into(),
413            severity: Severity::Warning,
414            message: String::new(),
415            token_map: vec!["bg-blue-500=bg-brand".into()],
416            ..Default::default()
417        };
418        let rule = TailwindThemeTokensRule::new(&config).unwrap();
419        let line = r#"<div className="bg-blue-500">"#;
420        let violations = check(&rule, line);
421        let v = violations.iter().find(|v| v.message.contains("bg-blue-500"));
422        assert!(v.is_some());
423        assert!(v.unwrap().suggest.as_ref().unwrap().contains("bg-brand"));
424    }
425
426    // ── allowed_classes removes from ban map ──
427
428    #[test]
429    fn allowed_class_not_flagged() {
430        let config = RuleConfig {
431            id: "tailwind-theme-tokens".into(),
432            severity: Severity::Warning,
433            message: String::new(),
434            allowed_classes: vec!["bg-white".into()],
435            ..Default::default()
436        };
437        let rule = TailwindThemeTokensRule::new(&config).unwrap();
438        let line = r#"<div className="bg-white">"#;
439        let violations = check(&rule, line);
440        assert!(
441            !violations.iter().any(|v| v.message.contains("bg-white")),
442            "explicitly allowed class should not be flagged"
443        );
444    }
445
446    // ── Violation metadata ──
447
448    #[test]
449    fn violation_has_correct_line_number() {
450        let rule = make_rule();
451        let content = "const x = 1;\n<div className=\"bg-white p-4\">\n</div>";
452        let violations = check(&rule, content);
453        assert!(violations.iter().any(|v| v.line == Some(2)));
454    }
455
456    #[test]
457    fn violation_has_source_line() {
458        let rule = make_rule();
459        let line = r#"<div className="bg-white">"#;
460        let violations = check(&rule, line);
461        assert!(!violations.is_empty());
462        assert_eq!(violations[0].source_line.as_deref(), Some(line));
463    }
464
465    // ── Full file tests ──
466
467    #[test]
468    fn bad_card_full_file() {
469        let rule = make_rule();
470        let content = include_str!("../../examples/BadCard.tsx");
471        let violations = check(&rule, content);
472        assert!(
473            violations.len() >= 5,
474            "BadCard.tsx should have many violations, got {}",
475            violations.len()
476        );
477    }
478
479    #[test]
480    fn good_card_full_file() {
481        let rule = make_rule();
482        let content = include_str!("../../examples/GoodCard.tsx");
483        let violations = check(&rule, content);
484        assert!(
485            violations.is_empty(),
486            "GoodCard.tsx should have no violations, got {}: {:?}",
487            violations.len(),
488            violations.iter().map(|v| &v.message).collect::<Vec<_>>()
489        );
490    }
491}