Skip to main content

code_baseline/rules/ast/
require_img_alt.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::parse_file;
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5/// Flags `<img>` elements that are missing an `alt` attribute.
6///
7/// Walks the AST for `jsx_self_closing_element` and `jsx_opening_element`
8/// nodes with tag name `img` and checks that at least one child
9/// `jsx_attribute` has the name `alt`.
10pub struct RequireImgAltRule {
11    id: String,
12    severity: Severity,
13    message: String,
14    suggest: Option<String>,
15    glob: Option<String>,
16}
17
18impl RequireImgAltRule {
19    pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
20        Ok(Self {
21            id: config.id.clone(),
22            severity: config.severity,
23            message: config.message.clone(),
24            suggest: config.suggest.clone(),
25            glob: config.glob.clone(),
26        })
27    }
28}
29
30impl Rule for RequireImgAltRule {
31    fn id(&self) -> &str {
32        &self.id
33    }
34
35    fn severity(&self) -> Severity {
36        self.severity
37    }
38
39    fn file_glob(&self) -> Option<&str> {
40        self.glob.as_deref()
41    }
42
43    fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
44        let mut violations = Vec::new();
45        let tree = match parse_file(ctx.file_path, ctx.content) {
46            Some(t) => t,
47            None => return violations,
48        };
49        let source = ctx.content.as_bytes();
50        self.visit(tree.root_node(), source, ctx, &mut violations);
51        violations
52    }
53}
54
55impl RequireImgAltRule {
56    fn visit(
57        &self,
58        node: tree_sitter::Node,
59        source: &[u8],
60        ctx: &ScanContext,
61        violations: &mut Vec<Violation>,
62    ) {
63        let kind = node.kind();
64        if kind == "jsx_self_closing_element" || kind == "jsx_opening_element" {
65            if self.is_img_tag(&node, source) && !self.has_alt_attribute(&node, source) {
66                let row = node.start_position().row;
67                violations.push(Violation {
68                    rule_id: self.id.clone(),
69                    severity: self.severity,
70                    file: ctx.file_path.to_path_buf(),
71                    line: Some(row + 1),
72                    column: Some(node.start_position().column + 1),
73                    message: self.message.clone(),
74                    suggest: self.suggest.clone(),
75                    source_line: ctx.content.lines().nth(row).map(String::from),
76                    fix: None,
77                });
78            }
79        }
80
81        for i in 0..node.child_count() {
82            if let Some(child) = node.child(i) {
83                self.visit(child, source, ctx, violations);
84            }
85        }
86    }
87
88    fn is_img_tag(&self, node: &tree_sitter::Node, source: &[u8]) -> bool {
89        for i in 0..node.child_count() {
90            if let Some(child) = node.child(i) {
91                if child.kind() == "identifier" || child.kind() == "member_expression" {
92                    if let Ok(name) = child.utf8_text(source) {
93                        return name == "img";
94                    }
95                }
96            }
97        }
98        false
99    }
100
101    fn has_alt_attribute(&self, node: &tree_sitter::Node, source: &[u8]) -> bool {
102        for i in 0..node.child_count() {
103            if let Some(child) = node.child(i) {
104                if child.kind() == "jsx_attribute" {
105                    if let Some(name_node) = child.child(0) {
106                        if let Ok(name) = name_node.utf8_text(source) {
107                            if name == "alt" {
108                                return true;
109                            }
110                        }
111                    }
112                }
113            }
114        }
115        false
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::path::Path;
123
124    fn make_rule() -> RequireImgAltRule {
125        RequireImgAltRule::new(&RuleConfig {
126            id: "require-img-alt".into(),
127            severity: Severity::Error,
128            message: "img element must have an alt attribute".into(),
129            suggest: Some("Add alt=\"description\" or alt=\"\" for decorative images".into()),
130            glob: Some("**/*.{tsx,jsx}".into()),
131            ..Default::default()
132        })
133        .unwrap()
134    }
135
136    fn check(rule: &RequireImgAltRule, content: &str) -> Vec<Violation> {
137        let ctx = ScanContext {
138            file_path: Path::new("test.tsx"),
139            content,
140        };
141        rule.check_file(&ctx)
142    }
143
144    #[test]
145    fn img_with_alt_no_violation() {
146        let rule = make_rule();
147        let violations = check(&rule, r#"function App() { return <img alt="photo" src="/a.jpg" />; }"#);
148        assert!(violations.is_empty());
149    }
150
151    #[test]
152    fn img_with_empty_alt_no_violation() {
153        let rule = make_rule();
154        let violations = check(&rule, r#"function App() { return <img alt="" src="/a.jpg" />; }"#);
155        assert!(violations.is_empty());
156    }
157
158    #[test]
159    fn img_without_alt_violation() {
160        let rule = make_rule();
161        let violations = check(&rule, r#"function App() { return <img src="/a.jpg" />; }"#);
162        assert_eq!(violations.len(), 1);
163        assert_eq!(violations[0].rule_id, "require-img-alt");
164    }
165
166    #[test]
167    fn img_opening_element_without_alt() {
168        let rule = make_rule();
169        let violations = check(&rule, r#"function App() { return <img src="/a.jpg"></img>; }"#);
170        assert_eq!(violations.len(), 1);
171    }
172
173    #[test]
174    fn non_img_element_ignored() {
175        let rule = make_rule();
176        let violations = check(&rule, r#"function App() { return <div className="foo" />; }"#);
177        assert!(violations.is_empty());
178    }
179
180    #[test]
181    fn uppercase_image_component_ignored() {
182        let rule = make_rule();
183        let violations = check(&rule, r#"function App() { return <Image src="/a.jpg" />; }"#);
184        assert!(violations.is_empty());
185    }
186
187    #[test]
188    fn multiple_imgs_mixed() {
189        let rule = make_rule();
190        let content = r#"function App() {
191  return (
192    <div>
193      <img alt="ok" src="/a.jpg" />
194      <img src="/b.jpg" />
195      <img src="/c.jpg" />
196    </div>
197  );
198}"#;
199        let violations = check(&rule, content);
200        assert_eq!(violations.len(), 2);
201    }
202
203    #[test]
204    fn non_tsx_file_skipped() {
205        let rule = make_rule();
206        let ctx = ScanContext {
207            file_path: Path::new("test.rs"),
208            content: "fn main() {}",
209        };
210        assert!(rule.check_file(&ctx).is_empty());
211    }
212}