code_baseline/rules/ast/
require_img_alt.rs1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::parse_file;
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5pub 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}