Skip to main content

oparry_validators/
accessibility.rs

1//! Accessibility validator
2
3use 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    /// Check if img tag has alt attribute
46    fn has_alt_attribute(img_tag: &str) -> bool {
47        // Match alt= with or without quotes, handling various formats
48        let alt_regex = Regex::new(r#"alt\s*=\s*("[^"]*"|'[^']*'|[^"'\s>]+)"#).unwrap();
49        alt_regex.is_match(img_tag)
50    }
51
52    /// Check if button has accessible content (text or aria-label)
53    fn has_accessible_label(button_tag: &str) -> bool {
54        // Check for aria-label or aria-labelledby
55        let aria_regex = Regex::new(r#"aria-(label|labelledby)\s*="#).unwrap();
56        if aria_regex.is_match(button_tag) {
57            return true;
58        }
59        // Extract content between tags
60        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        // Warnings don't fail by default (only in strict mode)
136        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}