rumdl_lib/rules/
md045_no_alt_text.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::regex_cache::IMAGE_REGEX;
3
4pub 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#[derive(Clone)]
13pub struct MD045NoAltText {
14    config: MD045Config,
15}
16
17impl Default for MD045NoAltText {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl MD045NoAltText {
24    pub fn new() -> Self {
25        Self {
26            config: MD045Config::default(),
27        }
28    }
29
30    pub fn from_config_struct(config: MD045Config) -> Self {
31        Self { config }
32    }
33
34    /// Generate a more context-aware placeholder text based on the image URL
35    fn generate_placeholder_text(&self, url_part: &str) -> String {
36        // If a custom placeholder is configured (not the default), always use it
37        if self.config.placeholder_text != "TODO: Add image description" {
38            return self.config.placeholder_text.clone();
39        }
40
41        // Extract the URL from the url_part (could be "(url)" or "[ref]")
42        let url = if url_part.starts_with('(') && url_part.ends_with(')') {
43            &url_part[1..url_part.len() - 1]
44        } else {
45            // For reference-style images, we can't determine the URL, use default
46            return self.config.placeholder_text.clone();
47        };
48
49        // Try to extract a meaningful filename from the URL
50        if let Some(filename) = url.split('/').next_back() {
51            // Remove the extension and common separators to create a readable description
52            if let Some(name_without_ext) = filename.split('.').next() {
53                // Replace common separators with spaces and capitalize words
54                let readable_name = name_without_ext
55                    .replace(['-', '_'], " ")
56                    .split_whitespace()
57                    .map(|word| {
58                        let mut chars = word.chars();
59                        match chars.next() {
60                            None => String::new(),
61                            Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
62                        }
63                    })
64                    .collect::<Vec<_>>()
65                    .join(" ");
66
67                if !readable_name.is_empty() {
68                    return format!("{readable_name} image");
69                }
70            }
71        }
72
73        // Fall back to the configured placeholder text
74        self.config.placeholder_text.clone()
75    }
76}
77
78impl Rule for MD045NoAltText {
79    fn name(&self) -> &'static str {
80        "MD045"
81    }
82
83    fn description(&self) -> &'static str {
84        "Images should have alternate text (alt text)"
85    }
86
87    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
88        // Skip if no image syntax present
89        !ctx.content.contains("![")
90    }
91
92    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
93        let mut warnings = Vec::new();
94
95        // Use centralized image parsing from LintContext
96        for image in &ctx.images {
97            if image.alt_text.trim().is_empty() {
98                let url_part = if image.is_reference {
99                    if let Some(ref_id) = &image.reference_id {
100                        format!("[{ref_id}]")
101                    } else {
102                        "[]".to_string()
103                    }
104                } else {
105                    format!("({})", image.url)
106                };
107
108                let placeholder = if image.is_reference {
109                    self.config.placeholder_text.clone()
110                } else {
111                    self.generate_placeholder_text(&format!("({})", &image.url))
112                };
113
114                warnings.push(LintWarning {
115                    rule_name: Some(self.name()),
116                    line: image.line,
117                    column: image.start_col + 1, // Convert to 1-indexed
118                    end_line: image.line,
119                    end_column: image.end_col + 1, // Convert to 1-indexed
120                    message: "Image missing alt text (add description for accessibility: ![description](url))"
121                        .to_string(),
122                    severity: Severity::Warning,
123                    fix: Some(Fix {
124                        range: image.byte_offset..image.byte_offset + (image.end_col - image.start_col),
125                        replacement: format!("![{placeholder}]{url_part}"),
126                    }),
127                });
128            }
129        }
130
131        Ok(warnings)
132    }
133
134    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
135        let content = ctx.content;
136
137        let mut result = String::new();
138        let mut last_end = 0;
139
140        for caps in IMAGE_REGEX.captures_iter(content) {
141            let full_match = caps.get(0).unwrap();
142            let alt_text = caps.get(1).map_or("", |m| m.as_str());
143            let url = caps.get(2).map_or("", |m| m.as_str());
144
145            // Add text before this match
146            result.push_str(&content[last_end..full_match.start()]);
147
148            // Check if this image is inside a code block
149            if ctx.is_in_code_block_or_span(full_match.start()) {
150                // Keep the original image if it's in a code block
151                result.push_str(&caps[0]);
152            } else if alt_text.trim().is_empty() {
153                // Generate a more helpful placeholder based on the image URL
154                // The centralized regex captures just the URL, so we need to add parentheses
155                let url_part = format!("({url})");
156                let placeholder = self.generate_placeholder_text(&url_part);
157                result.push_str(&format!("![{placeholder}]({url})"));
158            } else {
159                // Keep the original if alt text is not empty
160                result.push_str(&caps[0]);
161            }
162
163            last_end = full_match.end();
164        }
165
166        // Add any remaining text
167        result.push_str(&content[last_end..]);
168
169        Ok(result)
170    }
171
172    fn as_any(&self) -> &dyn std::any::Any {
173        self
174    }
175
176    fn default_config_section(&self) -> Option<(String, toml::Value)> {
177        let json_value = serde_json::to_value(&self.config).ok()?;
178        Some((
179            self.name().to_string(),
180            crate::rule_config_serde::json_to_toml_value(&json_value)?,
181        ))
182    }
183
184    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
185    where
186        Self: Sized,
187    {
188        let rule_config = crate::rule_config_serde::load_rule_config::<MD045Config>(config);
189        Box::new(Self::from_config_struct(rule_config))
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::lint_context::LintContext;
197
198    #[test]
199    fn test_image_with_alt_text() {
200        let rule = MD045NoAltText::new();
201        let content = "![A beautiful sunset](sunset.jpg)";
202        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
203        let result = rule.check(&ctx).unwrap();
204
205        assert_eq!(result.len(), 0);
206    }
207
208    #[test]
209    fn test_image_without_alt_text() {
210        let rule = MD045NoAltText::new();
211        let content = "![](sunset.jpg)";
212        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
213        let result = rule.check(&ctx).unwrap();
214
215        assert_eq!(result.len(), 1);
216        assert_eq!(result[0].line, 1);
217        assert!(result[0].message.contains("Image missing alt text"));
218    }
219
220    #[test]
221    fn test_image_with_only_whitespace_alt_text() {
222        let rule = MD045NoAltText::new();
223        let content = "![   ](sunset.jpg)";
224        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
225        let result = rule.check(&ctx).unwrap();
226
227        assert_eq!(result.len(), 1);
228        assert_eq!(result[0].line, 1);
229    }
230
231    #[test]
232    fn test_multiple_images() {
233        let rule = MD045NoAltText::new();
234        let content = "![Good alt text](image1.jpg)\n![](image2.jpg)\n![Another good one](image3.jpg)\n![](image4.jpg)";
235        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
236        let result = rule.check(&ctx).unwrap();
237
238        assert_eq!(result.len(), 2);
239        assert_eq!(result[0].line, 2);
240        assert_eq!(result[1].line, 4);
241    }
242
243    #[test]
244    fn test_reference_style_image() {
245        let rule = MD045NoAltText::new();
246        let content = "![][sunset]\n\n[sunset]: sunset.jpg";
247        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
248        let result = rule.check(&ctx).unwrap();
249
250        assert_eq!(result.len(), 1);
251        assert_eq!(result[0].line, 1);
252    }
253
254    #[test]
255    fn test_placeholder_text_generation() {
256        let rule = MD045NoAltText::new();
257
258        // Test with a simple filename
259        assert_eq!(rule.generate_placeholder_text("(sunset.jpg)"), "Sunset image");
260
261        // Test with hyphens in filename
262        assert_eq!(
263            rule.generate_placeholder_text("(my-beautiful-sunset.png)"),
264            "My Beautiful Sunset image"
265        );
266
267        // Test with underscores in filename
268        assert_eq!(
269            rule.generate_placeholder_text("(team_photo_2024.jpg)"),
270            "Team Photo 2024 image"
271        );
272
273        // Test with URL path
274        assert_eq!(
275            rule.generate_placeholder_text("(https://example.com/images/profile-picture.png)"),
276            "Profile Picture image"
277        );
278
279        // Test with reference-style (should use default)
280        assert_eq!(
281            rule.generate_placeholder_text("[sunset]"),
282            "TODO: Add image description"
283        );
284
285        // Test with empty filename
286        assert_eq!(rule.generate_placeholder_text("(.jpg)"), "TODO: Add image description");
287    }
288
289    #[test]
290    fn test_fix_with_smart_placeholders() {
291        let rule = MD045NoAltText::new();
292        let content = "![](team-photo.jpg)\n![](product_screenshot.png)\n![Good alt](logo.png)";
293        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
294        let fixed = rule.fix(&ctx).unwrap();
295
296        assert_eq!(
297            fixed,
298            "![Team Photo image](team-photo.jpg)\n![Product Screenshot image](product_screenshot.png)\n![Good alt](logo.png)"
299        );
300    }
301
302    #[test]
303    fn test_reference_style_with_alt_text() {
304        let rule = MD045NoAltText::new();
305        let content = "![Beautiful sunset][sunset]\n\n[sunset]: sunset.jpg";
306        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
307        let result = rule.check(&ctx).unwrap();
308
309        assert_eq!(result.len(), 0);
310    }
311
312    #[test]
313    fn test_image_in_code_block() {
314        let rule = MD045NoAltText::new();
315        let content = "```\n![](image.jpg)\n```";
316        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
317        let result = rule.check(&ctx).unwrap();
318
319        // Should not flag images in code blocks
320        assert_eq!(result.len(), 0);
321    }
322
323    #[test]
324    fn test_image_in_inline_code() {
325        let rule = MD045NoAltText::new();
326        let content = "Use `![](image.jpg)` syntax";
327        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
328        let result = rule.check(&ctx).unwrap();
329
330        // Should not flag images in inline code
331        assert_eq!(result.len(), 0);
332    }
333
334    #[test]
335    fn test_fix_empty_alt_text() {
336        let rule = MD045NoAltText::new();
337        let content = "![](sunset.jpg)";
338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
339        let fixed = rule.fix(&ctx).unwrap();
340
341        assert_eq!(fixed, "![Sunset image](sunset.jpg)");
342    }
343
344    #[test]
345    fn test_fix_whitespace_alt_text() {
346        let rule = MD045NoAltText::new();
347        let content = "![   ](sunset.jpg)";
348        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
349        let fixed = rule.fix(&ctx).unwrap();
350
351        assert_eq!(fixed, "![Sunset image](sunset.jpg)");
352    }
353
354    #[test]
355    fn test_fix_multiple_images() {
356        let rule = MD045NoAltText::new();
357        let content = "![Good](img1.jpg) ![](img2.jpg) ![](img3.jpg)";
358        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
359        let fixed = rule.fix(&ctx).unwrap();
360
361        assert_eq!(
362            fixed,
363            "![Good](img1.jpg) ![Img2 image](img2.jpg) ![Img3 image](img3.jpg)"
364        );
365    }
366
367    #[test]
368    fn test_fix_preserves_existing_alt_text() {
369        let rule = MD045NoAltText::new();
370        let content = "![This has alt text](image.jpg)";
371        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
372        let fixed = rule.fix(&ctx).unwrap();
373
374        assert_eq!(fixed, "![This has alt text](image.jpg)");
375    }
376
377    #[test]
378    fn test_fix_does_not_modify_code_blocks() {
379        let rule = MD045NoAltText::new();
380        let content = "```\n![](image.jpg)\n```\n![](real-image.jpg)";
381        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
382        let fixed = rule.fix(&ctx).unwrap();
383
384        assert_eq!(fixed, "```\n![](image.jpg)\n```\n![Real Image image](real-image.jpg)");
385    }
386
387    #[test]
388    fn test_complex_urls() {
389        let rule = MD045NoAltText::new();
390        let content = "![](https://example.com/path/to/image.jpg?query=value#fragment)";
391        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
392        let result = rule.check(&ctx).unwrap();
393
394        assert_eq!(result.len(), 1);
395    }
396
397    #[test]
398    fn test_nested_parentheses_in_url() {
399        let rule = MD045NoAltText::new();
400        let content = "![](image(1).jpg)";
401        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
402        let result = rule.check(&ctx).unwrap();
403
404        assert_eq!(result.len(), 1);
405    }
406
407    #[test]
408    fn test_image_with_title() {
409        let rule = MD045NoAltText::new();
410        let content = "![](image.jpg \"Title text\")";
411        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
412        let result = rule.check(&ctx).unwrap();
413
414        assert_eq!(result.len(), 1);
415        assert!(result[0].message.contains("Image missing alt text"));
416    }
417
418    #[test]
419    fn test_fix_preserves_title() {
420        let rule = MD045NoAltText::new();
421        let content = "![](image.jpg \"Title text\")";
422        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
423        let fixed = rule.fix(&ctx).unwrap();
424
425        assert_eq!(fixed, "![Image image](image.jpg \"Title text\")");
426    }
427
428    #[test]
429    fn test_image_with_spaces_in_url() {
430        let rule = MD045NoAltText::new();
431        let content = "![](my image.jpg)";
432        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
433        let result = rule.check(&ctx).unwrap();
434
435        assert_eq!(result.len(), 1);
436    }
437
438    #[test]
439    fn test_column_positions() {
440        let rule = MD045NoAltText::new();
441        let content = "Text before ![](image.jpg) text after";
442        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
443        let result = rule.check(&ctx).unwrap();
444
445        assert_eq!(result.len(), 1);
446        assert_eq!(result[0].line, 1);
447        assert_eq!(result[0].column, 13); // 1-indexed column
448    }
449
450    #[test]
451    fn test_multiline_content() {
452        let rule = MD045NoAltText::new();
453        let content = "Line 1\nLine 2 with ![](image.jpg)\nLine 3";
454        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
455        let result = rule.check(&ctx).unwrap();
456
457        assert_eq!(result.len(), 1);
458        assert_eq!(result[0].line, 2);
459    }
460
461    #[test]
462    fn test_custom_placeholder_text() {
463        let config = MD045Config {
464            placeholder_text: "FIXME: Add alt text".to_string(),
465        };
466        let rule = MD045NoAltText::from_config_struct(config);
467        let content = "![](image.jpg)";
468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
469        let fixed = rule.fix(&ctx).unwrap();
470
471        // When custom placeholder is set, smart placeholders are not used
472        assert_eq!(fixed, "![FIXME: Add alt text](image.jpg)");
473    }
474
475    #[test]
476    fn test_fix_multiple_with_custom_placeholder() {
477        let config = MD045Config {
478            placeholder_text: "MISSING ALT".to_string(),
479        };
480        let rule = MD045NoAltText::from_config_struct(config);
481        let content = "![Good](img1.jpg) ![](img2.jpg) ![   ](img3.jpg)";
482        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
483        let fixed = rule.fix(&ctx).unwrap();
484
485        assert_eq!(
486            fixed,
487            // When custom placeholder is set, smart placeholders are not used
488            "![Good](img1.jpg) ![MISSING ALT](img2.jpg) ![MISSING ALT](img3.jpg)"
489        );
490    }
491}