1use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::calculate_line_range;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::collections::{HashMap, HashSet};
7use std::sync::LazyLock;
8
9static SHORTCUT_REFERENCE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]").unwrap());
13
14static REFERENCE_DEFINITION_REGEX: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"^\s*\[([^\]]+)\]:\s+(.+)$").unwrap());
17
18static CONTINUATION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s+(.+)$").unwrap());
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23#[serde(rename_all = "kebab-case")]
24pub struct MD053Config {
25 #[serde(default = "default_ignored_definitions")]
27 pub ignored_definitions: Vec<String>,
28}
29
30impl Default for MD053Config {
31 fn default() -> Self {
32 Self {
33 ignored_definitions: default_ignored_definitions(),
34 }
35 }
36}
37
38fn default_ignored_definitions() -> Vec<String> {
39 Vec::new()
40}
41
42impl RuleConfig for MD053Config {
43 const RULE_NAME: &'static str = "MD053";
44}
45
46#[derive(Clone)]
98pub struct MD053LinkImageReferenceDefinitions {
99 config: MD053Config,
100}
101
102impl MD053LinkImageReferenceDefinitions {
103 pub fn new() -> Self {
105 Self {
106 config: MD053Config::default(),
107 }
108 }
109
110 pub fn from_config_struct(config: MD053Config) -> Self {
112 Self { config }
113 }
114
115 fn should_skip_pattern(text: &str) -> bool {
117 if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
120 return true;
121 }
122
123 if text == "*" || text == "..." || text == "**" {
125 return true;
126 }
127
128 if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
130 return true;
131 }
132
133 if text.len() <= 2 && !text.chars().all(char::is_alphanumeric) {
136 return true;
137 }
138
139 if text.contains(':') && text.contains(' ') && !text.contains('`') {
143 if let Some((before_colon, _)) = text.split_once(':') {
146 let before_trimmed = before_colon.trim();
147 let word_count = before_trimmed.split_whitespace().count();
149 if word_count >= 3 {
151 return true;
152 }
153 }
154 }
155
156 if text.starts_with('!') {
158 return true;
159 }
160
161 false
173 }
174
175 fn unescape_reference(reference: &str) -> String {
182 reference.replace('\\', "")
184 }
185
186 fn is_likely_comment_reference(ref_id: &str, url: &str) -> bool {
205 const COMMENT_LABELS: &[&str] = &[
207 "//", "comment", "note", "todo", "fixme", "hack", ];
214
215 let normalized_id = ref_id.trim().to_lowercase();
216 let normalized_url = url.trim();
217
218 if COMMENT_LABELS.contains(&normalized_id.as_str()) && normalized_url.starts_with('#') {
221 return true;
222 }
223
224 if normalized_url == "#" {
227 return true;
228 }
229
230 false
231 }
232
233 fn find_definitions(&self, ctx: &crate::lint_context::LintContext) -> HashMap<String, Vec<(usize, usize)>> {
237 let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
238
239 for ref_def in &ctx.reference_defs {
241 if Self::is_likely_comment_reference(&ref_def.id, &ref_def.url) {
243 continue;
244 }
245
246 let normalized_id = Self::unescape_reference(&ref_def.id); definitions
249 .entry(normalized_id)
250 .or_default()
251 .push((ref_def.line - 1, ref_def.line - 1)); }
253
254 let lines = &ctx.lines;
256 let mut last_def_line: Option<usize> = None;
257 let mut last_def_id: Option<String> = None;
258
259 for (i, line_info) in lines.iter().enumerate() {
260 if line_info.in_code_block || line_info.in_front_matter {
261 last_def_line = None;
262 last_def_id = None;
263 continue;
264 }
265
266 let line = line_info.content(ctx.content);
267
268 if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line) {
269 let ref_id = caps.get(1).unwrap().as_str().trim();
271 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
272 last_def_line = Some(i);
273 last_def_id = Some(normalized_id);
274 } else if let Some(def_start) = last_def_line
275 && let Some(ref def_id) = last_def_id
276 && CONTINUATION_REGEX.is_match(line)
277 {
278 if let Some(ranges) = definitions.get_mut(def_id.as_str())
280 && let Some(last_range) = ranges.last_mut()
281 && last_range.0 == def_start
282 {
283 last_range.1 = i;
284 }
285 } else {
286 last_def_line = None;
288 last_def_id = None;
289 }
290 }
291 definitions
292 }
293
294 fn find_usages(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
299 let mut usages: HashSet<String> = HashSet::new();
300
301 for link in &ctx.links {
303 if link.is_reference
304 && let Some(ref_id) = &link.reference_id
305 && !ctx.line_info(link.line).is_some_and(|info| info.in_code_block)
306 {
307 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
308 }
309 }
310
311 for image in &ctx.images {
313 if image.is_reference
314 && let Some(ref_id) = &image.reference_id
315 && !ctx.line_info(image.line).is_some_and(|info| info.in_code_block)
316 {
317 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
318 }
319 }
320
321 for footnote_ref in &ctx.footnote_refs {
323 if !ctx.line_info(footnote_ref.line).is_some_and(|info| info.in_code_block) {
324 let ref_id = format!("^{}", footnote_ref.id);
325 usages.insert(ref_id.to_lowercase());
326 }
327 }
328
329 let code_spans = ctx.code_spans();
332
333 let mut span_ranges: Vec<(usize, usize)> = code_spans
335 .iter()
336 .map(|span| (span.byte_offset, span.byte_end))
337 .collect();
338 span_ranges.sort_unstable_by_key(|&(start, _)| start);
339
340 for line_info in &ctx.lines {
341 if line_info.in_code_block || line_info.in_front_matter {
342 continue;
343 }
344
345 let line_content = line_info.content(ctx.content);
346
347 if !line_content.contains('[') {
349 continue;
350 }
351
352 if REFERENCE_DEFINITION_REGEX.is_match(line_content) {
354 continue;
355 }
356
357 for caps in SHORTCUT_REFERENCE_REGEX.captures_iter(line_content) {
358 if let Some(full_match) = caps.get(0)
359 && let Some(ref_id_match) = caps.get(1)
360 {
361 let match_start = full_match.start();
362
363 if match_start > 0 && line_content.as_bytes()[match_start - 1] == b'!' {
365 continue;
366 }
367
368 let match_end = full_match.end();
370 if match_end < line_content.len() && line_content.as_bytes()[match_end] == b'[' {
371 continue;
372 }
373
374 let match_byte_offset = line_info.byte_offset + match_start;
375
376 let in_code_span = span_ranges
378 .binary_search_by(|&(start, end)| {
379 if match_byte_offset < start {
380 std::cmp::Ordering::Greater
381 } else if match_byte_offset >= end {
382 std::cmp::Ordering::Less
383 } else {
384 std::cmp::Ordering::Equal
385 }
386 })
387 .is_ok();
388
389 if !in_code_span {
390 let ref_id = ref_id_match.as_str().trim();
391
392 if !Self::should_skip_pattern(ref_id) {
393 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
394 usages.insert(normalized_id);
395 }
396 }
397 }
398 }
399 }
400
401 usages
402 }
403
404 fn get_unused_references(
411 &self,
412 definitions: &HashMap<String, Vec<(usize, usize)>>,
413 usages: &HashSet<String>,
414 ) -> Vec<(String, usize, usize)> {
415 let mut unused = Vec::new();
416 for (id, ranges) in definitions {
417 if !usages.contains(id) && !self.is_ignored_definition(id) {
419 if ranges.len() == 1 {
422 let (start, end) = ranges[0];
423 unused.push((id.clone(), start, end));
424 }
425 }
428 }
429 unused
430 }
431
432 fn is_ignored_definition(&self, definition_id: &str) -> bool {
434 self.config
435 .ignored_definitions
436 .iter()
437 .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
438 }
439}
440
441impl Default for MD053LinkImageReferenceDefinitions {
442 fn default() -> Self {
443 Self::new()
444 }
445}
446
447impl Rule for MD053LinkImageReferenceDefinitions {
448 fn name(&self) -> &'static str {
449 "MD053"
450 }
451
452 fn description(&self) -> &'static str {
453 "Link and image reference definitions should be needed"
454 }
455
456 fn category(&self) -> RuleCategory {
457 RuleCategory::Link
458 }
459
460 fn fix_capability(&self) -> FixCapability {
461 FixCapability::Unfixable
462 }
463
464 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
468 let definitions = self.find_definitions(ctx);
470 let usages = self.find_usages(ctx);
471
472 let unused_refs = self.get_unused_references(&definitions, &usages);
474
475 let mut warnings = Vec::new();
476
477 let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); for (definition_id, ranges) in &definitions {
481 if self.is_ignored_definition(definition_id) {
483 continue;
484 }
485
486 if ranges.len() > 1 {
487 for (i, &(start_line, _)) in ranges.iter().enumerate() {
489 if i > 0 {
490 let line_num = start_line + 1;
492 let line_content = ctx.lines.get(start_line).map_or("", |l| l.content(ctx.content));
493 let (start_line_1idx, start_col, end_line, end_col) =
494 calculate_line_range(line_num, line_content);
495
496 warnings.push(LintWarning {
497 rule_name: Some(self.name().to_string()),
498 line: start_line_1idx,
499 column: start_col,
500 end_line,
501 end_column: end_col,
502 message: format!("Duplicate link or image reference definition: [{definition_id}]"),
503 severity: Severity::Warning,
504 fix: None,
505 });
506 }
507 }
508 }
509
510 if let Some(&(start_line, _)) = ranges.first() {
512 if let Some(line_info) = ctx.lines.get(start_line)
514 && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line_info.content(ctx.content))
515 {
516 let original_id = caps.get(1).unwrap().as_str().trim();
517 let lower_id = original_id.to_lowercase();
518
519 if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
520 if first_original != original_id {
522 let line_num = start_line + 1;
523 let line_content = line_info.content(ctx.content);
524 let (start_line_1idx, start_col, end_line, end_col) =
525 calculate_line_range(line_num, line_content);
526
527 warnings.push(LintWarning {
528 rule_name: Some(self.name().to_string()),
529 line: start_line_1idx,
530 column: start_col,
531 end_line,
532 end_column: end_col,
533 message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
534 original_id, first_original, first_line + 1),
535 severity: Severity::Warning,
536 fix: None,
537 });
538 }
539 } else {
540 seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
541 }
542 }
543 }
544 }
545
546 for (definition, start, _end) in unused_refs {
548 let line_num = start + 1; let line_content = ctx.lines.get(start).map_or("", |l| l.content(ctx.content));
550
551 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
553
554 warnings.push(LintWarning {
555 rule_name: Some(self.name().to_string()),
556 line: start_line,
557 column: start_col,
558 end_line,
559 end_column: end_col,
560 message: format!("Unused link/image reference: [{definition}]"),
561 severity: Severity::Warning,
562 fix: None, });
564 }
565
566 Ok(warnings)
567 }
568
569 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
571 Ok(ctx.content.to_string())
573 }
574
575 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
577 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
579 }
580
581 fn as_any(&self) -> &dyn std::any::Any {
582 self
583 }
584
585 crate::impl_rule_config_methods!(MD053Config);
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::lint_context::LintContext;
592
593 #[test]
594 fn test_used_reference_link() {
595 let rule = MD053LinkImageReferenceDefinitions::new();
596 let content = "[text][ref]\n\n[ref]: https://example.com";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598 let result = rule.check(&ctx).unwrap();
599
600 assert_eq!(result.len(), 0);
601 }
602
603 #[test]
604 fn test_unused_reference_definition() {
605 let rule = MD053LinkImageReferenceDefinitions::new();
606 let content = "[unused]: https://example.com";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let result = rule.check(&ctx).unwrap();
609
610 assert_eq!(result.len(), 1);
611 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
612 }
613
614 #[test]
615 fn test_used_reference_image() {
616 let rule = MD053LinkImageReferenceDefinitions::new();
617 let content = "![alt][img]\n\n[img]: image.jpg";
618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
619 let result = rule.check(&ctx).unwrap();
620
621 assert_eq!(result.len(), 0);
622 }
623
624 #[test]
625 fn test_case_insensitive_matching() {
626 let rule = MD053LinkImageReferenceDefinitions::new();
627 let content = "[Text][REF]\n\n[ref]: https://example.com";
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let result = rule.check(&ctx).unwrap();
630
631 assert_eq!(result.len(), 0);
632 }
633
634 #[test]
635 fn test_shortcut_reference() {
636 let rule = MD053LinkImageReferenceDefinitions::new();
637 let content = "[ref]\n\n[ref]: https://example.com";
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639 let result = rule.check(&ctx).unwrap();
640
641 assert_eq!(result.len(), 0);
642 }
643
644 #[test]
645 fn test_collapsed_reference() {
646 let rule = MD053LinkImageReferenceDefinitions::new();
647 let content = "[ref][]\n\n[ref]: https://example.com";
648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649 let result = rule.check(&ctx).unwrap();
650
651 assert_eq!(result.len(), 0);
652 }
653
654 #[test]
655 fn test_multiple_unused_definitions() {
656 let rule = MD053LinkImageReferenceDefinitions::new();
657 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660
661 assert_eq!(result.len(), 3);
662
663 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
665 assert!(messages.iter().any(|m| m.contains("unused1")));
666 assert!(messages.iter().any(|m| m.contains("unused2")));
667 assert!(messages.iter().any(|m| m.contains("unused3")));
668 }
669
670 #[test]
671 fn test_mixed_used_and_unused() {
672 let rule = MD053LinkImageReferenceDefinitions::new();
673 let content = "[used]\n\n[used]: url1\n[unused]: url2";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676
677 assert_eq!(result.len(), 1);
678 assert!(result[0].message.contains("unused"));
679 }
680
681 #[test]
682 fn test_multiline_definition() {
683 let rule = MD053LinkImageReferenceDefinitions::new();
684 let content = "[ref]: https://example.com\n \"Title on next line\"";
685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686 let result = rule.check(&ctx).unwrap();
687
688 assert_eq!(result.len(), 1); }
690
691 #[test]
692 fn test_reference_in_code_block() {
693 let rule = MD053LinkImageReferenceDefinitions::new();
694 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696 let result = rule.check(&ctx).unwrap();
697
698 assert_eq!(result.len(), 1);
700 }
701
702 #[test]
703 fn test_reference_in_inline_code() {
704 let rule = MD053LinkImageReferenceDefinitions::new();
705 let content = "`[ref]`\n\n[ref]: https://example.com";
706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707 let result = rule.check(&ctx).unwrap();
708
709 assert_eq!(result.len(), 1);
711 }
712
713 #[test]
714 fn test_escaped_reference() {
715 let rule = MD053LinkImageReferenceDefinitions::new();
716 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718 let result = rule.check(&ctx).unwrap();
719
720 assert_eq!(result.len(), 0);
722 }
723
724 #[test]
725 fn test_duplicate_definitions() {
726 let rule = MD053LinkImageReferenceDefinitions::new();
727 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.check(&ctx).unwrap();
730
731 assert_eq!(result.len(), 1);
733 }
734
735 #[test]
736 fn test_fix_returns_original() {
737 let rule = MD053LinkImageReferenceDefinitions::new();
739 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let fixed = rule.fix(&ctx).unwrap();
742
743 assert_eq!(fixed, content);
744 }
745
746 #[test]
747 fn test_fix_preserves_content() {
748 let rule = MD053LinkImageReferenceDefinitions::new();
750 let content = "Content\n\n[unused]: url\n\nMore content";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752 let fixed = rule.fix(&ctx).unwrap();
753
754 assert_eq!(fixed, content);
755 }
756
757 #[test]
758 fn test_fix_does_not_remove() {
759 let rule = MD053LinkImageReferenceDefinitions::new();
761 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let fixed = rule.fix(&ctx).unwrap();
764
765 assert_eq!(fixed, content);
766 }
767
768 #[test]
769 fn test_special_characters_in_reference() {
770 let rule = MD053LinkImageReferenceDefinitions::new();
771 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.check(&ctx).unwrap();
774
775 assert_eq!(result.len(), 0);
776 }
777
778 #[test]
779 fn test_find_definitions() {
780 let rule = MD053LinkImageReferenceDefinitions::new();
781 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let defs = rule.find_definitions(&ctx);
784
785 assert_eq!(defs.len(), 3);
786 assert!(defs.contains_key("ref1"));
787 assert!(defs.contains_key("ref2"));
788 assert!(defs.contains_key("ref3"));
789 }
790
791 #[test]
792 fn test_find_usages() {
793 let rule = MD053LinkImageReferenceDefinitions::new();
794 let content = "[text][ref1] and [ref2] and ![img][ref3]";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let usages = rule.find_usages(&ctx);
797
798 assert!(usages.contains("ref1"));
799 assert!(usages.contains("ref2"));
800 assert!(usages.contains("ref3"));
801 }
802
803 #[test]
804 fn test_ignored_definitions_config() {
805 let config = MD053Config {
807 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
808 };
809 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
810
811 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813 let result = rule.check(&ctx).unwrap();
814
815 assert_eq!(result.len(), 1);
817 assert!(result[0].message.contains("unused"));
818 assert!(!result[0].message.contains("todo"));
819 assert!(!result[0].message.contains("draft"));
820 }
821
822 #[test]
823 fn test_ignored_definitions_case_insensitive() {
824 let config = MD053Config {
826 ignored_definitions: vec!["TODO".to_string()],
827 };
828 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
829
830 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
832 let result = rule.check(&ctx).unwrap();
833
834 assert_eq!(result.len(), 1);
836 assert!(result[0].message.contains("unused"));
837 assert!(!result[0].message.contains("todo"));
838 }
839
840 #[test]
841 fn test_default_config_section() {
842 let rule = MD053LinkImageReferenceDefinitions::default();
843 let config_section = rule.default_config_section();
844
845 assert!(config_section.is_some());
846 let (name, value) = config_section.unwrap();
847 assert_eq!(name, "MD053");
848
849 if let toml::Value::Table(table) = value {
851 assert!(table.contains_key("ignored-definitions"));
852 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
853 } else {
854 panic!("Expected TOML table");
855 }
856 }
857
858 #[test]
859 fn test_fix_with_ignored_definitions() {
860 let config = MD053Config {
862 ignored_definitions: vec!["template".to_string()],
863 };
864 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
865
866 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868 let fixed = rule.fix(&ctx).unwrap();
869
870 assert_eq!(fixed, content);
872 }
873
874 #[test]
875 fn test_duplicate_definitions_exact_case() {
876 let rule = MD053LinkImageReferenceDefinitions::new();
877 let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let result = rule.check(&ctx).unwrap();
880
881 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
884 assert_eq!(duplicate_warnings.len(), 2);
885 assert_eq!(duplicate_warnings[0].line, 2);
886 assert_eq!(duplicate_warnings[1].line, 3);
887 }
888
889 #[test]
890 fn test_duplicate_definitions_case_variants() {
891 let rule = MD053LinkImageReferenceDefinitions::new();
892 let content =
893 "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895 let result = rule.check(&ctx).unwrap();
896
897 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
900 assert_eq!(duplicate_warnings.len(), 2);
901
902 assert_eq!(duplicate_warnings[0].line, 2);
905 assert_eq!(duplicate_warnings[1].line, 3);
906 }
907
908 #[test]
909 fn test_duplicate_and_unused() {
910 let rule = MD053LinkImageReferenceDefinitions::new();
911 let content = "[used]\n[used]: url1\n[used]: url2\n[unused]: url3";
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913 let result = rule.check(&ctx).unwrap();
914
915 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
917 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
918
919 assert_eq!(duplicate_warnings.len(), 1);
920 assert_eq!(unused_warnings.len(), 1);
921 assert_eq!(duplicate_warnings[0].line, 3); assert_eq!(unused_warnings[0].line, 4); }
924
925 #[test]
926 fn test_duplicate_with_usage() {
927 let rule = MD053LinkImageReferenceDefinitions::new();
928 let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931 let result = rule.check(&ctx).unwrap();
932
933 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
935 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
936
937 assert_eq!(duplicate_warnings.len(), 1);
938 assert_eq!(unused_warnings.len(), 0);
939 assert_eq!(duplicate_warnings[0].line, 4);
940 }
941
942 #[test]
943 fn test_no_duplicate_different_ids() {
944 let rule = MD053LinkImageReferenceDefinitions::new();
945 let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947 let result = rule.check(&ctx).unwrap();
948
949 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
951 assert_eq!(duplicate_warnings.len(), 0);
952 }
953
954 #[test]
955 fn test_comment_style_reference_double_slash() {
956 let rule = MD053LinkImageReferenceDefinitions::new();
957 let content = "[//]: # (This is a comment)\n\nSome regular text.";
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.check(&ctx).unwrap();
961
962 assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
964 }
965
966 #[test]
967 fn test_comment_style_reference_comment_label() {
968 let rule = MD053LinkImageReferenceDefinitions::new();
969 let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let result = rule.check(&ctx).unwrap();
973
974 assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
976 }
977
978 #[test]
979 fn test_comment_style_reference_todo_fixme() {
980 let rule = MD053LinkImageReferenceDefinitions::new();
981 let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985
986 assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
988 }
989
990 #[test]
991 fn test_comment_style_reference_fragment_only() {
992 let rule = MD053LinkImageReferenceDefinitions::new();
993 let content = "[anything]: #\n[ref]: #\n\nSome text.";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997
998 assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
1000 }
1001
1002 #[test]
1003 fn test_comment_vs_real_reference() {
1004 let rule = MD053LinkImageReferenceDefinitions::new();
1005 let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
1007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008 let result = rule.check(&ctx).unwrap();
1009
1010 assert_eq!(result.len(), 1, "Only real unused references should be flagged");
1012 assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
1013 }
1014
1015 #[test]
1016 fn test_comment_with_fragment_section() {
1017 let rule = MD053LinkImageReferenceDefinitions::new();
1018 let content = "[//]: #section (Comment about section)\n\nSome text.";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021 let result = rule.check(&ctx).unwrap();
1022
1023 assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1025 }
1026
1027 #[test]
1028 fn test_is_likely_comment_reference_helper() {
1029 assert!(
1031 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1032 "[//]: # should be recognized as comment"
1033 );
1034 assert!(
1035 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1036 "[comment]: #section should be recognized as comment"
1037 );
1038 assert!(
1039 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1040 "[note]: # should be recognized as comment"
1041 );
1042 assert!(
1043 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1044 "[todo]: # should be recognized as comment"
1045 );
1046 assert!(
1047 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1048 "Any label with just '#' should be recognized as comment"
1049 );
1050 assert!(
1051 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1052 "Real URL should not be recognized as comment"
1053 );
1054 assert!(
1055 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1056 "Real URL should not be recognized as comment"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_reference_with_colon_in_name() {
1062 let rule = MD053LinkImageReferenceDefinitions::new();
1064 let content = "Check [RFC: 1234] for specs.\n\n[RFC: 1234]: https://example.com\n";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let result = rule.check(&ctx).unwrap();
1067
1068 assert!(
1069 result.is_empty(),
1070 "Reference with colon should be recognized as used, got warnings: {result:?}"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_reference_with_colon_various_styles() {
1076 let rule = MD053LinkImageReferenceDefinitions::new();
1078 let content = r#"See [RFC: 1234] and [Issue: 42] and [PR: 100].
1079
1080[RFC: 1234]: https://example.com/rfc1234
1081[Issue: 42]: https://example.com/issue42
1082[PR: 100]: https://example.com/pr100
1083"#;
1084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1085 let result = rule.check(&ctx).unwrap();
1086
1087 assert!(
1088 result.is_empty(),
1089 "All colon-style references should be recognized as used, got warnings: {result:?}"
1090 );
1091 }
1092
1093 #[test]
1094 fn test_should_skip_pattern_allows_rfc_style() {
1095 assert!(
1098 !MD053LinkImageReferenceDefinitions::should_skip_pattern("RFC: 1234"),
1099 "RFC-style references should NOT be skipped"
1100 );
1101 assert!(
1102 !MD053LinkImageReferenceDefinitions::should_skip_pattern("Issue: 42"),
1103 "Issue-style references should NOT be skipped"
1104 );
1105 assert!(
1106 !MD053LinkImageReferenceDefinitions::should_skip_pattern("PR: 100"),
1107 "PR-style references should NOT be skipped"
1108 );
1109 assert!(
1110 !MD053LinkImageReferenceDefinitions::should_skip_pattern("See: Section 2"),
1111 "References with 'See:' should NOT be skipped"
1112 );
1113 assert!(
1114 !MD053LinkImageReferenceDefinitions::should_skip_pattern("foo:bar"),
1115 "References without space after colon should NOT be skipped"
1116 );
1117 }
1118
1119 #[test]
1120 fn test_should_skip_pattern_skips_prose() {
1121 assert!(
1123 MD053LinkImageReferenceDefinitions::should_skip_pattern("default value is: something"),
1124 "Prose with 3+ words before colon SHOULD be skipped"
1125 );
1126 assert!(
1127 MD053LinkImageReferenceDefinitions::should_skip_pattern("this is a label: description"),
1128 "Prose with 4 words before colon SHOULD be skipped"
1129 );
1130 assert!(
1131 MD053LinkImageReferenceDefinitions::should_skip_pattern("the project root: path/to/dir"),
1132 "Prose-like descriptions SHOULD be skipped"
1133 );
1134 }
1135
1136 #[test]
1137 fn test_many_code_spans_with_shortcut_references() {
1138 let rule = MD053LinkImageReferenceDefinitions::new();
1141
1142 let mut lines = Vec::new();
1143 for i in 0..100 {
1145 lines.push(format!("Some `code{i}` text and [used_ref] here"));
1146 }
1147 lines.push(String::new());
1148 lines.push("[used_ref]: https://example.com".to_string());
1149 lines.push("[unused_ref]: https://unused.com".to_string());
1150
1151 let content = lines.join("\n");
1152 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1153 let result = rule.check(&ctx).unwrap();
1154
1155 assert_eq!(result.len(), 1);
1157 assert!(result[0].message.contains("unused_ref"));
1158 }
1159
1160 #[test]
1161 fn test_multiline_definition_continuation_tracking() {
1162 let rule = MD053LinkImageReferenceDefinitions::new();
1165 let content = "\
1166[ref1]: https://example.com
1167 \"Title on next line\"
1168
1169[ref2]: https://example2.com
1170 \"Another title\"
1171
1172Some text using [ref1] here.
1173";
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175 let result = rule.check(&ctx).unwrap();
1176
1177 assert_eq!(result.len(), 1);
1179 assert!(result[0].message.contains("ref2"));
1180 }
1181
1182 #[test]
1183 fn test_code_span_at_boundary_does_not_hide_reference() {
1184 let rule = MD053LinkImageReferenceDefinitions::new();
1186 let content = "`code`[ref]\n\n[ref]: https://example.com";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let result = rule.check(&ctx).unwrap();
1189
1190 assert_eq!(result.len(), 0);
1192 }
1193
1194 #[test]
1195 fn test_reference_inside_code_span_not_counted() {
1196 let rule = MD053LinkImageReferenceDefinitions::new();
1198 let content = "Use `[ref]` in code\n\n[ref]: https://example.com";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200 let result = rule.check(&ctx).unwrap();
1201
1202 assert_eq!(result.len(), 1);
1204 }
1205
1206 #[test]
1207 fn test_shortcut_ref_at_byte_zero() {
1208 let rule = MD053LinkImageReferenceDefinitions::default();
1209 let content = "[example]\n\n[example]: https://example.com\n";
1210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211 let result = rule.check(&ctx).unwrap();
1212 assert!(
1213 result.is_empty(),
1214 "[ref] at byte 0 should be recognized as usage: {result:?}"
1215 );
1216 }
1217
1218 #[test]
1219 fn test_shortcut_ref_at_end_of_line() {
1220 let rule = MD053LinkImageReferenceDefinitions::default();
1221 let content = "Text [example]\n\n[example]: https://example.com\n";
1222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223 let result = rule.check(&ctx).unwrap();
1224 assert!(
1225 result.is_empty(),
1226 "[ref] at end of line should be recognized as usage: {result:?}"
1227 );
1228 }
1229
1230 #[test]
1231 fn test_reference_in_multiline_footnote_not_false_positive() {
1232 let rule = MD053LinkImageReferenceDefinitions::new();
1236 let content = "\
1237# Greetings
1238
1239This is a paragraph that has a footnote.[^footnote]
1240
1241[^footnote]:
1242 This footnote is long enough that it doesn't fit on just one line.
1243 Here is my [website][web].
1244
1245[web]: https://web.evanchen.cc
1246";
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248 let result = rule.check(&ctx).unwrap();
1249 assert!(
1250 result.is_empty(),
1251 "Reference used inside multi-line footnote should not be flagged: {result:?}"
1252 );
1253 }
1254
1255 #[test]
1256 fn test_reference_in_single_line_footnote() {
1257 let rule = MD053LinkImageReferenceDefinitions::new();
1258 let content = "\
1259# Greetings
1260
1261This is a paragraph that has a footnote.[^footnote]
1262
1263[^footnote]: Here is my [website][web].
1264
1265[web]: https://web.evanchen.cc
1266";
1267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268 let result = rule.check(&ctx).unwrap();
1269 assert!(
1270 result.is_empty(),
1271 "Reference used inside single-line footnote should not be flagged: {result:?}"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_shortcut_reference_in_multiline_footnote() {
1277 let rule = MD053LinkImageReferenceDefinitions::new();
1279 let content = "\
1280Text with footnote.[^note]
1281
1282[^note]:
1283 See [web] for details.
1284
1285[web]: https://example.com
1286";
1287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1288 let result = rule.check(&ctx).unwrap();
1289 assert!(
1290 result.is_empty(),
1291 "Shortcut reference inside multi-line footnote should not be flagged: {result:?}"
1292 );
1293 }
1294
1295 #[test]
1296 fn test_unused_reference_not_in_footnote_still_flagged() {
1297 let rule = MD053LinkImageReferenceDefinitions::new();
1299 let content = "\
1300# Greetings
1301
1302This is a paragraph that has a footnote.[^footnote]
1303
1304[^footnote]:
1305 This footnote is long enough.
1306
1307[unused]: https://example.com
1308";
1309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1310 let result = rule.check(&ctx).unwrap();
1311 assert_eq!(result.len(), 1);
1312 assert!(result[0].message.contains("unused"));
1313 }
1314
1315 #[test]
1316 fn test_image_reference_in_multiline_footnote() {
1317 let rule = MD053LinkImageReferenceDefinitions::new();
1318 let content = "\
1319Text with footnote.[^note]
1320
1321[^note]:
1322 Here is a diagram:
1323 ![diagram][img]
1324
1325[img]: https://example.com/diagram.png
1326";
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328 let result = rule.check(&ctx).unwrap();
1329 assert!(
1330 result.is_empty(),
1331 "Image reference inside multi-line footnote should not be flagged: {result:?}"
1332 );
1333 }
1334
1335 #[test]
1336 fn test_multiple_references_in_one_footnote() {
1337 let rule = MD053LinkImageReferenceDefinitions::new();
1338 let content = "\
1339Text.[^note]
1340
1341[^note]:
1342 See [link1][ref1] and [link2][ref2] for details.
1343
1344[ref1]: https://example.com
1345[ref2]: https://example.org
1346";
1347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1348 let result = rule.check(&ctx).unwrap();
1349 assert!(
1350 result.is_empty(),
1351 "Multiple references inside one footnote should all be recognized: {result:?}"
1352 );
1353 }
1354
1355 #[test]
1356 fn test_reference_in_code_block_inside_footnote_not_counted() {
1357 let rule = MD053LinkImageReferenceDefinitions::new();
1360 let content = "\
1361Text.[^code]
1362
1363[^code]:
1364 ```python
1365 x = [ref_like_syntax]
1366 ```
1367
1368[ref_like_syntax]: https://example.com
1369";
1370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1371 let result = rule.check(&ctx).unwrap();
1372 assert_eq!(
1373 result.len(),
1374 1,
1375 "Reference inside fenced code block within footnote should still be unused: {result:?}"
1376 );
1377 assert!(result[0].message.contains("ref_like_syntax"));
1378 }
1379
1380 #[test]
1381 fn test_nested_list_in_footnote_with_reference() {
1382 let rule = MD053LinkImageReferenceDefinitions::new();
1383 let content = "\
1384Text.[^deep]
1385
1386[^deep]:
1387 - List item
1388 - Nested with [link text][deep-ref]
1389
1390[deep-ref]: https://example.com
1391";
1392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1393 let result = rule.check(&ctx).unwrap();
1394 assert!(
1395 result.is_empty(),
1396 "Reference in nested list inside footnote should not be flagged: {result:?}"
1397 );
1398 }
1399}