Skip to main content

rumdl_lib/rules/
md045_no_alt_text.rs

1use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3pub mod md045_config;
4use md045_config::MD045Config;
5
6/// Rule MD045: Images should have alt text
7///
8/// See [docs/md045.md](../../docs/md045.md) for full documentation, configuration, and examples.
9///
10/// This rule is triggered when an image is missing alternate text (alt text).
11/// This rule is diagnostic-only — it does not offer auto-fix because meaningful
12/// alt text requires human judgment. Automated placeholders are harmful for
13/// accessibility (screen readers would read fabricated text to users).
14#[derive(Clone)]
15pub struct MD045NoAltText {
16    config: MD045Config,
17}
18
19impl Default for MD045NoAltText {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl MD045NoAltText {
26    pub fn new() -> Self {
27        Self {
28            config: MD045Config::default(),
29        }
30    }
31
32    pub fn from_config_struct(config: MD045Config) -> Self {
33        Self { config }
34    }
35}
36
37impl Rule for MD045NoAltText {
38    fn name(&self) -> &'static str {
39        "MD045"
40    }
41
42    fn description(&self) -> &'static str {
43        "Images should have alternate text (alt text)"
44    }
45
46    fn category(&self) -> RuleCategory {
47        RuleCategory::Image
48    }
49
50    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
51        // Skip if no image syntax present
52        !ctx.likely_has_links_or_images()
53    }
54
55    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
56        let mut warnings = Vec::new();
57
58        for image in &ctx.images {
59            if image.alt_text.trim().is_empty() {
60                warnings.push(LintWarning {
61                    rule_name: Some(self.name().to_string()),
62                    line: image.line,
63                    column: image.start_col + 1,
64                    end_line: image.line,
65                    end_column: image.end_col + 1,
66                    message: "Image missing alt text (add description for accessibility: ![description](url))"
67                        .to_string(),
68                    severity: Severity::Error,
69                    fix: None,
70                });
71            }
72        }
73
74        Ok(warnings)
75    }
76
77    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
78        Ok(ctx.content.to_string())
79    }
80
81    fn fix_capability(&self) -> FixCapability {
82        FixCapability::Unfixable
83    }
84
85    fn as_any(&self) -> &dyn std::any::Any {
86        self
87    }
88
89    fn default_config_section(&self) -> Option<(String, toml::Value)> {
90        let json_value = serde_json::to_value(&self.config).ok()?;
91        Some((
92            self.name().to_string(),
93            crate::rule_config_serde::json_to_toml_value(&json_value)?,
94        ))
95    }
96
97    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
98    where
99        Self: Sized,
100    {
101        let rule_config = crate::rule_config_serde::load_rule_config::<MD045Config>(config);
102        Box::new(Self::from_config_struct(rule_config))
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::lint_context::LintContext;
110
111    #[test]
112    fn test_image_with_alt_text() {
113        let rule = MD045NoAltText::new();
114        let content = "![A beautiful sunset](sunset.jpg)";
115        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
116        let result = rule.check(&ctx).unwrap();
117
118        assert_eq!(result.len(), 0);
119    }
120
121    #[test]
122    fn test_image_without_alt_text() {
123        let rule = MD045NoAltText::new();
124        let content = "![](sunset.jpg)";
125        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
126        let result = rule.check(&ctx).unwrap();
127
128        assert_eq!(result.len(), 1);
129        assert_eq!(result[0].line, 1);
130        assert!(result[0].message.contains("Image missing alt text"));
131    }
132
133    #[test]
134    fn test_no_fix_offered() {
135        let rule = MD045NoAltText::new();
136        let content = "![](sunset.jpg)";
137        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
138        let result = rule.check(&ctx).unwrap();
139
140        assert_eq!(result.len(), 1);
141        assert!(
142            result[0].fix.is_none(),
143            "MD045 should not offer auto-fix (alt text requires human judgment)"
144        );
145    }
146
147    #[test]
148    fn test_image_with_only_whitespace_alt_text() {
149        let rule = MD045NoAltText::new();
150        let content = "![   ](sunset.jpg)";
151        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
152        let result = rule.check(&ctx).unwrap();
153
154        assert_eq!(result.len(), 1);
155        assert_eq!(result[0].line, 1);
156        assert!(result[0].fix.is_none());
157    }
158
159    #[test]
160    fn test_multiple_images() {
161        let rule = MD045NoAltText::new();
162        let content = "![Good alt text](image1.jpg)\n![](image2.jpg)\n![Another good one](image3.jpg)\n![](image4.jpg)";
163        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
164        let result = rule.check(&ctx).unwrap();
165
166        assert_eq!(result.len(), 2);
167        assert_eq!(result[0].line, 2);
168        assert_eq!(result[1].line, 4);
169    }
170
171    #[test]
172    fn test_reference_style_image() {
173        let rule = MD045NoAltText::new();
174        let content = "![][sunset]\n\n[sunset]: sunset.jpg";
175        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
176        let result = rule.check(&ctx).unwrap();
177
178        assert_eq!(result.len(), 1);
179        assert_eq!(result[0].line, 1);
180    }
181
182    #[test]
183    fn test_reference_style_with_alt_text() {
184        let rule = MD045NoAltText::new();
185        let content = "![Beautiful sunset][sunset]\n\n[sunset]: sunset.jpg";
186        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
187        let result = rule.check(&ctx).unwrap();
188
189        assert_eq!(result.len(), 0);
190    }
191
192    #[test]
193    fn test_image_in_code_block() {
194        let rule = MD045NoAltText::new();
195        let content = "```\n![](image.jpg)\n```";
196        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
197        let result = rule.check(&ctx).unwrap();
198
199        assert_eq!(result.len(), 0);
200    }
201
202    #[test]
203    fn test_image_in_inline_code() {
204        let rule = MD045NoAltText::new();
205        let content = "Use `![](image.jpg)` syntax";
206        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
207        let result = rule.check(&ctx).unwrap();
208
209        assert_eq!(result.len(), 0);
210    }
211
212    #[test]
213    fn test_complex_urls() {
214        let rule = MD045NoAltText::new();
215        let content = "![](https://example.com/path/to/image.jpg?query=value#fragment)";
216        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
217        let result = rule.check(&ctx).unwrap();
218
219        assert_eq!(result.len(), 1);
220    }
221
222    #[test]
223    fn test_image_with_title() {
224        let rule = MD045NoAltText::new();
225        let content = "![](image.jpg \"Title text\")";
226        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
227        let result = rule.check(&ctx).unwrap();
228
229        assert_eq!(result.len(), 1);
230        assert!(result[0].message.contains("Image missing alt text"));
231    }
232
233    #[test]
234    fn test_column_positions() {
235        let rule = MD045NoAltText::new();
236        let content = "Text before ![](image.jpg) text after";
237        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
238        let result = rule.check(&ctx).unwrap();
239
240        assert_eq!(result.len(), 1);
241        assert_eq!(result[0].line, 1);
242        assert_eq!(result[0].column, 13);
243    }
244
245    #[test]
246    fn test_multiline_content() {
247        let rule = MD045NoAltText::new();
248        let content = "Line 1\nLine 2 with ![](image.jpg)\nLine 3";
249        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
250        let result = rule.check(&ctx).unwrap();
251
252        assert_eq!(result.len(), 1);
253        assert_eq!(result[0].line, 2);
254    }
255}