1use crate::css::Color;
6
7#[derive(Debug, Clone, PartialEq)]
9pub enum LintSeverity {
10 Error,
11 Warning,
12 Info,
13}
14
15#[derive(Debug, Clone)]
17pub struct LintResult {
18 pub severity: LintSeverity,
19 pub message: String,
20 pub rule: String,
21 pub line: Option<usize>,
22}
23
24pub struct CssLinter;
26
27impl CssLinter {
28 pub fn check_accessibility(css: &str) -> Vec<LintResult> {
30 let mut results = Vec::new();
31 use regex::Regex;
32
33 if !css.contains(":focus") && !css.contains(":focus-visible") {
35 results.push(LintResult {
36 severity: LintSeverity::Warning,
37 message: "Missing focus styles. Consider adding :focus-visible styles for keyboard navigation.".to_string(),
38 rule: "a11y-focus".to_string(),
39 line: None,
40 });
41 }
42
43 if (css.contains("animation") || css.contains("transition"))
45 && !css.contains("prefers-reduced-motion")
46 {
47 results.push(LintResult {
48 severity: LintSeverity::Info,
49 message:
50 "Consider adding @media (prefers-reduced-motion: reduce) for accessibility."
51 .to_string(),
52 rule: "a11y-reduced-motion".to_string(),
53 line: None,
54 });
55 }
56
57 if css.contains("color:") && !css.contains("background") {
59 results.push(LintResult {
60 severity: LintSeverity::Warning,
61 message: "Ensure text colors have sufficient contrast with backgrounds."
62 .to_string(),
63 rule: "a11y-contrast".to_string(),
64 line: None,
65 });
66 }
67
68 let small_font_re = Regex::new(r"font-size:\s*([0-9.]+)px").unwrap();
70 for cap in small_font_re.captures_iter(css) {
71 if let Ok(size) = cap.get(1).unwrap().as_str().parse::<f32>() {
72 if size < 12.0 {
73 results.push(LintResult {
74 severity: LintSeverity::Warning,
75 message: format!("Font size {}px may be too small for readability. Minimum recommended: 12px.", size),
76 rule: "a11y-font-size".to_string(),
77 line: None,
78 });
79 }
80 }
81 }
82
83 if css.contains("img") && !css.contains("alt") {
85 results.push(LintResult {
86 severity: LintSeverity::Info,
87 message: "Ensure images have alt text for screen readers.".to_string(),
88 rule: "a11y-alt-text".to_string(),
89 line: None,
90 });
91 }
92
93 let touch_target_re =
95 Regex::new(r"(width|height|min-width|min-height):\s*([0-9.]+)px").unwrap();
96 for cap in touch_target_re.captures_iter(css) {
97 if let Ok(size) = cap.get(2).unwrap().as_str().parse::<f32>() {
98 if size < 44.0 {
99 results.push(LintResult {
100 severity: LintSeverity::Warning,
101 message: format!(
102 "Touch target size {}px is below recommended 44x44px minimum.",
103 size
104 ),
105 rule: "a11y-touch-target".to_string(),
106 line: None,
107 });
108 }
109 }
110 }
111
112 if css.contains("role=") && !css.contains("aria-") {
114 results.push(LintResult {
115 severity: LintSeverity::Info,
116 message: "Consider adding ARIA attributes for better screen reader support."
117 .to_string(),
118 rule: "a11y-aria".to_string(),
119 line: None,
120 });
121 }
122
123 if !css.contains("@media (prefers-contrast") {
125 results.push(LintResult {
126 severity: LintSeverity::Info,
127 message:
128 "Consider supporting high contrast mode with @media (prefers-contrast: high)."
129 .to_string(),
130 rule: "a11y-high-contrast".to_string(),
131 line: None,
132 });
133 }
134
135 if css.contains("nav") && !css.contains("skip") {
137 results.push(LintResult {
138 severity: LintSeverity::Info,
139 message: "Consider adding skip navigation links for keyboard users.".to_string(),
140 rule: "a11y-skip-links".to_string(),
141 line: None,
142 });
143 }
144
145 if css.contains("font-size") && css.contains("!important") {
147 results.push(LintResult {
148 severity: LintSeverity::Warning,
149 message:
150 "Avoid using !important on font-size as it prevents users from resizing text."
151 .to_string(),
152 rule: "a11y-text-resize".to_string(),
153 line: None,
154 });
155 }
156
157 results
158 }
159
160 pub fn check_performance(css: &str) -> Vec<LintResult> {
162 let mut results = Vec::new();
163 use regex::Regex;
164
165 if css.contains("*") {
167 results.push(LintResult {
168 severity: LintSeverity::Warning,
169 message:
170 "Universal selector (*) can impact performance. Consider being more specific."
171 .to_string(),
172 rule: "perf-universal-selector".to_string(),
173 line: None,
174 });
175 }
176
177 if css.len() > 100_000 {
179 results.push(LintResult {
180 severity: LintSeverity::Info,
181 message: format!(
182 "Large CSS file ({} bytes). Consider code splitting.",
183 css.len()
184 ),
185 rule: "perf-large-css".to_string(),
186 line: None,
187 });
188 }
189
190 let deep_nesting_re = Regex::new(r"([^{}]*\{[^{}]*\{[^{}]*\{[^{}]*\{)").unwrap();
192 if deep_nesting_re.is_match(css) {
193 results.push(LintResult {
194 severity: LintSeverity::Warning,
195 message: "Deeply nested selectors can impact performance. Consider flattening."
196 .to_string(),
197 rule: "perf-deep-nesting".to_string(),
198 line: None,
199 });
200 }
201
202 let attr_selector_count = css.matches("[").count();
204 if attr_selector_count > 50 {
205 results.push(LintResult {
206 severity: LintSeverity::Info,
207 message: format!(
208 "Many attribute selectors ({}). Consider using classes for better performance.",
209 attr_selector_count
210 ),
211 rule: "perf-attribute-selectors".to_string(),
212 line: None,
213 });
214 }
215
216 let keyframes_re = Regex::new(r"@keyframes\s+(\w+)").unwrap();
218 let defined_keyframes: Vec<String> = keyframes_re
219 .captures_iter(css)
220 .map(|cap| cap.get(1).unwrap().as_str().to_string())
221 .collect();
222
223 for keyframe in &defined_keyframes {
224 if !css.contains(&format!("animation-name: {}", keyframe))
225 && !css.contains(&format!("animation: {}", keyframe))
226 {
227 results.push(LintResult {
228 severity: LintSeverity::Info,
229 message: format!(
230 "Unused @keyframes '{}'. Consider removing if not needed.",
231 keyframe
232 ),
233 rule: "perf-unused-keyframes".to_string(),
234 line: None,
235 });
236 }
237 }
238
239 let calc_count = css.matches("calc(").count();
241 if calc_count > 20 {
242 results.push(LintResult {
243 severity: LintSeverity::Info,
244 message: format!(
245 "Many calc() expressions ({}). Consider pre-calculating values.",
246 calc_count
247 ),
248 rule: "perf-calc-expressions".to_string(),
249 line: None,
250 });
251 }
252
253 if css.contains("filter:") || css.contains("backdrop-filter:") {
255 results.push(LintResult {
256 severity: LintSeverity::Warning,
257 message: "Filter effects can be expensive. Use sparingly and test performance."
258 .to_string(),
259 rule: "perf-filters".to_string(),
260 line: None,
261 });
262 }
263
264 if css.contains("will-change:") {
266 results.push(LintResult {
267 severity: LintSeverity::Info,
268 message: "will-change should be used sparingly. Remove when animation completes."
269 .to_string(),
270 rule: "perf-will-change".to_string(),
271 line: None,
272 });
273 }
274
275 results
276 }
277
278 pub fn check_best_practices(css: &str) -> Vec<LintResult> {
280 let mut results = Vec::new();
281 use regex::Regex;
282
283 let important_count = css.matches("!important").count();
285 if important_count > 10 {
286 results.push(LintResult {
287 severity: LintSeverity::Warning,
288 message: format!("Excessive use of !important ({} occurrences). Consider refactoring specificity.", important_count),
289 rule: "best-practice-important".to_string(),
290 line: None,
291 });
292 }
293
294 if css.contains("-webkit-") || css.contains("-moz-") || css.contains("-ms-") {
296 results.push(LintResult {
297 severity: LintSeverity::Info,
298 message: "Manual vendor prefixes detected. Consider using autoprefixer."
299 .to_string(),
300 rule: "best-practice-vendor-prefixes".to_string(),
301 line: None,
302 });
303 }
304
305 if css.contains("style=") {
307 results.push(LintResult {
308 severity: LintSeverity::Info,
309 message:
310 "Inline styles detected. Consider moving to stylesheet for maintainability."
311 .to_string(),
312 rule: "best-practice-inline-styles".to_string(),
313 line: None,
314 });
315 }
316
317 let magic_number_re = Regex::new(r":\s*([0-9]+)px").unwrap();
319 let magic_numbers: std::collections::HashSet<&str> = magic_number_re
320 .captures_iter(css)
321 .map(|cap| cap.get(1).unwrap().as_str())
322 .collect();
323
324 if magic_numbers.len() > 20 {
325 results.push(LintResult {
326 severity: LintSeverity::Info,
327 message:
328 "Many magic numbers detected. Consider using design tokens or CSS variables."
329 .to_string(),
330 rule: "best-practice-magic-numbers".to_string(),
331 line: None,
332 });
333 }
334
335 let hex_color_re = Regex::new(r"#[0-9a-fA-F]{3,6}").unwrap();
337 let hex_count = hex_color_re.find_iter(css).count();
338 if hex_count > 10 {
339 results.push(LintResult {
340 severity: LintSeverity::Info,
341 message: format!(
342 "Many hardcoded colors ({}). Consider using CSS variables or design tokens.",
343 hex_count
344 ),
345 rule: "best-practice-hardcoded-colors".to_string(),
346 line: None,
347 });
348 }
349
350 if css.contains("var(") && !css.contains("fallback") {
361 results.push(LintResult {
362 severity: LintSeverity::Info,
363 message: "Consider providing fallback values for CSS variables.".to_string(),
364 rule: "best-practice-fallbacks".to_string(),
365 line: None,
366 });
367 }
368
369 let empty_rule_re = Regex::new(r"[^{}]*\{\s*\}").unwrap();
371 if empty_rule_re.is_match(css) {
372 results.push(LintResult {
373 severity: LintSeverity::Info,
374 message: "Empty CSS rules detected. Consider removing unused rules.".to_string(),
375 rule: "best-practice-empty-rules".to_string(),
376 line: None,
377 });
378 }
379
380 if css.len() > 5000 && css.matches("/*").count() < 5 {
382 results.push(LintResult {
383 severity: LintSeverity::Info,
384 message:
385 "Consider adding comments for complex CSS rules to improve maintainability."
386 .to_string(),
387 rule: "best-practice-comments".to_string(),
388 line: None,
389 });
390 }
391
392 results
393 }
394
395 pub fn lint(css: &str) -> Vec<LintResult> {
397 let mut results = Vec::new();
398 results.extend(Self::check_accessibility(css));
399 results.extend(Self::check_performance(css));
400 results.extend(Self::check_best_practices(css));
401 results
402 }
403
404 pub fn auto_fix(css: &str) -> (String, Vec<String>) {
406 let mut fixed_css = css.to_string();
407 let mut fixes_applied = Vec::new();
408 use regex::Regex;
409
410 let missing_semicolon_re = Regex::new(r"([a-z-]+):\s*([^;}]+)\s*([}])").unwrap();
412 if missing_semicolon_re.is_match(&fixed_css) {
413 fixed_css = missing_semicolon_re
414 .replace_all(&fixed_css, "$1: $2; $3")
415 .to_string();
416 fixes_applied.push("Added missing semicolons".to_string());
417 }
418
419 let whitespace_re = Regex::new(r"\s+").unwrap();
425 let normalized = whitespace_re.replace_all(&fixed_css, " ");
426 if normalized != fixed_css {
427 fixed_css = normalized.to_string();
428 fixes_applied.push("Normalized whitespace".to_string());
429 }
430
431 (fixed_css, fixes_applied)
435 }
436
437 pub fn get_stats(css: &str) -> LintStats {
439 let results = Self::lint(css);
440 let errors = results
441 .iter()
442 .filter(|r| r.severity == LintSeverity::Error)
443 .count();
444 let warnings = results
445 .iter()
446 .filter(|r| r.severity == LintSeverity::Warning)
447 .count();
448 let info = results
449 .iter()
450 .filter(|r| r.severity == LintSeverity::Info)
451 .count();
452
453 LintStats {
454 total_rules: results.len(),
455 errors,
456 warnings,
457 info,
458 css_size: css.len(),
459 }
460 }
461}
462
463#[derive(Debug, Clone)]
465pub struct LintStats {
466 pub total_rules: usize,
467 pub errors: usize,
468 pub warnings: usize,
469 pub info: usize,
470 pub css_size: usize,
471}
472
473pub struct ContrastLinter;
475
476impl ContrastLinter {
477 pub fn check_contrast(foreground: &Color, background: &Color) -> LintResult {
479 use crate::a11y::contrast::{meets_wcag_aa, meets_wcag_aaa};
480
481 let meets_aa = meets_wcag_aa(foreground, background, false);
482 let meets_aaa = meets_wcag_aaa(foreground, background, false);
483
484 if !meets_aa {
485 LintResult {
486 severity: LintSeverity::Error,
487 message: "Color contrast does not meet WCAG AA standards (minimum 4.5:1 for normal text).".to_string(),
488 rule: "contrast-wcag-aa".to_string(),
489 line: None,
490 }
491 } else if !meets_aaa {
492 LintResult {
493 severity: LintSeverity::Warning,
494 message: "Color contrast meets WCAG AA but not AAA standards. Consider improving for better accessibility.".to_string(),
495 rule: "contrast-wcag-aaa".to_string(),
496 line: None,
497 }
498 } else {
499 LintResult {
500 severity: LintSeverity::Info,
501 message: "Color contrast meets WCAG AAA standards.".to_string(),
502 rule: "contrast-wcag-aaa".to_string(),
503 line: None,
504 }
505 }
506 }
507}