Skip to main content

rumdl_lib/rules/
md045_no_alt_text.rs

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