mdbook_lint_core/rules/standard/
md045.rs

1//! MD045: Images should have alternate text
2//!
3//! This rule checks that all images have non-empty alternate text for accessibility.
4
5use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11use comrak::nodes::{AstNode, NodeValue};
12
13/// Rule to check that images have alternate text
14pub struct MD045;
15
16impl MD045 {
17    /// Check if an image node has empty or missing alt text
18    fn is_empty_alt_text<'a>(&self, node: &'a AstNode<'a>) -> bool {
19        // Get all text content from the image's children
20        let text_content = Self::extract_text_content(node);
21        text_content.trim().is_empty()
22    }
23
24    /// Extract all text content from a node and its children
25    fn extract_text_content<'a>(node: &'a AstNode<'a>) -> String {
26        let mut content = String::new();
27
28        match &node.data.borrow().value {
29            NodeValue::Text(text) => {
30                content.push_str(text);
31            }
32            NodeValue::Code(code) => {
33                content.push_str(&code.literal);
34            }
35            _ => {}
36        }
37
38        // Recursively extract text from children
39        for child in node.children() {
40            content.push_str(&Self::extract_text_content(child));
41        }
42
43        content
44    }
45
46    /// Get line and column position for a node
47    fn get_position<'a>(&self, node: &'a AstNode<'a>) -> (usize, usize) {
48        let data = node.data.borrow();
49        let pos = data.sourcepos;
50        (pos.start.line, pos.start.column)
51    }
52
53    /// Walk AST and find all image violations
54    fn check_node<'a>(&self, node: &'a AstNode<'a>, violations: &mut Vec<Violation>) {
55        if let NodeValue::Image(_) = &node.data.borrow().value
56            && self.is_empty_alt_text(node)
57        {
58            let (line, column) = self.get_position(node);
59            violations.push(self.create_violation(
60                "Images should have alternate text".to_string(),
61                line,
62                column,
63                Severity::Warning,
64            ));
65        }
66
67        // Recursively check children
68        for child in node.children() {
69            self.check_node(child, violations);
70        }
71    }
72}
73
74impl AstRule for MD045 {
75    fn id(&self) -> &'static str {
76        "MD045"
77    }
78
79    fn name(&self) -> &'static str {
80        "no-alt-text"
81    }
82
83    fn description(&self) -> &'static str {
84        "Images should have alternate text"
85    }
86
87    fn metadata(&self) -> RuleMetadata {
88        RuleMetadata::stable(RuleCategory::Content).introduced_in("mdbook-lint v0.1.0")
89    }
90
91    fn check_ast<'a>(&self, _document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
92        let mut violations = Vec::new();
93        self.check_node(ast, &mut violations);
94        Ok(violations)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::rule::Rule;
102    use std::path::PathBuf;
103
104    fn create_test_document(content: &str) -> Document {
105        Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
106    }
107
108    #[test]
109    fn test_md045_images_with_alt_text_valid() {
110        let content = r#"Here is an image with alt text: ![Good alt text](image.png).
111
112Another ![descriptive text](image2.jpg) here.
113
114And a reference image: ![alt text][ref]
115
116[ref]: image3.gif
117"#;
118
119        let document = create_test_document(content);
120        let rule = MD045;
121        let violations = rule.check(&document).unwrap();
122        assert_eq!(violations.len(), 0);
123    }
124
125    #[test]
126    fn test_md045_images_without_alt_text_violation() {
127        let content = r#"Here is an image without alt text: ![](image.png).
128
129Another ![](image2.jpg) here.
130"#;
131
132        let document = create_test_document(content);
133        let rule = MD045;
134        let violations = rule.check(&document).unwrap();
135        assert_eq!(violations.len(), 2);
136        assert_eq!(violations[0].rule_id, "MD045");
137        assert!(
138            violations[0]
139                .message
140                .contains("Images should have alternate text")
141        );
142        assert_eq!(violations[0].line, 1);
143        assert_eq!(violations[1].line, 3);
144    }
145
146    #[test]
147    fn test_md045_images_with_whitespace_only_alt_text() {
148        let content = r#"Image with spaces: ![   ](image.png).
149
150Image with tabs: ![		](image2.jpg).
151
152Image with mixed whitespace: ![  	  ](image3.gif).
153"#;
154
155        let document = create_test_document(content);
156        let rule = MD045;
157        let violations = rule.check(&document).unwrap();
158        assert_eq!(violations.len(), 3);
159        assert_eq!(violations[0].line, 1);
160        assert_eq!(violations[1].line, 3);
161        assert_eq!(violations[2].line, 5);
162    }
163
164    #[test]
165    fn test_md045_images_with_code_alt_text_valid() {
166        let content = r#"Image with code alt text: ![`filename.png`](image.png).
167
168Another with inline code: ![The `main.rs` file](code.png).
169"#;
170
171        let document = create_test_document(content);
172        let rule = MD045;
173        let violations = rule.check(&document).unwrap();
174        assert_eq!(violations.len(), 0);
175    }
176
177    #[test]
178    fn test_md045_images_with_emphasis_alt_text_valid() {
179        let content = r#"Image with emphasis: ![*Important* diagram](diagram.png).
180
181Image with strong: ![**Critical** figure](figure.png).
182
183Image with mixed: ![*Very* **important** chart](chart.png).
184"#;
185
186        let document = create_test_document(content);
187        let rule = MD045;
188        let violations = rule.check(&document).unwrap();
189        assert_eq!(violations.len(), 0);
190    }
191
192    #[test]
193    fn test_md045_reference_images() {
194        let content = r#"Good reference image: ![Good alt text][good].
195
196Bad reference image: ![][bad].
197
198Another bad one: ![  ][also-bad].
199
200[good]: image1.png
201[bad]: image2.png
202[also-bad]: image3.png
203"#;
204
205        let document = create_test_document(content);
206        let rule = MD045;
207        let violations = rule.check(&document).unwrap();
208        assert_eq!(violations.len(), 2);
209        assert_eq!(violations[0].line, 3);
210        assert_eq!(violations[1].line, 5);
211    }
212
213    #[test]
214    fn test_md045_links_ignored() {
215        let content = r#"This is a [link without text]() which should not be flagged.
216
217This is a [](http://example.com) empty link, also not flagged by this rule.
218
219But this ![](image.png) empty image should be flagged.
220"#;
221
222        let document = create_test_document(content);
223        let rule = MD045;
224        let violations = rule.check(&document).unwrap();
225        assert_eq!(violations.len(), 1);
226        assert_eq!(violations[0].line, 5);
227    }
228
229    #[test]
230    fn test_md045_mixed_images_and_links() {
231        let content = r#"Good image: ![Alt text](image.png) and good [link](http://example.com).
232
233Bad image: ![](bad-image.png) and empty [](http://example.com) link.
234
235Another good image: ![Description](good.png) here.
236"#;
237
238        let document = create_test_document(content);
239        let rule = MD045;
240        let violations = rule.check(&document).unwrap();
241        assert_eq!(violations.len(), 1);
242        assert_eq!(violations[0].line, 3);
243    }
244
245    #[test]
246    fn test_md045_nested_formatting_in_alt_text() {
247        let content = r#"Complex alt text: ![Figure showing **bold** and *italic* with `code`](complex.png).
248
249Simple alt text: ![Just text](simple.png).
250
251Empty alt text: ![](empty.png).
252"#;
253
254        let document = create_test_document(content);
255        let rule = MD045;
256        let violations = rule.check(&document).unwrap();
257        assert_eq!(violations.len(), 1);
258        assert_eq!(violations[0].line, 5);
259    }
260
261    #[test]
262    fn test_md045_inline_images() {
263        let content = r#"Text with inline ![good alt](inline.png) image.
264
265Text with inline ![](bad-inline.png) empty image.
266
267More text with ![another good](good-inline.png) alt text.
268"#;
269
270        let document = create_test_document(content);
271        let rule = MD045;
272        let violations = rule.check(&document).unwrap();
273        assert_eq!(violations.len(), 1);
274        assert_eq!(violations[0].line, 3);
275    }
276
277    #[test]
278    fn test_md045_multiple_images_per_line() {
279        let content = r#"Multiple images: ![Good](img1.png) and ![](img2.png) and ![Also good](img3.png).
280
281All good: ![Alt 1](img4.png) and ![Alt 2](img5.png).
282
283All bad: ![](img6.png) and ![  ](img7.png).
284"#;
285
286        let document = create_test_document(content);
287        let rule = MD045;
288        let violations = rule.check(&document).unwrap();
289        assert_eq!(violations.len(), 3);
290        assert_eq!(violations[0].line, 1); // ![](img2.png)
291        assert_eq!(violations[1].line, 5); // ![](img6.png)
292        assert_eq!(violations[2].line, 5); // ![  ](img7.png)
293    }
294}