1use crate::config::{RuleConfig, Severity};
2use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
3use regex::Regex;
4use std::collections::HashMap;
5
6pub struct TailwindThemeTokensRule {
18 id: String,
19 severity: Severity,
20 message: String,
21 glob: Option<String>,
22 token_map: HashMap<String, String>,
24 color_re: Regex,
26 class_context_re: Regex,
28}
29
30fn default_token_map() -> HashMap<String, String> {
32 let mut map = HashMap::new();
33
34 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 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 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 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 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 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 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 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 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 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 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 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 for cls in &config.allowed_classes {
147 token_map.remove(cls);
148 }
149
150 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 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 if !self.line_has_class_context(line) {
198 continue;
199 }
200
201 for cap in self.color_re.captures_iter(line) {
203 let full_match = cap.get(0).unwrap().as_str();
204
205 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 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 #[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 #[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 #[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 assert!(
382 !violations.iter().any(|v| v.message.contains("dark:bg-slate-900")),
383 "dark: prefixed classes should be skipped"
384 );
385 }
386
387 #[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 #[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 #[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 #[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 #[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 #[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}