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#[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 fn generate_placeholder_text(&self, url_part: &str) -> String {
36 if self.config.placeholder_text != "TODO: Add image description" {
38 return self.config.placeholder_text.clone();
39 }
40
41 let url = if url_part.starts_with('(') && url_part.ends_with(')') {
43 &url_part[1..url_part.len() - 1]
44 } else {
45 return self.config.placeholder_text.clone();
47 };
48
49 if let Some(filename) = url.split('/').next_back() {
51 if let Some(name_without_ext) = filename.split('.').next() {
53 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 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 !ctx.content.contains("![")
90 }
91
92 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
93 let mut warnings = Vec::new();
94
95 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, end_line: image.line,
119 end_column: image.end_col + 1, message: "Image missing alt text (add description for accessibility: )"
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 result.push_str(&content[last_end..full_match.start()]);
147
148 if ctx.is_in_code_block_or_span(full_match.start()) {
150 result.push_str(&caps[0]);
152 } else if alt_text.trim().is_empty() {
153 let url_part = format!("({url})");
156 let placeholder = self.generate_placeholder_text(&url_part);
157 result.push_str(&format!(""));
158 } else {
159 result.push_str(&caps[0]);
161 }
162
163 last_end = full_match.end();
164 }
165
166 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 = "";
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 = "";
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 = "";
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 = "\n\n\n";
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 assert_eq!(rule.generate_placeholder_text("(sunset.jpg)"), "Sunset image");
260
261 assert_eq!(
263 rule.generate_placeholder_text("(my-beautiful-sunset.png)"),
264 "My Beautiful Sunset image"
265 );
266
267 assert_eq!(
269 rule.generate_placeholder_text("(team_photo_2024.jpg)"),
270 "Team Photo 2024 image"
271 );
272
273 assert_eq!(
275 rule.generate_placeholder_text("(https://example.com/images/profile-picture.png)"),
276 "Profile Picture image"
277 );
278
279 assert_eq!(
281 rule.generate_placeholder_text("[sunset]"),
282 "TODO: Add image description"
283 );
284
285 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 = "\n\n";
293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
294 let fixed = rule.fix(&ctx).unwrap();
295
296 assert_eq!(
297 fixed,
298 "\n\n"
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\n```";
316 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
317 let result = rule.check(&ctx).unwrap();
318
319 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 `` syntax";
327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
328 let result = rule.check(&ctx).unwrap();
329
330 assert_eq!(result.len(), 0);
332 }
333
334 #[test]
335 fn test_fix_empty_alt_text() {
336 let rule = MD045NoAltText::new();
337 let content = "";
338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
339 let fixed = rule.fix(&ctx).unwrap();
340
341 assert_eq!(fixed, "");
342 }
343
344 #[test]
345 fn test_fix_whitespace_alt_text() {
346 let rule = MD045NoAltText::new();
347 let content = "";
348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
349 let fixed = rule.fix(&ctx).unwrap();
350
351 assert_eq!(fixed, "");
352 }
353
354 #[test]
355 fn test_fix_multiple_images() {
356 let rule = MD045NoAltText::new();
357 let content = "  ";
358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
359 let fixed = rule.fix(&ctx).unwrap();
360
361 assert_eq!(
362 fixed,
363 "  "
364 );
365 }
366
367 #[test]
368 fn test_fix_preserves_existing_alt_text() {
369 let rule = MD045NoAltText::new();
370 let content = "";
371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
372 let fixed = rule.fix(&ctx).unwrap();
373
374 assert_eq!(fixed, "");
375 }
376
377 #[test]
378 fn test_fix_does_not_modify_code_blocks() {
379 let rule = MD045NoAltText::new();
380 let content = "```\n\n```\n";
381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
382 let fixed = rule.fix(&ctx).unwrap();
383
384 assert_eq!(fixed, "```\n\n```\n");
385 }
386
387 #[test]
388 fn test_complex_urls() {
389 let rule = MD045NoAltText::new();
390 let content = "";
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 = ".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 = "";
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 = "";
422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
423 let fixed = rule.fix(&ctx).unwrap();
424
425 assert_eq!(fixed, "");
426 }
427
428 #[test]
429 fn test_image_with_spaces_in_url() {
430 let rule = MD045NoAltText::new();
431 let content = "";
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  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); }
449
450 #[test]
451 fn test_multiline_content() {
452 let rule = MD045NoAltText::new();
453 let content = "Line 1\nLine 2 with \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 = "";
468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
469 let fixed = rule.fix(&ctx).unwrap();
470
471 assert_eq!(fixed, "");
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 = "  ";
482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
483 let fixed = rule.fix(&ctx).unwrap();
484
485 assert_eq!(
486 fixed,
487 "  "
489 );
490 }
491}