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            // Image syntax inside a template shortcode tag is a parameter to the
61            // shortcode, not an image the renderer emits, so there is no alt-text
62            // slot to fill.
63            if ctx.is_in_shortcode(image.byte_offset) {
64                continue;
65            }
66
67            if image.alt_text.trim().is_empty() {
68                warnings.push(LintWarning {
69                    rule_name: Some(self.name().to_string()),
70                    line: image.line,
71                    column: image.start_col + 1,
72                    end_line: image.line,
73                    end_column: image.end_col + 1,
74                    message: "Image missing alt text (add description for accessibility: ![description](url))"
75                        .to_string(),
76                    severity: Severity::Error,
77                    fix: None,
78                });
79            }
80        }
81
82        Ok(warnings)
83    }
84
85    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
86        Ok(ctx.content.to_string())
87    }
88
89    fn fix_capability(&self) -> FixCapability {
90        FixCapability::Unfixable
91    }
92
93    fn as_any(&self) -> &dyn std::any::Any {
94        self
95    }
96
97    // Not impl_rule_config_methods!: MD045Config has no fields, so the derived
98    // table is empty and the macro would return None. The empty Some keeps the
99    // [MD045] section recognized by config validation and `rumdl explain`.
100    fn default_config_section(&self) -> Option<(String, toml::Value)> {
101        use crate::rule_config_serde::RuleConfig;
102        Some((
103            MD045Config::RULE_NAME.to_string(),
104            toml::Value::Table(toml::map::Map::new()),
105        ))
106    }
107
108    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
109    where
110        Self: Sized,
111    {
112        let rule_config = crate::rule_config_serde::load_rule_config::<MD045Config>(config);
113        Box::new(Self::from_config_struct(rule_config))
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::lint_context::LintContext;
121
122    #[test]
123    fn test_image_with_alt_text() {
124        let rule = MD045NoAltText::new();
125        let content = "![A beautiful sunset](sunset.jpg)";
126        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
127        let result = rule.check(&ctx).unwrap();
128
129        assert_eq!(result.len(), 0);
130    }
131
132    #[test]
133    fn test_image_without_alt_text() {
134        let rule = MD045NoAltText::new();
135        let content = "![](sunset.jpg)";
136        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
137        let result = rule.check(&ctx).unwrap();
138
139        assert_eq!(result.len(), 1);
140        assert_eq!(result[0].line, 1);
141        assert!(result[0].message.contains("Image missing alt text"));
142    }
143
144    #[test]
145    fn test_no_fix_offered() {
146        let rule = MD045NoAltText::new();
147        let content = "![](sunset.jpg)";
148        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
149        let result = rule.check(&ctx).unwrap();
150
151        assert_eq!(result.len(), 1);
152        assert!(
153            result[0].fix.is_none(),
154            "MD045 should not offer auto-fix (alt text requires human judgment)"
155        );
156    }
157
158    #[test]
159    fn test_image_with_only_whitespace_alt_text() {
160        let rule = MD045NoAltText::new();
161        let content = "![   ](sunset.jpg)";
162        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
163        let result = rule.check(&ctx).unwrap();
164
165        assert_eq!(result.len(), 1);
166        assert_eq!(result[0].line, 1);
167        assert!(result[0].fix.is_none());
168    }
169
170    #[test]
171    fn test_multiple_images() {
172        let rule = MD045NoAltText::new();
173        let content = "![Good alt text](image1.jpg)\n![](image2.jpg)\n![Another good one](image3.jpg)\n![](image4.jpg)";
174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
175        let result = rule.check(&ctx).unwrap();
176
177        assert_eq!(result.len(), 2);
178        assert_eq!(result[0].line, 2);
179        assert_eq!(result[1].line, 4);
180    }
181
182    #[test]
183    fn test_reference_style_image() {
184        let rule = MD045NoAltText::new();
185        let content = "![][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(), 1);
190        assert_eq!(result[0].line, 1);
191    }
192
193    #[test]
194    fn test_reference_style_with_alt_text() {
195        let rule = MD045NoAltText::new();
196        let content = "![Beautiful sunset][sunset]\n\n[sunset]: sunset.jpg";
197        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
198        let result = rule.check(&ctx).unwrap();
199
200        assert_eq!(result.len(), 0);
201    }
202
203    #[test]
204    fn test_image_in_code_block() {
205        let rule = MD045NoAltText::new();
206        let content = "```\n![](image.jpg)\n```";
207        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
208        let result = rule.check(&ctx).unwrap();
209
210        assert_eq!(result.len(), 0);
211    }
212
213    #[test]
214    fn test_image_in_inline_code() {
215        let rule = MD045NoAltText::new();
216        let content = "Use `![](image.jpg)` syntax";
217        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
218        let result = rule.check(&ctx).unwrap();
219
220        assert_eq!(result.len(), 0);
221    }
222
223    #[test]
224    fn test_complex_urls() {
225        let rule = MD045NoAltText::new();
226        let content = "![](https://example.com/path/to/image.jpg?query=value#fragment)";
227        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
228        let result = rule.check(&ctx).unwrap();
229
230        assert_eq!(result.len(), 1);
231    }
232
233    #[test]
234    fn test_image_with_title() {
235        let rule = MD045NoAltText::new();
236        let content = "![](image.jpg \"Title text\")";
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!(result[0].message.contains("Image missing alt text"));
242    }
243
244    #[test]
245    fn test_column_positions() {
246        let rule = MD045NoAltText::new();
247        let content = "Text before ![](image.jpg) text after";
248        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
249        let result = rule.check(&ctx).unwrap();
250
251        assert_eq!(result.len(), 1);
252        assert_eq!(result[0].line, 1);
253        assert_eq!(result[0].column, 13);
254    }
255
256    #[test]
257    fn test_multiline_content() {
258        let rule = MD045NoAltText::new();
259        let content = "Line 1\nLine 2 with ![](image.jpg)\nLine 3";
260        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
261        let result = rule.check(&ctx).unwrap();
262
263        assert_eq!(result.len(), 1);
264        assert_eq!(result[0].line, 2);
265    }
266}