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
7pub struct TailwindThemeTokensRule {
19 id: String,
20 severity: Severity,
21 message: String,
22 glob: Option<String>,
23 token_map: HashMap<String, String>,
25 color_re: Regex,
27 class_context_re: Regex,
29}
30
31fn default_token_map() -> HashMap<String, String> {
33 let mut map = HashMap::new();
34
35 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 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 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 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 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 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 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 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 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 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 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 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 for cls in &config.allowed_classes {
148 token_map.remove(cls);
149 }
150
151 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 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 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 #[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 #[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 #[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 assert!(
446 !violations.iter().any(|v| v.message.contains("dark:bg-slate-900")),
447 "dark: prefixed classes should be skipped"
448 );
449 }
450
451 #[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 #[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 #[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 #[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 #[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 #[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 #[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}