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's only field is the
91    // deprecated skip_serializing placeholder, so its section table is empty
92    // and the macro would return None. The empty Some keeps the [MD045]
93    // section recognized by config validation and `rumdl explain`.
94    fn default_config_section(&self) -> Option<(String, toml::Value)> {
95        use crate::rule_config_serde::RuleConfig;
96        Some((
97            MD045Config::RULE_NAME.to_string(),
98            toml::Value::Table(toml::map::Map::new()),
99        ))
100    }
101
102    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
103    where
104        Self: Sized,
105    {
106        let rule_config = crate::rule_config_serde::load_rule_config::<MD045Config>(config);
107        Box::new(Self::from_config_struct(rule_config))
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::lint_context::LintContext;
115
116    #[test]
117    fn test_image_with_alt_text() {
118        let rule = MD045NoAltText::new();
119        let content = "![A beautiful sunset](sunset.jpg)";
120        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
121        let result = rule.check(&ctx).unwrap();
122
123        assert_eq!(result.len(), 0);
124    }
125
126    #[test]
127    fn test_image_without_alt_text() {
128        let rule = MD045NoAltText::new();
129        let content = "![](sunset.jpg)";
130        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
131        let result = rule.check(&ctx).unwrap();
132
133        assert_eq!(result.len(), 1);
134        assert_eq!(result[0].line, 1);
135        assert!(result[0].message.contains("Image missing alt text"));
136    }
137
138    #[test]
139    fn test_no_fix_offered() {
140        let rule = MD045NoAltText::new();
141        let content = "![](sunset.jpg)";
142        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
143        let result = rule.check(&ctx).unwrap();
144
145        assert_eq!(result.len(), 1);
146        assert!(
147            result[0].fix.is_none(),
148            "MD045 should not offer auto-fix (alt text requires human judgment)"
149        );
150    }
151
152    #[test]
153    fn test_image_with_only_whitespace_alt_text() {
154        let rule = MD045NoAltText::new();
155        let content = "![   ](sunset.jpg)";
156        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
157        let result = rule.check(&ctx).unwrap();
158
159        assert_eq!(result.len(), 1);
160        assert_eq!(result[0].line, 1);
161        assert!(result[0].fix.is_none());
162    }
163
164    #[test]
165    fn test_multiple_images() {
166        let rule = MD045NoAltText::new();
167        let content = "![Good alt text](image1.jpg)\n![](image2.jpg)\n![Another good one](image3.jpg)\n![](image4.jpg)";
168        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
169        let result = rule.check(&ctx).unwrap();
170
171        assert_eq!(result.len(), 2);
172        assert_eq!(result[0].line, 2);
173        assert_eq!(result[1].line, 4);
174    }
175
176    #[test]
177    fn test_reference_style_image() {
178        let rule = MD045NoAltText::new();
179        let content = "![][sunset]\n\n[sunset]: sunset.jpg";
180        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
181        let result = rule.check(&ctx).unwrap();
182
183        assert_eq!(result.len(), 1);
184        assert_eq!(result[0].line, 1);
185    }
186
187    #[test]
188    fn test_reference_style_with_alt_text() {
189        let rule = MD045NoAltText::new();
190        let content = "![Beautiful sunset][sunset]\n\n[sunset]: sunset.jpg";
191        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
192        let result = rule.check(&ctx).unwrap();
193
194        assert_eq!(result.len(), 0);
195    }
196
197    #[test]
198    fn test_image_in_code_block() {
199        let rule = MD045NoAltText::new();
200        let content = "```\n![](image.jpg)\n```";
201        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
202        let result = rule.check(&ctx).unwrap();
203
204        assert_eq!(result.len(), 0);
205    }
206
207    #[test]
208    fn test_image_in_inline_code() {
209        let rule = MD045NoAltText::new();
210        let content = "Use `![](image.jpg)` syntax";
211        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
212        let result = rule.check(&ctx).unwrap();
213
214        assert_eq!(result.len(), 0);
215    }
216
217    #[test]
218    fn test_complex_urls() {
219        let rule = MD045NoAltText::new();
220        let content = "![](https://example.com/path/to/image.jpg?query=value#fragment)";
221        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
222        let result = rule.check(&ctx).unwrap();
223
224        assert_eq!(result.len(), 1);
225    }
226
227    #[test]
228    fn test_image_with_title() {
229        let rule = MD045NoAltText::new();
230        let content = "![](image.jpg \"Title text\")";
231        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
232        let result = rule.check(&ctx).unwrap();
233
234        assert_eq!(result.len(), 1);
235        assert!(result[0].message.contains("Image missing alt text"));
236    }
237
238    #[test]
239    fn test_column_positions() {
240        let rule = MD045NoAltText::new();
241        let content = "Text before ![](image.jpg) text after";
242        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
243        let result = rule.check(&ctx).unwrap();
244
245        assert_eq!(result.len(), 1);
246        assert_eq!(result[0].line, 1);
247        assert_eq!(result[0].column, 13);
248    }
249
250    #[test]
251    fn test_multiline_content() {
252        let rule = MD045NoAltText::new();
253        let content = "Line 1\nLine 2 with ![](image.jpg)\nLine 3";
254        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
255        let result = rule.check(&ctx).unwrap();
256
257        assert_eq!(result.len(), 1);
258        assert_eq!(result[0].line, 2);
259    }
260}