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> =
20 LazyLock::new(|| Regex::new(r"^\s*\[((?:[^\]\\]|\\.)+)\]:\s+(.+)$").unwrap());
21
22static CONTINUATION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s+(.+)$").unwrap());
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27#[serde(rename_all = "kebab-case")]
28pub struct MD053Config {
29 #[serde(default = "default_ignored_definitions")]
31 pub ignored_definitions: Vec<String>,
32}
33
34impl Default for MD053Config {
35 fn default() -> Self {
36 Self {
37 ignored_definitions: default_ignored_definitions(),
38 }
39 }
40}
41
42fn default_ignored_definitions() -> Vec<String> {
43 Vec::new()
44}
45
46impl RuleConfig for MD053Config {
47 const RULE_NAME: &'static str = "MD053";
48}
49
50#[derive(Clone)]
102pub struct MD053LinkImageReferenceDefinitions {
103 config: MD053Config,
104}
105
106impl MD053LinkImageReferenceDefinitions {
107 pub fn new() -> Self {
109 Self {
110 config: MD053Config::default(),
111 }
112 }
113
114 pub fn from_config_struct(config: MD053Config) -> Self {
116 Self { config }
117 }
118
119 fn should_skip_pattern(text: &str) -> bool {
121 if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
124 return true;
125 }
126
127 if text == "*" || text == "..." || text == "**" {
129 return true;
130 }
131
132 if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
134 return true;
135 }
136
137 if text.len() <= 2 && !text.chars().all(char::is_alphanumeric) {
140 return true;
141 }
142
143 if text.contains(':') && text.contains(' ') && !text.contains('`') {
147 if let Some((before_colon, _)) = text.split_once(':') {
150 let before_trimmed = before_colon.trim();
151 let word_count = before_trimmed.split_whitespace().count();
153 if word_count >= 3 {
155 return true;
156 }
157 }
158 }
159
160 if text.starts_with('!') {
162 return true;
163 }
164
165 false
177 }
178
179 fn unescape_reference(reference: &str) -> String {
186 reference.replace('\\', "")
188 }
189
190 fn normalize_reference(reference: &str) -> String {
193 if !reference.contains(['\\', '\t', '\r', '\n'])
194 && reference.trim_matches(' ') == reference
195 && !reference.contains(" ")
196 {
197 return unicase::UniCase::new(reference).to_folded_case();
198 }
199 let unescaped = Self::unescape_reference(reference);
200 let label = unescaped
201 .split([' ', '\t', '\r', '\n'])
202 .filter(|part| !part.is_empty())
203 .collect::<Vec<_>>()
204 .join(" ");
205 unicase::UniCase::new(label).to_folded_case()
206 }
207
208 fn is_likely_comment_reference(ref_id: &str, url: &str) -> bool {
227 const COMMENT_LABELS: &[&str] = &[
229 "//", "comment", "note", "todo", "fixme", "hack", ];
236
237 let normalized_id = ref_id.trim().to_lowercase();
238 let normalized_url = url.trim();
239
240 if COMMENT_LABELS.contains(&normalized_id.as_str()) && normalized_url.starts_with('#') {
243 return true;
244 }
245
246 if normalized_url == "#" {
249 return true;
250 }
251
252 false
253 }
254
255 fn find_definitions(&self, ctx: &crate::lint_context::LintContext) -> HashMap<String, Vec<(usize, usize)>> {
259 let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
260
261 for ref_def in ctx.reference_definitions() {
263 if Self::is_likely_comment_reference(&ref_def.id, &ref_def.url) {
265 continue;
266 }
267
268 let normalized_id = Self::normalize_reference(&ref_def.id);
270 definitions
271 .entry(normalized_id)
272 .or_default()
273 .push((ref_def.line - 1, ref_def.line - 1)); }
275
276 let lines = &ctx.lines;
278 let mut last_def_line: Option<usize> = None;
279 let mut last_def_id: Option<String> = None;
280
281 for (i, line_info) in lines.iter().enumerate() {
282 if line_info.in_code_block || line_info.in_front_matter {
283 last_def_line = None;
284 last_def_id = None;
285 continue;
286 }
287
288 let line = line_info.content(ctx.content);
289
290 if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line) {
291 let ref_id = caps.get(1).unwrap().as_str().trim();
293 let normalized_id = Self::normalize_reference(ref_id);
294 last_def_line = Some(i);
295 last_def_id = Some(normalized_id);
296 } else if let Some(def_start) = last_def_line
297 && let Some(ref def_id) = last_def_id
298 && CONTINUATION_REGEX.is_match(line)
299 {
300 if let Some(ranges) = definitions.get_mut(def_id.as_str())
302 && let Some(last_range) = ranges.last_mut()
303 && last_range.0 == def_start
304 {
305 last_range.1 = i;
306 }
307 } else {
308 last_def_line = None;
310 last_def_id = None;
311 }
312 }
313 definitions
314 }
315
316 fn find_usages(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
321 let mut usages: HashSet<String> = HashSet::new();
322
323 for link in ctx.links() {
325 if link.is_reference
326 && let Some(ref_id) = &link.reference_id
327 && !ctx.line_info(link.line).is_some_and(|info| info.in_code_block)
328 {
329 usages.insert(Self::normalize_reference(ref_id));
330 }
331 }
332
333 for image in ctx.images() {
335 if image.is_reference
336 && let Some(ref_id) = &image.reference_id
337 && !ctx.line_info(image.line).is_some_and(|info| info.in_code_block)
338 {
339 usages.insert(Self::normalize_reference(ref_id));
340 }
341 }
342
343 for footnote_ref in ctx.footnote_references() {
345 if !ctx.line_info(footnote_ref.line).is_some_and(|info| info.in_code_block) {
346 let ref_id = format!("^{}", footnote_ref.id);
347 usages.insert(Self::normalize_reference(&ref_id));
348 }
349 }
350
351 let code_spans = ctx.source_code_spans();
354
355 let mut span_ranges: Vec<(usize, usize)> = code_spans
357 .iter()
358 .map(|span| (span.byte_offset, span.byte_end))
359 .collect();
360 span_ranges.sort_unstable_by_key(|&(start, _)| start);
361
362 for line_info in &ctx.lines {
363 if line_info.in_code_block || line_info.in_front_matter {
364 continue;
365 }
366
367 let line_content = line_info.content(ctx.content);
368
369 if !line_content.contains('[') {
371 continue;
372 }
373
374 if REFERENCE_DEFINITION_REGEX.is_match(line_content) {
376 continue;
377 }
378
379 for caps in SHORTCUT_REFERENCE_REGEX.captures_iter(line_content) {
380 if let Some(full_match) = caps.get(0)
381 && let Some(ref_id_match) = caps.get(1)
382 {
383 let match_start = full_match.start();
384
385 if match_start > 0 && line_content.as_bytes()[match_start - 1] == b'!' {
387 continue;
388 }
389
390 let match_end = full_match.end();
392 if match_end < line_content.len() && line_content.as_bytes()[match_end] == b'[' {
393 continue;
394 }
395
396 let match_byte_offset = line_info.byte_offset + match_start;
397
398 let in_code_span = span_ranges
400 .binary_search_by(|&(start, end)| {
401 if match_byte_offset < start {
402 std::cmp::Ordering::Greater
403 } else if match_byte_offset >= end {
404 std::cmp::Ordering::Less
405 } else {
406 std::cmp::Ordering::Equal
407 }
408 })
409 .is_ok();
410
411 if !in_code_span {
412 let ref_id = ref_id_match.as_str().trim();
413
414 if !Self::should_skip_pattern(ref_id) {
415 let normalized_id = Self::normalize_reference(ref_id);
416 usages.insert(normalized_id);
417 }
418 }
419 }
420 }
421 }
422
423 usages
424 }
425
426 fn get_unused_references(
433 &self,
434 definitions: &HashMap<String, Vec<(usize, usize)>>,
435 usages: &HashSet<String>,
436 ) -> Vec<(String, usize, usize)> {
437 let mut unused = Vec::new();
438 for (id, ranges) in definitions {
439 if !usages.contains(id) && !self.is_ignored_definition(id) {
441 if ranges.len() == 1 {
444 let (start, end) = ranges[0];
445 unused.push((id.clone(), start, end));
446 }
447 }
450 }
451 unused
452 }
453
454 fn is_ignored_definition(&self, definition_id: &str) -> bool {
456 self.config
457 .ignored_definitions
458 .iter()
459 .any(|ignored| Self::normalize_reference(ignored) == definition_id)
460 }
461}
462
463impl Default for MD053LinkImageReferenceDefinitions {
464 fn default() -> Self {
465 Self::new()
466 }
467}
468
469impl Rule for MD053LinkImageReferenceDefinitions {
470 fn name(&self) -> &'static str {
471 "MD053"
472 }
473
474 fn description(&self) -> &'static str {
475 "Link and image reference definitions should be needed"
476 }
477
478 fn category(&self) -> RuleCategory {
479 RuleCategory::Link
480 }
481
482 fn fix_capability(&self) -> FixCapability {
483 FixCapability::Unfixable
484 }
485
486 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
490 if ctx.reference_definitions().is_empty() {
493 return Ok(Vec::new());
494 }
495 let definitions = self.find_definitions(ctx);
497 if definitions.is_empty() {
498 return Ok(Vec::new());
499 }
500 let usages = self.find_usages(ctx);
501
502 let unused_refs = self.get_unused_references(&definitions, &usages);
504
505 let mut warnings = Vec::new();
506
507 let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); for (definition_id, ranges) in &definitions {
511 if self.is_ignored_definition(definition_id) {
513 continue;
514 }
515
516 if ranges.len() > 1 {
517 for (i, &(start_line, _)) in ranges.iter().enumerate() {
519 if i > 0 {
520 let line_num = start_line + 1;
522 let line_content = ctx.lines.get(start_line).map_or("", |l| l.content(ctx.content));
523 let (start_line_1idx, start_col, end_line, end_col) =
524 calculate_line_range(line_num, line_content);
525
526 warnings.push(LintWarning {
527 rule_name: Some(self.name().to_string()),
528 line: start_line_1idx,
529 column: start_col,
530 end_line,
531 end_column: end_col,
532 message: format!("Duplicate link or image reference definition: [{definition_id}]"),
533 severity: Severity::Warning,
534 fix: None,
535 });
536 }
537 }
538 }
539
540 if let Some(&(start_line, _)) = ranges.first() {
542 if let Some(line_info) = ctx.lines.get(start_line)
544 && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line_info.content(ctx.content))
545 {
546 let original_id = caps.get(1).unwrap().as_str().trim();
547 let lower_id = Self::normalize_reference(original_id);
548
549 if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
550 if first_original != original_id {
552 let line_num = start_line + 1;
553 let line_content = line_info.content(ctx.content);
554 let (start_line_1idx, start_col, end_line, end_col) =
555 calculate_line_range(line_num, line_content);
556
557 warnings.push(LintWarning {
558 rule_name: Some(self.name().to_string()),
559 line: start_line_1idx,
560 column: start_col,
561 end_line,
562 end_column: end_col,
563 message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
564 original_id, first_original, first_line + 1),
565 severity: Severity::Warning,
566 fix: None,
567 });
568 }
569 } else {
570 seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
571 }
572 }
573 }
574 }
575
576 for (definition, start, _end) in unused_refs {
578 let line_num = start + 1; let line_content = ctx.lines.get(start).map_or("", |l| l.content(ctx.content));
580
581 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
583
584 warnings.push(LintWarning {
585 rule_name: Some(self.name().to_string()),
586 line: start_line,
587 column: start_col,
588 end_line,
589 end_column: end_col,
590 message: format!("Unused link/image reference: [{definition}]"),
591 severity: Severity::Warning,
592 fix: None, });
594 }
595
596 Ok(warnings)
597 }
598
599 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
601 Ok(ctx.content.to_string())
603 }
604
605 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
607 ctx.reference_definitions().is_empty()
608 }
609
610 fn as_any(&self) -> &dyn std::any::Any {
611 self
612 }
613
614 crate::impl_rule_config_methods!(MD053Config);
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use crate::lint_context::LintContext;
621
622 #[test]
623 fn test_used_references_follow_unicode_case_fold_and_label_whitespace() {
624 let rule = MD053LinkImageReferenceDefinitions::new();
625 for (usage, definition) in [
626 ("STRASSE", "straße"),
627 ("σ", "ς"),
628 ("SS", "ẞ"),
629 ("office", "office"),
630 ("foo bar", "foo bar"),
631 ("foo bar", "foo\tbar"),
632 ("foo\nbar", "foo bar"),
633 ] {
634 for reference in [
635 format!("[{usage}]"),
636 format!("[visible][{usage}]"),
637 format!("[{usage}][]"),
638 format!("![alt][{usage}]"),
639 ] {
640 let content = format!("{reference}\n\n[{definition}]: https://example.com\n");
641 assert!(
642 pulldown_cmark::Parser::new(&content).any(|event| matches!(
643 event,
644 pulldown_cmark::Event::Start(
645 pulldown_cmark::Tag::Link { .. } | pulldown_cmark::Tag::Image { .. }
646 )
647 )),
648 "the native parser resolves the reference: {content}"
649 );
650 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
651 assert!(
652 rule.check(&ctx).unwrap().is_empty(),
653 "the definition is used: {content}"
654 );
655 }
656 }
657 }
658
659 #[test]
660 fn test_normalized_references_still_report_unused_and_duplicate_definitions() {
661 let rule = MD053LinkImageReferenceDefinitions::new();
662 let content = "[SS]\n\n[ß]: https://example.com\n[unused]: https://other.example.com\n";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let warnings = rule.check(&ctx).unwrap();
665 assert_eq!(warnings.len(), 1);
666 assert_eq!(warnings[0].line, 4);
667 assert!(warnings[0].message.contains("Unused"));
668
669 let content = "[STRASSE]\n\n[straße]: https://first.example.com\n[STRASSE]: https://second.example.com\n";
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671 let warnings = rule.check(&ctx).unwrap();
672 assert_eq!(warnings.len(), 1);
673 assert_eq!(warnings[0].line, 4);
674 assert!(warnings[0].message.contains("Duplicate"));
675 assert!(warnings[0].fix.is_none());
676 }
677
678 #[test]
679 fn test_ignored_reference_labels_use_the_same_normalization() {
680 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(MD053Config {
681 ignored_definitions: vec!["straße".to_string(), "foo bar".to_string()],
682 });
683 let content = "[STRASSE]: https://example.com\n[foo bar]: https://example.com\n[other]: https://example.com\n";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let warnings = rule.check(&ctx).unwrap();
686 assert_eq!(warnings.len(), 1);
687 assert_eq!(warnings[0].line, 3);
688 }
689
690 #[test]
691 fn test_used_reference_link() {
692 let rule = MD053LinkImageReferenceDefinitions::new();
693 let content = "[text][ref]\n\n[ref]: https://example.com";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx).unwrap();
696
697 assert_eq!(result.len(), 0);
698 }
699
700 #[test]
701 fn test_unused_reference_definition() {
702 let rule = MD053LinkImageReferenceDefinitions::new();
703 let content = "[unused]: https://example.com";
704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706
707 assert_eq!(result.len(), 1);
708 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
709 }
710
711 #[test]
712 fn test_unused_reference_definition_with_escaped_bracket_label() {
713 let rule = MD053LinkImageReferenceDefinitions::new();
717 let content = "[unused\\[\\]]: https://example.com";
718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719 let result = rule.check(&ctx).unwrap();
720
721 assert_eq!(result.len(), 1, "unused escaped-bracket definition must be reported");
722 }
723
724 #[test]
725 fn test_used_reference_definition_with_escaped_bracket_label() {
726 let rule = MD053LinkImageReferenceDefinitions::new();
733 let content = "[text][used\\[\\]]\n\n[used\\[\\]]: https://example.com";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735
736 assert_eq!(
737 ctx.reference_definitions()
738 .iter()
739 .map(|d| d.id.as_str())
740 .collect::<Vec<_>>(),
741 vec!["used\\[\\]"],
742 "precondition: the definition must be visible to the rule"
743 );
744
745 let result = rule.check(&ctx).unwrap();
746 assert!(
747 result.is_empty(),
748 "a used escaped-bracket definition must not be reported: {result:?}"
749 );
750 }
751
752 #[test]
753 fn test_escaped_bracket_definition_is_not_read_as_a_shortcut_usage() {
754 let rule = MD053LinkImageReferenceDefinitions::new();
760 let content = "[a\\[]: https://example.com/1\n[a\\[\\]]: https://example.com/2\n";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763
764 assert_eq!(
765 result.len(),
766 2,
767 "a definition line must never register as a usage of another definition: {result:?}"
768 );
769 }
770
771 #[test]
772 fn test_used_reference_image() {
773 let rule = MD053LinkImageReferenceDefinitions::new();
774 let content = "![alt][img]\n\n[img]: image.jpg";
775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776 let result = rule.check(&ctx).unwrap();
777
778 assert_eq!(result.len(), 0);
779 }
780
781 #[test]
782 fn test_case_insensitive_matching() {
783 let rule = MD053LinkImageReferenceDefinitions::new();
784 let content = "[Text][REF]\n\n[ref]: https://example.com";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787
788 assert_eq!(result.len(), 0);
789 }
790
791 #[test]
792 fn test_shortcut_reference() {
793 let rule = MD053LinkImageReferenceDefinitions::new();
794 let content = "[ref]\n\n[ref]: https://example.com";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let result = rule.check(&ctx).unwrap();
797
798 assert_eq!(result.len(), 0);
799 }
800
801 #[test]
802 fn test_collapsed_reference() {
803 let rule = MD053LinkImageReferenceDefinitions::new();
804 let content = "[ref][]\n\n[ref]: https://example.com";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806 let result = rule.check(&ctx).unwrap();
807
808 assert_eq!(result.len(), 0);
809 }
810
811 #[test]
812 fn test_multiple_unused_definitions() {
813 let rule = MD053LinkImageReferenceDefinitions::new();
814 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.check(&ctx).unwrap();
817
818 assert_eq!(result.len(), 3);
819
820 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
822 assert!(messages.iter().any(|m| m.contains("unused1")));
823 assert!(messages.iter().any(|m| m.contains("unused2")));
824 assert!(messages.iter().any(|m| m.contains("unused3")));
825 }
826
827 #[test]
828 fn test_mixed_used_and_unused() {
829 let rule = MD053LinkImageReferenceDefinitions::new();
830 let content = "[used]\n\n[used]: url1\n[unused]: url2";
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);
835 assert!(result[0].message.contains("unused"));
836 }
837
838 #[test]
839 fn test_multiline_definition() {
840 let rule = MD053LinkImageReferenceDefinitions::new();
841 let content = "[ref]: https://example.com\n \"Title on next line\"";
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843 let result = rule.check(&ctx).unwrap();
844
845 assert_eq!(result.len(), 1); }
847
848 #[test]
849 fn test_reference_in_code_block() {
850 let rule = MD053LinkImageReferenceDefinitions::new();
851 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853 let result = rule.check(&ctx).unwrap();
854
855 assert_eq!(result.len(), 1);
857 }
858
859 #[test]
860 fn test_reference_in_inline_code() {
861 let rule = MD053LinkImageReferenceDefinitions::new();
862 let content = "`[ref]`\n\n[ref]: https://example.com";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865
866 assert_eq!(result.len(), 1);
868 }
869
870 #[test]
871 fn test_escaped_reference() {
872 let rule = MD053LinkImageReferenceDefinitions::new();
873 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 let result = rule.check(&ctx).unwrap();
876
877 assert_eq!(result.len(), 0);
879 }
880
881 #[test]
882 fn test_duplicate_definitions() {
883 let rule = MD053LinkImageReferenceDefinitions::new();
884 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886 let result = rule.check(&ctx).unwrap();
887
888 assert_eq!(result.len(), 1);
890 }
891
892 #[test]
893 fn test_fix_returns_original() {
894 let rule = MD053LinkImageReferenceDefinitions::new();
896 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let fixed = rule.fix(&ctx).unwrap();
899
900 assert_eq!(fixed, content);
901 }
902
903 #[test]
904 fn test_fix_preserves_content() {
905 let rule = MD053LinkImageReferenceDefinitions::new();
907 let content = "Content\n\n[unused]: url\n\nMore content";
908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909 let fixed = rule.fix(&ctx).unwrap();
910
911 assert_eq!(fixed, content);
912 }
913
914 #[test]
915 fn test_fix_does_not_remove() {
916 let rule = MD053LinkImageReferenceDefinitions::new();
918 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920 let fixed = rule.fix(&ctx).unwrap();
921
922 assert_eq!(fixed, content);
923 }
924
925 #[test]
926 fn test_special_characters_in_reference() {
927 let rule = MD053LinkImageReferenceDefinitions::new();
928 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931
932 assert_eq!(result.len(), 0);
933 }
934
935 #[test]
936 fn test_find_definitions() {
937 let rule = MD053LinkImageReferenceDefinitions::new();
938 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let defs = rule.find_definitions(&ctx);
941
942 assert_eq!(defs.len(), 3);
943 assert!(defs.contains_key("ref1"));
944 assert!(defs.contains_key("ref2"));
945 assert!(defs.contains_key("ref3"));
946 }
947
948 #[test]
949 fn test_find_usages() {
950 let rule = MD053LinkImageReferenceDefinitions::new();
951 let content = "[text][ref1] and [ref2] and ![img][ref3]";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953 let usages = rule.find_usages(&ctx);
954
955 assert!(usages.contains("ref1"));
956 assert!(usages.contains("ref2"));
957 assert!(usages.contains("ref3"));
958 }
959
960 #[test]
961 fn test_ignored_definitions_config() {
962 let config = MD053Config {
964 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
965 };
966 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
967
968 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 let result = rule.check(&ctx).unwrap();
971
972 assert_eq!(result.len(), 1);
974 assert!(result[0].message.contains("unused"));
975 assert!(!result[0].message.contains("todo"));
976 assert!(!result[0].message.contains("draft"));
977 }
978
979 #[test]
980 fn test_ignored_definitions_case_insensitive() {
981 let config = MD053Config {
983 ignored_definitions: vec!["TODO".to_string()],
984 };
985 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
986
987 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989 let result = rule.check(&ctx).unwrap();
990
991 assert_eq!(result.len(), 1);
993 assert!(result[0].message.contains("unused"));
994 assert!(!result[0].message.contains("todo"));
995 }
996
997 #[test]
998 fn test_default_config_section() {
999 let rule = MD053LinkImageReferenceDefinitions::default();
1000 let config_section = rule.default_config_section();
1001
1002 assert!(config_section.is_some());
1003 let (name, value) = config_section.unwrap();
1004 assert_eq!(name, "MD053");
1005
1006 if let toml::Value::Table(table) = value {
1008 assert!(table.contains_key("ignored-definitions"));
1009 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
1010 } else {
1011 panic!("Expected TOML table");
1012 }
1013 }
1014
1015 #[test]
1016 fn test_fix_with_ignored_definitions() {
1017 let config = MD053Config {
1019 ignored_definitions: vec!["template".to_string()],
1020 };
1021 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
1022
1023 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
1024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025 let fixed = rule.fix(&ctx).unwrap();
1026
1027 assert_eq!(fixed, content);
1029 }
1030
1031 #[test]
1032 fn test_duplicate_definitions_exact_case() {
1033 let rule = MD053LinkImageReferenceDefinitions::new();
1034 let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
1035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036 let result = rule.check(&ctx).unwrap();
1037
1038 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1041 assert_eq!(duplicate_warnings.len(), 2);
1042 assert_eq!(duplicate_warnings[0].line, 2);
1043 assert_eq!(duplicate_warnings[1].line, 3);
1044 }
1045
1046 #[test]
1047 fn test_duplicate_definitions_case_variants() {
1048 let rule = MD053LinkImageReferenceDefinitions::new();
1049 let content =
1050 "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
1051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052 let result = rule.check(&ctx).unwrap();
1053
1054 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1057 assert_eq!(duplicate_warnings.len(), 2);
1058
1059 assert_eq!(duplicate_warnings[0].line, 2);
1062 assert_eq!(duplicate_warnings[1].line, 3);
1063 }
1064
1065 #[test]
1066 fn test_duplicate_and_unused() {
1067 let rule = MD053LinkImageReferenceDefinitions::new();
1068 let content = "[used]\n\n[used]: http://url1\n\n[used]: http://url2\n\n[unused]: http://url3";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1070 let result = rule.check(&ctx).unwrap();
1071
1072 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1074 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
1075
1076 assert_eq!(duplicate_warnings.len(), 1);
1077 assert_eq!(unused_warnings.len(), 1);
1078 assert_eq!(duplicate_warnings[0].line, 5); assert_eq!(unused_warnings[0].line, 7); }
1081
1082 #[test]
1083 fn test_duplicate_with_usage() {
1084 let rule = MD053LinkImageReferenceDefinitions::new();
1085 let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088 let result = rule.check(&ctx).unwrap();
1089
1090 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1092 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
1093
1094 assert_eq!(duplicate_warnings.len(), 1);
1095 assert_eq!(unused_warnings.len(), 0);
1096 assert_eq!(duplicate_warnings[0].line, 4);
1097 }
1098
1099 #[test]
1100 fn test_no_duplicate_different_ids() {
1101 let rule = MD053LinkImageReferenceDefinitions::new();
1102 let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
1103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104 let result = rule.check(&ctx).unwrap();
1105
1106 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1108 assert_eq!(duplicate_warnings.len(), 0);
1109 }
1110
1111 #[test]
1112 fn test_comment_style_reference_double_slash() {
1113 let rule = MD053LinkImageReferenceDefinitions::new();
1114 let content = "[//]: # (This is a comment)\n\nSome regular text.";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 let result = rule.check(&ctx).unwrap();
1118
1119 assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
1121 }
1122
1123 #[test]
1124 fn test_comment_style_reference_comment_label() {
1125 let rule = MD053LinkImageReferenceDefinitions::new();
1126 let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
1128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129 let result = rule.check(&ctx).unwrap();
1130
1131 assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
1133 }
1134
1135 #[test]
1136 fn test_comment_style_reference_todo_fixme() {
1137 let rule = MD053LinkImageReferenceDefinitions::new();
1138 let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.check(&ctx).unwrap();
1142
1143 assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
1145 }
1146
1147 #[test]
1148 fn test_comment_style_reference_fragment_only() {
1149 let rule = MD053LinkImageReferenceDefinitions::new();
1150 let content = "[anything]: #\n[ref]: #\n\nSome text.";
1152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153 let result = rule.check(&ctx).unwrap();
1154
1155 assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
1157 }
1158
1159 #[test]
1160 fn test_comment_vs_real_reference() {
1161 let rule = MD053LinkImageReferenceDefinitions::new();
1162 let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
1164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1165 let result = rule.check(&ctx).unwrap();
1166
1167 assert_eq!(result.len(), 1, "Only real unused references should be flagged");
1169 assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
1170 }
1171
1172 #[test]
1173 fn test_comment_with_fragment_section() {
1174 let rule = MD053LinkImageReferenceDefinitions::new();
1175 let content = "[//]: #section (Comment about section)\n\nSome text.";
1177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1178 let result = rule.check(&ctx).unwrap();
1179
1180 assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1182 }
1183
1184 #[test]
1185 fn test_is_likely_comment_reference_helper() {
1186 assert!(
1188 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1189 "[//]: # should be recognized as comment"
1190 );
1191 assert!(
1192 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1193 "[comment]: #section should be recognized as comment"
1194 );
1195 assert!(
1196 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1197 "[note]: # should be recognized as comment"
1198 );
1199 assert!(
1200 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1201 "[todo]: # should be recognized as comment"
1202 );
1203 assert!(
1204 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1205 "Any label with just '#' should be recognized as comment"
1206 );
1207 assert!(
1208 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1209 "Real URL should not be recognized as comment"
1210 );
1211 assert!(
1212 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1213 "Real URL should not be recognized as comment"
1214 );
1215 }
1216
1217 #[test]
1218 fn test_reference_with_colon_in_name() {
1219 let rule = MD053LinkImageReferenceDefinitions::new();
1221 let content = "Check [RFC: 1234] for specs.\n\n[RFC: 1234]: https://example.com\n";
1222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223 let result = rule.check(&ctx).unwrap();
1224
1225 assert!(
1226 result.is_empty(),
1227 "Reference with colon should be recognized as used, got warnings: {result:?}"
1228 );
1229 }
1230
1231 #[test]
1232 fn test_reference_with_colon_various_styles() {
1233 let rule = MD053LinkImageReferenceDefinitions::new();
1235 let content = r#"See [RFC: 1234] and [Issue: 42] and [PR: 100].
1236
1237[RFC: 1234]: https://example.com/rfc1234
1238[Issue: 42]: https://example.com/issue42
1239[PR: 100]: https://example.com/pr100
1240"#;
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let result = rule.check(&ctx).unwrap();
1243
1244 assert!(
1245 result.is_empty(),
1246 "All colon-style references should be recognized as used, got warnings: {result:?}"
1247 );
1248 }
1249
1250 #[test]
1251 fn test_should_skip_pattern_allows_rfc_style() {
1252 assert!(
1255 !MD053LinkImageReferenceDefinitions::should_skip_pattern("RFC: 1234"),
1256 "RFC-style references should NOT be skipped"
1257 );
1258 assert!(
1259 !MD053LinkImageReferenceDefinitions::should_skip_pattern("Issue: 42"),
1260 "Issue-style references should NOT be skipped"
1261 );
1262 assert!(
1263 !MD053LinkImageReferenceDefinitions::should_skip_pattern("PR: 100"),
1264 "PR-style references should NOT be skipped"
1265 );
1266 assert!(
1267 !MD053LinkImageReferenceDefinitions::should_skip_pattern("See: Section 2"),
1268 "References with 'See:' should NOT be skipped"
1269 );
1270 assert!(
1271 !MD053LinkImageReferenceDefinitions::should_skip_pattern("foo:bar"),
1272 "References without space after colon should NOT be skipped"
1273 );
1274 }
1275
1276 #[test]
1277 fn test_should_skip_pattern_skips_prose() {
1278 assert!(
1280 MD053LinkImageReferenceDefinitions::should_skip_pattern("default value is: something"),
1281 "Prose with 3+ words before colon SHOULD be skipped"
1282 );
1283 assert!(
1284 MD053LinkImageReferenceDefinitions::should_skip_pattern("this is a label: description"),
1285 "Prose with 4 words before colon SHOULD be skipped"
1286 );
1287 assert!(
1288 MD053LinkImageReferenceDefinitions::should_skip_pattern("the project root: path/to/dir"),
1289 "Prose-like descriptions SHOULD be skipped"
1290 );
1291 }
1292
1293 #[test]
1294 fn test_many_code_spans_with_shortcut_references() {
1295 let rule = MD053LinkImageReferenceDefinitions::new();
1298
1299 let mut lines = Vec::new();
1300 for i in 0..100 {
1302 lines.push(format!("Some `code{i}` text and [used_ref] here"));
1303 }
1304 lines.push(String::new());
1305 lines.push("[used_ref]: https://example.com".to_string());
1306 lines.push("[unused_ref]: https://unused.com".to_string());
1307
1308 let content = lines.join("\n");
1309 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1310 let result = rule.check(&ctx).unwrap();
1311
1312 assert_eq!(result.len(), 1);
1314 assert!(result[0].message.contains("unused_ref"));
1315 }
1316
1317 #[test]
1318 fn test_multiline_definition_continuation_tracking() {
1319 let rule = MD053LinkImageReferenceDefinitions::new();
1322 let content = "\
1323[ref1]: https://example.com
1324 \"Title on next line\"
1325
1326[ref2]: https://example2.com
1327 \"Another title\"
1328
1329Some text using [ref1] here.
1330";
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332 let result = rule.check(&ctx).unwrap();
1333
1334 assert_eq!(result.len(), 1);
1336 assert!(result[0].message.contains("ref2"));
1337 }
1338
1339 #[test]
1340 fn test_code_span_at_boundary_does_not_hide_reference() {
1341 let rule = MD053LinkImageReferenceDefinitions::new();
1343 let content = "`code`[ref]\n\n[ref]: https://example.com";
1344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345 let result = rule.check(&ctx).unwrap();
1346
1347 assert_eq!(result.len(), 0);
1349 }
1350
1351 #[test]
1352 fn test_reference_inside_code_span_not_counted() {
1353 let rule = MD053LinkImageReferenceDefinitions::new();
1355 let content = "Use `[ref]` in code\n\n[ref]: https://example.com";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let result = rule.check(&ctx).unwrap();
1358
1359 assert_eq!(result.len(), 1);
1361 }
1362
1363 #[test]
1364 fn test_shortcut_ref_at_byte_zero() {
1365 let rule = MD053LinkImageReferenceDefinitions::default();
1366 let content = "[example]\n\n[example]: https://example.com\n";
1367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1368 let result = rule.check(&ctx).unwrap();
1369 assert!(
1370 result.is_empty(),
1371 "[ref] at byte 0 should be recognized as usage: {result:?}"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_shortcut_ref_at_end_of_line() {
1377 let rule = MD053LinkImageReferenceDefinitions::default();
1378 let content = "Text [example]\n\n[example]: https://example.com\n";
1379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380 let result = rule.check(&ctx).unwrap();
1381 assert!(
1382 result.is_empty(),
1383 "[ref] at end of line should be recognized as usage: {result:?}"
1384 );
1385 }
1386
1387 #[test]
1388 fn test_reference_in_multiline_footnote_not_false_positive() {
1389 let rule = MD053LinkImageReferenceDefinitions::new();
1393 let content = "\
1394# Greetings
1395
1396This is a paragraph that has a footnote.[^footnote]
1397
1398[^footnote]:
1399 This footnote is long enough that it doesn't fit on just one line.
1400 Here is my [website][web].
1401
1402[web]: https://web.evanchen.cc
1403";
1404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1405 let result = rule.check(&ctx).unwrap();
1406 assert!(
1407 result.is_empty(),
1408 "Reference used inside multi-line footnote should not be flagged: {result:?}"
1409 );
1410 }
1411
1412 #[test]
1413 fn test_reference_in_single_line_footnote() {
1414 let rule = MD053LinkImageReferenceDefinitions::new();
1415 let content = "\
1416# Greetings
1417
1418This is a paragraph that has a footnote.[^footnote]
1419
1420[^footnote]: Here is my [website][web].
1421
1422[web]: https://web.evanchen.cc
1423";
1424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425 let result = rule.check(&ctx).unwrap();
1426 assert!(
1427 result.is_empty(),
1428 "Reference used inside single-line footnote should not be flagged: {result:?}"
1429 );
1430 }
1431
1432 #[test]
1433 fn test_shortcut_reference_in_multiline_footnote() {
1434 let rule = MD053LinkImageReferenceDefinitions::new();
1436 let content = "\
1437Text with footnote.[^note]
1438
1439[^note]:
1440 See [web] for details.
1441
1442[web]: https://example.com
1443";
1444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1445 let result = rule.check(&ctx).unwrap();
1446 assert!(
1447 result.is_empty(),
1448 "Shortcut reference inside multi-line footnote should not be flagged: {result:?}"
1449 );
1450 }
1451
1452 #[test]
1453 fn test_unused_reference_not_in_footnote_still_flagged() {
1454 let rule = MD053LinkImageReferenceDefinitions::new();
1456 let content = "\
1457# Greetings
1458
1459This is a paragraph that has a footnote.[^footnote]
1460
1461[^footnote]:
1462 This footnote is long enough.
1463
1464[unused]: https://example.com
1465";
1466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1467 let result = rule.check(&ctx).unwrap();
1468 assert_eq!(result.len(), 1);
1469 assert!(result[0].message.contains("unused"));
1470 }
1471
1472 #[test]
1473 fn test_image_reference_in_multiline_footnote() {
1474 let rule = MD053LinkImageReferenceDefinitions::new();
1475 let content = "\
1476Text with footnote.[^note]
1477
1478[^note]:
1479 Here is a diagram:
1480 ![diagram][img]
1481
1482[img]: https://example.com/diagram.png
1483";
1484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1485 let result = rule.check(&ctx).unwrap();
1486 assert!(
1487 result.is_empty(),
1488 "Image reference inside multi-line footnote should not be flagged: {result:?}"
1489 );
1490 }
1491
1492 #[test]
1493 fn test_multiple_references_in_one_footnote() {
1494 let rule = MD053LinkImageReferenceDefinitions::new();
1495 let content = "\
1496Text.[^note]
1497
1498[^note]:
1499 See [link1][ref1] and [link2][ref2] for details.
1500
1501[ref1]: https://example.com
1502[ref2]: https://example.org
1503";
1504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505 let result = rule.check(&ctx).unwrap();
1506 assert!(
1507 result.is_empty(),
1508 "Multiple references inside one footnote should all be recognized: {result:?}"
1509 );
1510 }
1511
1512 #[test]
1513 fn test_reference_in_code_block_inside_footnote_not_counted() {
1514 let rule = MD053LinkImageReferenceDefinitions::new();
1517 let content = "\
1518Text.[^code]
1519
1520[^code]:
1521 ```python
1522 x = [ref_like_syntax]
1523 ```
1524
1525[ref_like_syntax]: https://example.com
1526";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528 let result = rule.check(&ctx).unwrap();
1529 assert_eq!(
1530 result.len(),
1531 1,
1532 "Reference inside fenced code block within footnote should still be unused: {result:?}"
1533 );
1534 assert!(result[0].message.contains("ref_like_syntax"));
1535 }
1536
1537 #[test]
1538 fn test_nested_list_in_footnote_with_reference() {
1539 let rule = MD053LinkImageReferenceDefinitions::new();
1540 let content = "\
1541Text.[^deep]
1542
1543[^deep]:
1544 - List item
1545 - Nested with [link text][deep-ref]
1546
1547[deep-ref]: https://example.com
1548";
1549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1550 let result = rule.check(&ctx).unwrap();
1551 assert!(
1552 result.is_empty(),
1553 "Reference in nested list inside footnote should not be flagged: {result:?}"
1554 );
1555 }
1556}