Skip to main content

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