Skip to main content

rumdl_lib/rules/
md045_no_alt_text.rs

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