oparry_validators/
accessibility.rs1use crate::Validator;
4use oparry_core::{Issue, IssueLevel, Result, ValidationResult};
5use oparry_parser::{ParsedCode, Language};
6use regex::Regex;
7use std::path::Path;
8
9#[derive(Debug, Clone)]
10pub struct A11yConfig {
11 pub require_alt: bool,
12 pub require_aria_label: bool,
13 pub allow_aria_hidden: bool,
14}
15
16impl Default for A11yConfig {
17 fn default() -> Self {
18 Self {
19 require_alt: true,
20 require_aria_label: true,
21 allow_aria_hidden: true,
22 }
23 }
24}
25
26pub struct A11yValidator {
27 config: A11yConfig,
28 img_tag_regex: Regex,
29 button_no_text_regex: Regex,
30}
31
32impl A11yValidator {
33 pub fn new(config: A11yConfig) -> Self {
34 Self {
35 config,
36 img_tag_regex: Regex::new(r"<img\b[^>]*>").unwrap(),
37 button_no_text_regex: Regex::new(r"<button\b[^>]*>(?:\s*</button>)?").unwrap(),
38 }
39 }
40
41 pub fn default_config() -> Self {
42 Self::new(A11yConfig::default())
43 }
44
45 fn has_alt_attribute(img_tag: &str) -> bool {
47 let alt_regex = Regex::new(r#"alt\s*=\s*("[^"]*"|'[^']*'|[^"'\s>]+)"#).unwrap();
49 alt_regex.is_match(img_tag)
50 }
51
52 fn has_accessible_label(button_tag: &str) -> bool {
54 let aria_regex = Regex::new(r#"aria-(label|labelledby)\s*="#).unwrap();
56 if aria_regex.is_match(button_tag) {
57 return true;
58 }
59 if let Some(start) = button_tag.find('>') {
61 let content = &button_tag[start + 1..];
62 if let Some(end) = content.find("</button>") {
63 let text = &content[..end];
64 return !text.trim().is_empty();
65 }
66 }
67 false
68 }
69
70 fn validate_internal(&self, source: &str, file: &str) -> Result<ValidationResult> {
71 let mut result = ValidationResult::new();
72
73 if self.config.require_alt {
74 for mat in self.img_tag_regex.find_iter(source) {
75 if !Self::has_alt_attribute(mat.as_str()) {
76 let line = source[..mat.start()].lines().count() + 1;
77 result.add_issue(Issue::warning("a11y-img-no-alt", "Image missing alt text")
78 .with_line(line)
79 .with_suggestion("Add alt attribute"));
80 }
81 }
82 }
83
84 if self.config.require_aria_label {
85 for mat in self.button_no_text_regex.find_iter(source) {
86 if !Self::has_accessible_label(mat.as_str()) {
87 let line = source[..mat.start()].lines().count() + 1;
88 result.add_issue(Issue::warning("a11y-button-no-label", "Button has no accessible label")
89 .with_line(line)
90 .with_suggestion("Add aria-label or text content"));
91 }
92 }
93 }
94
95 Ok(result)
96 }
97}
98
99impl Validator for A11yValidator {
100 fn name(&self) -> &str {
101 "Accessibility"
102 }
103
104 fn supports(&self, language: Language) -> bool {
105 language.is_javascript_variant()
106 }
107
108 fn validate_parsed(&self, code: &ParsedCode, file: &Path) -> Result<ValidationResult> {
109 let source = code.source();
110 let file_str = file.to_string_lossy().to_string();
111 self.validate_internal(source, &file_str)
112 }
113
114 fn validate_raw(&self, source: &str, file: &Path) -> Result<ValidationResult> {
115 let file_str = file.to_string_lossy().to_string();
116 self.validate_internal(source, &file_str)
117 }
118}
119
120impl Default for A11yValidator {
121 fn default() -> Self {
122 Self::new(A11yConfig::default())
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 #[test]
131 fn test_img_without_alt() {
132 let validator = A11yValidator::new(A11yConfig::default());
133 let source = r#"<img src="logo.png" />"#;
134 let result = validator.validate_raw(source, std::path::Path::new("test.tsx")).unwrap();
135 assert!(result.warning_count() > 0, "Should detect missing alt attribute");
137 }
138
139 #[test]
140 fn test_img_with_alt() {
141 let validator = A11yValidator::new(A11yConfig::default());
142 let source = r#"<img src="logo.png" alt="Company Logo" />"#;
143 let result = validator.validate_raw(source, std::path::Path::new("test.tsx")).unwrap();
144 assert!(result.passed);
145 assert_eq!(result.warning_count(), 0);
146 }
147}