1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::document_structure::DocumentStructure;
4use crate::utils::range_utils::calculate_line_range;
5use fancy_regex::Regex as FancyRegex;
6use lazy_static::lazy_static;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10
11lazy_static! {
12 static ref SHORTCUT_REFERENCE_REGEX: FancyRegex =
20 FancyRegex::new(r"(?<!\!)\[([^\]]+)\](?!\s*[\[(:])").unwrap();
21
22 static ref REFERENCE_DEFINITION_REGEX: Regex =
28 Regex::new(r"^\s*\[([^\]]+)\]:\s+(.+)$").unwrap();
29
30 static ref CONTINUATION_REGEX: Regex = Regex::new(r"^\s+(.+)$").unwrap();
32
33 static ref CODE_BLOCK_START_REGEX: Regex = Regex::new(r"^```").unwrap();
35 static ref CODE_BLOCK_END_REGEX: Regex = Regex::new(r"^```\s*$").unwrap();
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40#[serde(rename_all = "kebab-case")]
41pub struct MD053Config {
42 #[serde(default = "default_ignored_definitions")]
44 pub ignored_definitions: Vec<String>,
45}
46
47impl Default for MD053Config {
48 fn default() -> Self {
49 Self {
50 ignored_definitions: default_ignored_definitions(),
51 }
52 }
53}
54
55fn default_ignored_definitions() -> Vec<String> {
56 Vec::new()
57}
58
59impl RuleConfig for MD053Config {
60 const RULE_NAME: &'static str = "MD053";
61}
62
63#[derive(Clone)]
116pub struct MD053LinkImageReferenceDefinitions {
117 config: MD053Config,
118}
119
120impl MD053LinkImageReferenceDefinitions {
121 pub fn new() -> Self {
123 Self {
124 config: MD053Config::default(),
125 }
126 }
127
128 pub fn from_config_struct(config: MD053Config) -> Self {
130 Self { config }
131 }
132
133 fn unescape_reference(reference: &str) -> String {
140 reference.replace("\\", "")
142 }
143
144 fn find_definitions(
148 &self,
149 ctx: &crate::lint_context::LintContext,
150 doc_structure: &DocumentStructure,
151 ) -> HashMap<String, Vec<(usize, usize)>> {
152 let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
153
154 for ref_def in &ctx.reference_defs {
156 let normalized_id = Self::unescape_reference(&ref_def.id); definitions
159 .entry(normalized_id)
160 .or_default()
161 .push((ref_def.line - 1, ref_def.line - 1)); }
163
164 let lines = &ctx.lines;
166 let mut i = 0;
167 while i < lines.len() {
168 let line_info = &lines[i];
169 let line = &line_info.content;
170
171 if line_info.in_code_block || doc_structure.is_in_front_matter(i + 1) {
173 i += 1;
174 continue;
175 }
176
177 if i > 0 && CONTINUATION_REGEX.is_match(line) {
179 let mut def_start = i - 1;
181 while def_start > 0 && !REFERENCE_DEFINITION_REGEX.is_match(&lines[def_start].content) {
182 def_start -= 1;
183 }
184
185 if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&lines[def_start].content) {
186 let ref_id = caps.get(1).unwrap().as_str().trim();
187 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
188
189 if let Some(ranges) = definitions.get_mut(&normalized_id)
191 && let Some(last_range) = ranges.last_mut()
192 && last_range.0 == def_start
193 {
194 last_range.1 = i;
195 }
196 }
197 }
198 i += 1;
199 }
200 definitions
201 }
202
203 fn find_usages(
208 &self,
209 doc_structure: &DocumentStructure,
210 ctx: &crate::lint_context::LintContext,
211 ) -> HashSet<String> {
212 let mut usages: HashSet<String> = HashSet::new();
213
214 for link in &ctx.links {
216 if link.is_reference
217 && let Some(ref_id) = &link.reference_id
218 {
219 if !doc_structure.is_in_code_block(link.line) {
221 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
222 }
223 }
224 }
225
226 for image in &ctx.images {
228 if image.is_reference
229 && let Some(ref_id) = &image.reference_id
230 {
231 if !doc_structure.is_in_code_block(image.line) {
233 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
234 }
235 }
236 }
237
238 let code_spans = ctx.code_spans();
242
243 for (i, line_info) in ctx.lines.iter().enumerate() {
244 let line_num = i + 1; if line_info.in_code_block || doc_structure.is_in_front_matter(line_num) {
248 continue;
249 }
250
251 for caps in SHORTCUT_REFERENCE_REGEX.captures_iter(&line_info.content).flatten() {
253 if let Some(full_match) = caps.get(0)
254 && let Some(ref_id_match) = caps.get(1)
255 {
256 let match_byte_offset = line_info.byte_offset + full_match.start();
258 let in_code_span = code_spans
259 .iter()
260 .any(|span| match_byte_offset >= span.byte_offset && match_byte_offset < span.byte_end);
261
262 if !in_code_span {
263 let ref_id = ref_id_match.as_str().trim();
264 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
265 usages.insert(normalized_id);
266 }
267 }
268 }
269 }
270
271 usages
277 }
278
279 fn get_unused_references(
286 &self,
287 definitions: &HashMap<String, Vec<(usize, usize)>>,
288 usages: &HashSet<String>,
289 ) -> Vec<(String, usize, usize)> {
290 let mut unused = Vec::new();
291 for (id, ranges) in definitions {
292 if !usages.contains(id) && !self.is_ignored_definition(id) {
294 for (start, end) in ranges {
295 unused.push((id.clone(), *start, *end));
296 }
297 }
298 }
299 unused
300 }
301
302 fn is_ignored_definition(&self, definition_id: &str) -> bool {
304 self.config
305 .ignored_definitions
306 .iter()
307 .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
308 }
309
310 fn clean_up_blank_lines(&self, content: &str) -> String {
312 let lines: Vec<&str> = content.lines().collect();
313 let mut result_lines = Vec::new();
314 let mut consecutive_blanks = 0;
315
316 for line in lines {
317 if line.trim().is_empty() {
318 consecutive_blanks += 1;
319 if consecutive_blanks <= 1 {
320 result_lines.push(line);
322 }
323 } else {
324 consecutive_blanks = 0;
325 result_lines.push(line);
326 }
327 }
328
329 while !result_lines.is_empty() && result_lines[0].trim().is_empty() {
331 result_lines.remove(0);
332 }
333 while !result_lines.is_empty() && result_lines[result_lines.len() - 1].trim().is_empty() {
334 result_lines.pop();
335 }
336
337 result_lines.join("\n")
339 }
340}
341
342impl Default for MD053LinkImageReferenceDefinitions {
343 fn default() -> Self {
344 Self::new()
345 }
346}
347
348impl Rule for MD053LinkImageReferenceDefinitions {
349 fn name(&self) -> &'static str {
350 "MD053"
351 }
352
353 fn description(&self) -> &'static str {
354 "Link and image reference definitions should be needed"
355 }
356
357 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
361 let content = ctx.content;
362 let doc_structure = DocumentStructure::new(content);
364
365 let definitions = self.find_definitions(ctx, &doc_structure);
367 let usages = self.find_usages(&doc_structure, ctx);
368
369 let unused_refs = self.get_unused_references(&definitions, &usages);
371
372 let mut warnings = Vec::new();
373
374 for (definition, start, _end) in unused_refs {
376 let line_num = start + 1; let line_content = ctx.lines.get(start).map(|l| l.content.as_str()).unwrap_or("");
378
379 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
381
382 warnings.push(LintWarning {
383 rule_name: Some(self.name()),
384 line: start_line,
385 column: start_col,
386 end_line,
387 end_column: end_col,
388 message: format!("Unused link/image reference: [{definition}]"),
389 severity: Severity::Warning,
390 fix: Some(Fix {
391 range: {
393 let line_start = ctx.line_to_byte_offset(line_num).unwrap_or(0);
394 let line_end = if line_num < ctx.lines.len() {
395 ctx.line_to_byte_offset(line_num + 1).unwrap_or(content.len())
396 } else {
397 content.len()
398 };
399 line_start..line_end
400 },
401 replacement: String::new(), }),
403 });
404 }
405
406 Ok(warnings)
407 }
408
409 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
417 let content = ctx.content;
418 let doc_structure = DocumentStructure::new(content);
419
420 let definitions = self.find_definitions(ctx, &doc_structure);
422 let usages = self.find_usages(&doc_structure, ctx);
423
424 let unused_refs = self.get_unused_references(&definitions, &usages);
426
427 if unused_refs.is_empty() {
429 return Ok(content.to_string());
430 }
431
432 let mut lines_to_remove: Vec<(usize, usize)> =
434 unused_refs.iter().map(|(_, start, end)| (*start, *end)).collect();
435 lines_to_remove.sort_by(|a, b| b.0.cmp(&a.0)); let lines: Vec<&str> = ctx.lines.iter().map(|l| l.content.as_str()).collect();
439 let mut result_lines: Vec<&str> = lines.clone();
440
441 for (start_line, end_line) in lines_to_remove {
442 if start_line < result_lines.len() && end_line < result_lines.len() {
444 result_lines.drain(start_line..=end_line);
445 }
446 }
447
448 let mut result = result_lines.join("\n");
450
451 if content.ends_with('\n') && !result.ends_with('\n') {
453 result.push('\n');
454 }
455
456 let cleaned = self.clean_up_blank_lines(&result);
458
459 Ok(cleaned)
460 }
461
462 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
464 ctx.content.is_empty() || !ctx.content.contains("]:")
466 }
467
468 fn as_any(&self) -> &dyn std::any::Any {
469 self
470 }
471
472 fn default_config_section(&self) -> Option<(String, toml::Value)> {
473 let default_config = MD053Config::default();
474 let json_value = serde_json::to_value(&default_config).ok()?;
475 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
476 if let toml::Value::Table(table) = toml_value {
477 if !table.is_empty() {
478 Some((MD053Config::RULE_NAME.to_string(), toml::Value::Table(table)))
479 } else {
480 None
481 }
482 } else {
483 None
484 }
485 }
486
487 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
488 where
489 Self: Sized,
490 {
491 let rule_config = crate::rule_config_serde::load_rule_config::<MD053Config>(config);
492 Box::new(MD053LinkImageReferenceDefinitions::from_config_struct(rule_config))
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use crate::lint_context::LintContext;
500
501 #[test]
502 fn test_used_reference_link() {
503 let rule = MD053LinkImageReferenceDefinitions::new();
504 let content = "[text][ref]\n\n[ref]: https://example.com";
505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
506 let result = rule.check(&ctx).unwrap();
507
508 assert_eq!(result.len(), 0);
509 }
510
511 #[test]
512 fn test_unused_reference_definition() {
513 let rule = MD053LinkImageReferenceDefinitions::new();
514 let content = "[unused]: https://example.com";
515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
516 let result = rule.check(&ctx).unwrap();
517
518 assert_eq!(result.len(), 1);
519 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
520 }
521
522 #[test]
523 fn test_used_reference_image() {
524 let rule = MD053LinkImageReferenceDefinitions::new();
525 let content = "![alt][img]\n\n[img]: image.jpg";
526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
527 let result = rule.check(&ctx).unwrap();
528
529 assert_eq!(result.len(), 0);
530 }
531
532 #[test]
533 fn test_case_insensitive_matching() {
534 let rule = MD053LinkImageReferenceDefinitions::new();
535 let content = "[Text][REF]\n\n[ref]: https://example.com";
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
537 let result = rule.check(&ctx).unwrap();
538
539 assert_eq!(result.len(), 0);
540 }
541
542 #[test]
543 fn test_shortcut_reference() {
544 let rule = MD053LinkImageReferenceDefinitions::new();
545 let content = "[ref]\n\n[ref]: https://example.com";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
547 let result = rule.check(&ctx).unwrap();
548
549 assert_eq!(result.len(), 0);
550 }
551
552 #[test]
553 fn test_collapsed_reference() {
554 let rule = MD053LinkImageReferenceDefinitions::new();
555 let content = "[ref][]\n\n[ref]: https://example.com";
556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
557 let result = rule.check(&ctx).unwrap();
558
559 assert_eq!(result.len(), 0);
560 }
561
562 #[test]
563 fn test_multiple_unused_definitions() {
564 let rule = MD053LinkImageReferenceDefinitions::new();
565 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
567 let result = rule.check(&ctx).unwrap();
568
569 assert_eq!(result.len(), 3);
570
571 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
573 assert!(messages.iter().any(|m| m.contains("unused1")));
574 assert!(messages.iter().any(|m| m.contains("unused2")));
575 assert!(messages.iter().any(|m| m.contains("unused3")));
576 }
577
578 #[test]
579 fn test_mixed_used_and_unused() {
580 let rule = MD053LinkImageReferenceDefinitions::new();
581 let content = "[used]\n\n[used]: url1\n[unused]: url2";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
583 let result = rule.check(&ctx).unwrap();
584
585 assert_eq!(result.len(), 1);
586 assert!(result[0].message.contains("unused"));
587 }
588
589 #[test]
590 fn test_multiline_definition() {
591 let rule = MD053LinkImageReferenceDefinitions::new();
592 let content = "[ref]: https://example.com\n \"Title on next line\"";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
594 let result = rule.check(&ctx).unwrap();
595
596 assert_eq!(result.len(), 1); }
598
599 #[test]
600 fn test_reference_in_code_block() {
601 let rule = MD053LinkImageReferenceDefinitions::new();
602 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
604 let result = rule.check(&ctx).unwrap();
605
606 assert_eq!(result.len(), 1);
608 }
609
610 #[test]
611 fn test_reference_in_inline_code() {
612 let rule = MD053LinkImageReferenceDefinitions::new();
613 let content = "`[ref]`\n\n[ref]: https://example.com";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
615 let result = rule.check(&ctx).unwrap();
616
617 assert_eq!(result.len(), 1);
619 }
620
621 #[test]
622 fn test_escaped_reference() {
623 let rule = MD053LinkImageReferenceDefinitions::new();
624 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
626 let result = rule.check(&ctx).unwrap();
627
628 assert_eq!(result.len(), 0);
630 }
631
632 #[test]
633 fn test_duplicate_definitions() {
634 let rule = MD053LinkImageReferenceDefinitions::new();
635 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
637 let result = rule.check(&ctx).unwrap();
638
639 assert_eq!(result.len(), 0);
641 }
642
643 #[test]
644 fn test_fix_removes_unused_definition() {
645 let rule = MD053LinkImageReferenceDefinitions::new();
646 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
648 let fixed = rule.fix(&ctx).unwrap();
649
650 assert!(fixed.contains("[used]: url1"));
651 assert!(!fixed.contains("[unused]: url2"));
652 assert!(fixed.contains("More content"));
653 }
654
655 #[test]
656 fn test_fix_preserves_blank_lines() {
657 let rule = MD053LinkImageReferenceDefinitions::new();
658 let content = "Content\n\n[unused]: url\n\nMore content";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
660 let fixed = rule.fix(&ctx).unwrap();
661
662 assert_eq!(fixed, "Content\n\nMore content");
663 }
664
665 #[test]
666 fn test_fix_multiple_consecutive_definitions() {
667 let rule = MD053LinkImageReferenceDefinitions::new();
668 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
670 let fixed = rule.fix(&ctx).unwrap();
671
672 assert_eq!(fixed, "");
673 }
674
675 #[test]
676 fn test_special_characters_in_reference() {
677 let rule = MD053LinkImageReferenceDefinitions::new();
678 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
680 let result = rule.check(&ctx).unwrap();
681
682 assert_eq!(result.len(), 0);
683 }
684
685 #[test]
686 fn test_find_definitions() {
687 let rule = MD053LinkImageReferenceDefinitions::new();
688 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
690 let doc = DocumentStructure::new(content);
691 let defs = rule.find_definitions(&ctx, &doc);
692
693 assert_eq!(defs.len(), 3);
694 assert!(defs.contains_key("ref1"));
695 assert!(defs.contains_key("ref2"));
696 assert!(defs.contains_key("ref3"));
697 }
698
699 #[test]
700 fn test_find_usages() {
701 let rule = MD053LinkImageReferenceDefinitions::new();
702 let content = "[text][ref1] and [ref2] and ![img][ref3]";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
704 let doc = DocumentStructure::new(content);
705 let usages = rule.find_usages(&doc, &ctx);
706
707 assert!(usages.contains("ref1"));
708 assert!(usages.contains("ref2"));
709 assert!(usages.contains("ref3"));
710 }
711
712 #[test]
713 fn test_clean_up_blank_lines() {
714 let rule = MD053LinkImageReferenceDefinitions::new();
715
716 assert_eq!(rule.clean_up_blank_lines("text\n\n\n\nmore text"), "text\n\nmore text");
718
719 assert_eq!(rule.clean_up_blank_lines("\n\ntext\n\n"), "text");
721 }
722
723 #[test]
724 fn test_ignored_definitions_config() {
725 let config = MD053Config {
727 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
728 };
729 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
730
731 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
733 let result = rule.check(&ctx).unwrap();
734
735 assert_eq!(result.len(), 1);
737 assert!(result[0].message.contains("unused"));
738 assert!(!result[0].message.contains("todo"));
739 assert!(!result[0].message.contains("draft"));
740 }
741
742 #[test]
743 fn test_ignored_definitions_case_insensitive() {
744 let config = MD053Config {
746 ignored_definitions: vec!["TODO".to_string()],
747 };
748 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
749
750 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
752 let result = rule.check(&ctx).unwrap();
753
754 assert_eq!(result.len(), 1);
756 assert!(result[0].message.contains("unused"));
757 assert!(!result[0].message.contains("todo"));
758 }
759
760 #[test]
761 fn test_default_config_section() {
762 let rule = MD053LinkImageReferenceDefinitions::default();
763 let config_section = rule.default_config_section();
764
765 assert!(config_section.is_some());
766 let (name, value) = config_section.unwrap();
767 assert_eq!(name, "MD053");
768
769 if let toml::Value::Table(table) = value {
771 assert!(table.contains_key("ignored-definitions"));
772 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
773 } else {
774 panic!("Expected TOML table");
775 }
776 }
777
778 #[test]
779 fn test_fix_respects_ignored_definitions() {
780 let config = MD053Config {
782 ignored_definitions: vec!["template".to_string()],
783 };
784 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
785
786 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
788 let fixed = rule.fix(&ctx).unwrap();
789
790 assert!(fixed.contains("[template]: https://example.com/template"));
792 assert!(!fixed.contains("[unused]: https://example.com/unused"));
793 assert!(fixed.contains("Some content."));
794 }
795}