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.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 let definitions = self.find_definitions(ctx);
492 let usages = self.find_usages(ctx);
493
494 let unused_refs = self.get_unused_references(&definitions, &usages);
496
497 let mut warnings = Vec::new();
498
499 let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); for (definition_id, ranges) in &definitions {
503 if self.is_ignored_definition(definition_id) {
505 continue;
506 }
507
508 if ranges.len() > 1 {
509 for (i, &(start_line, _)) in ranges.iter().enumerate() {
511 if i > 0 {
512 let line_num = start_line + 1;
514 let line_content = ctx.lines.get(start_line).map_or("", |l| l.content(ctx.content));
515 let (start_line_1idx, start_col, end_line, end_col) =
516 calculate_line_range(line_num, line_content);
517
518 warnings.push(LintWarning {
519 rule_name: Some(self.name().to_string()),
520 line: start_line_1idx,
521 column: start_col,
522 end_line,
523 end_column: end_col,
524 message: format!("Duplicate link or image reference definition: [{definition_id}]"),
525 severity: Severity::Warning,
526 fix: None,
527 });
528 }
529 }
530 }
531
532 if let Some(&(start_line, _)) = ranges.first() {
534 if let Some(line_info) = ctx.lines.get(start_line)
536 && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line_info.content(ctx.content))
537 {
538 let original_id = caps.get(1).unwrap().as_str().trim();
539 let lower_id = Self::normalize_reference(original_id);
540
541 if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
542 if first_original != original_id {
544 let line_num = start_line + 1;
545 let line_content = line_info.content(ctx.content);
546 let (start_line_1idx, start_col, end_line, end_col) =
547 calculate_line_range(line_num, line_content);
548
549 warnings.push(LintWarning {
550 rule_name: Some(self.name().to_string()),
551 line: start_line_1idx,
552 column: start_col,
553 end_line,
554 end_column: end_col,
555 message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
556 original_id, first_original, first_line + 1),
557 severity: Severity::Warning,
558 fix: None,
559 });
560 }
561 } else {
562 seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
563 }
564 }
565 }
566 }
567
568 for (definition, start, _end) in unused_refs {
570 let line_num = start + 1; let line_content = ctx.lines.get(start).map_or("", |l| l.content(ctx.content));
572
573 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
575
576 warnings.push(LintWarning {
577 rule_name: Some(self.name().to_string()),
578 line: start_line,
579 column: start_col,
580 end_line,
581 end_column: end_col,
582 message: format!("Unused link/image reference: [{definition}]"),
583 severity: Severity::Warning,
584 fix: None, });
586 }
587
588 Ok(warnings)
589 }
590
591 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
593 Ok(ctx.content.to_string())
595 }
596
597 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
599 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
601 }
602
603 fn as_any(&self) -> &dyn std::any::Any {
604 self
605 }
606
607 crate::impl_rule_config_methods!(MD053Config);
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use crate::lint_context::LintContext;
614
615 #[test]
616 fn test_used_references_follow_unicode_case_fold_and_label_whitespace() {
617 let rule = MD053LinkImageReferenceDefinitions::new();
618 for (usage, definition) in [
619 ("STRASSE", "straße"),
620 ("σ", "ς"),
621 ("SS", "ẞ"),
622 ("office", "office"),
623 ("foo bar", "foo bar"),
624 ("foo bar", "foo\tbar"),
625 ("foo\nbar", "foo bar"),
626 ] {
627 for reference in [
628 format!("[{usage}]"),
629 format!("[visible][{usage}]"),
630 format!("[{usage}][]"),
631 format!("![alt][{usage}]"),
632 ] {
633 let content = format!("{reference}\n\n[{definition}]: https://example.com\n");
634 assert!(
635 pulldown_cmark::Parser::new(&content).any(|event| matches!(
636 event,
637 pulldown_cmark::Event::Start(
638 pulldown_cmark::Tag::Link { .. } | pulldown_cmark::Tag::Image { .. }
639 )
640 )),
641 "the native parser resolves the reference: {content}"
642 );
643 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
644 assert!(
645 rule.check(&ctx).unwrap().is_empty(),
646 "the definition is used: {content}"
647 );
648 }
649 }
650 }
651
652 #[test]
653 fn test_normalized_references_still_report_unused_and_duplicate_definitions() {
654 let rule = MD053LinkImageReferenceDefinitions::new();
655 let content = "[SS]\n\n[ß]: https://example.com\n[unused]: https://other.example.com\n";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let warnings = rule.check(&ctx).unwrap();
658 assert_eq!(warnings.len(), 1);
659 assert_eq!(warnings[0].line, 4);
660 assert!(warnings[0].message.contains("Unused"));
661
662 let content = "[STRASSE]\n\n[straße]: https://first.example.com\n[STRASSE]: https://second.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("Duplicate"));
668 assert!(warnings[0].fix.is_none());
669 }
670
671 #[test]
672 fn test_ignored_reference_labels_use_the_same_normalization() {
673 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(MD053Config {
674 ignored_definitions: vec!["straße".to_string(), "foo bar".to_string()],
675 });
676 let content = "[STRASSE]: https://example.com\n[foo bar]: https://example.com\n[other]: https://example.com\n";
677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678 let warnings = rule.check(&ctx).unwrap();
679 assert_eq!(warnings.len(), 1);
680 assert_eq!(warnings[0].line, 3);
681 }
682
683 #[test]
684 fn test_used_reference_link() {
685 let rule = MD053LinkImageReferenceDefinitions::new();
686 let content = "[text][ref]\n\n[ref]: https://example.com";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688 let result = rule.check(&ctx).unwrap();
689
690 assert_eq!(result.len(), 0);
691 }
692
693 #[test]
694 fn test_unused_reference_definition() {
695 let rule = MD053LinkImageReferenceDefinitions::new();
696 let content = "[unused]: https://example.com";
697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
698 let result = rule.check(&ctx).unwrap();
699
700 assert_eq!(result.len(), 1);
701 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
702 }
703
704 #[test]
705 fn test_unused_reference_definition_with_escaped_bracket_label() {
706 let rule = MD053LinkImageReferenceDefinitions::new();
710 let content = "[unused\\[\\]]: https://example.com";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713
714 assert_eq!(result.len(), 1, "unused escaped-bracket definition must be reported");
715 }
716
717 #[test]
718 fn test_used_reference_definition_with_escaped_bracket_label() {
719 let rule = MD053LinkImageReferenceDefinitions::new();
726 let content = "[text][used\\[\\]]\n\n[used\\[\\]]: https://example.com";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728
729 assert_eq!(
730 ctx.reference_definitions()
731 .iter()
732 .map(|d| d.id.as_str())
733 .collect::<Vec<_>>(),
734 vec!["used\\[\\]"],
735 "precondition: the definition must be visible to the rule"
736 );
737
738 let result = rule.check(&ctx).unwrap();
739 assert!(
740 result.is_empty(),
741 "a used escaped-bracket definition must not be reported: {result:?}"
742 );
743 }
744
745 #[test]
746 fn test_escaped_bracket_definition_is_not_read_as_a_shortcut_usage() {
747 let rule = MD053LinkImageReferenceDefinitions::new();
753 let content = "[a\\[]: https://example.com/1\n[a\\[\\]]: https://example.com/2\n";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755 let result = rule.check(&ctx).unwrap();
756
757 assert_eq!(
758 result.len(),
759 2,
760 "a definition line must never register as a usage of another definition: {result:?}"
761 );
762 }
763
764 #[test]
765 fn test_used_reference_image() {
766 let rule = MD053LinkImageReferenceDefinitions::new();
767 let content = "![alt][img]\n\n[img]: image.jpg";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770
771 assert_eq!(result.len(), 0);
772 }
773
774 #[test]
775 fn test_case_insensitive_matching() {
776 let rule = MD053LinkImageReferenceDefinitions::new();
777 let content = "[Text][REF]\n\n[ref]: https://example.com";
778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779 let result = rule.check(&ctx).unwrap();
780
781 assert_eq!(result.len(), 0);
782 }
783
784 #[test]
785 fn test_shortcut_reference() {
786 let rule = MD053LinkImageReferenceDefinitions::new();
787 let content = "[ref]\n\n[ref]: https://example.com";
788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789 let result = rule.check(&ctx).unwrap();
790
791 assert_eq!(result.len(), 0);
792 }
793
794 #[test]
795 fn test_collapsed_reference() {
796 let rule = MD053LinkImageReferenceDefinitions::new();
797 let content = "[ref][]\n\n[ref]: https://example.com";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let result = rule.check(&ctx).unwrap();
800
801 assert_eq!(result.len(), 0);
802 }
803
804 #[test]
805 fn test_multiple_unused_definitions() {
806 let rule = MD053LinkImageReferenceDefinitions::new();
807 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let result = rule.check(&ctx).unwrap();
810
811 assert_eq!(result.len(), 3);
812
813 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
815 assert!(messages.iter().any(|m| m.contains("unused1")));
816 assert!(messages.iter().any(|m| m.contains("unused2")));
817 assert!(messages.iter().any(|m| m.contains("unused3")));
818 }
819
820 #[test]
821 fn test_mixed_used_and_unused() {
822 let rule = MD053LinkImageReferenceDefinitions::new();
823 let content = "[used]\n\n[used]: url1\n[unused]: url2";
824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let result = rule.check(&ctx).unwrap();
826
827 assert_eq!(result.len(), 1);
828 assert!(result[0].message.contains("unused"));
829 }
830
831 #[test]
832 fn test_multiline_definition() {
833 let rule = MD053LinkImageReferenceDefinitions::new();
834 let content = "[ref]: https://example.com\n \"Title on next line\"";
835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
836 let result = rule.check(&ctx).unwrap();
837
838 assert_eq!(result.len(), 1); }
840
841 #[test]
842 fn test_reference_in_code_block() {
843 let rule = MD053LinkImageReferenceDefinitions::new();
844 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847
848 assert_eq!(result.len(), 1);
850 }
851
852 #[test]
853 fn test_reference_in_inline_code() {
854 let rule = MD053LinkImageReferenceDefinitions::new();
855 let content = "`[ref]`\n\n[ref]: https://example.com";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858
859 assert_eq!(result.len(), 1);
861 }
862
863 #[test]
864 fn test_escaped_reference() {
865 let rule = MD053LinkImageReferenceDefinitions::new();
866 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868 let result = rule.check(&ctx).unwrap();
869
870 assert_eq!(result.len(), 0);
872 }
873
874 #[test]
875 fn test_duplicate_definitions() {
876 let rule = MD053LinkImageReferenceDefinitions::new();
877 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let result = rule.check(&ctx).unwrap();
880
881 assert_eq!(result.len(), 1);
883 }
884
885 #[test]
886 fn test_fix_returns_original() {
887 let rule = MD053LinkImageReferenceDefinitions::new();
889 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891 let fixed = rule.fix(&ctx).unwrap();
892
893 assert_eq!(fixed, content);
894 }
895
896 #[test]
897 fn test_fix_preserves_content() {
898 let rule = MD053LinkImageReferenceDefinitions::new();
900 let content = "Content\n\n[unused]: url\n\nMore content";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902 let fixed = rule.fix(&ctx).unwrap();
903
904 assert_eq!(fixed, content);
905 }
906
907 #[test]
908 fn test_fix_does_not_remove() {
909 let rule = MD053LinkImageReferenceDefinitions::new();
911 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
913 let fixed = rule.fix(&ctx).unwrap();
914
915 assert_eq!(fixed, content);
916 }
917
918 #[test]
919 fn test_special_characters_in_reference() {
920 let rule = MD053LinkImageReferenceDefinitions::new();
921 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let result = rule.check(&ctx).unwrap();
924
925 assert_eq!(result.len(), 0);
926 }
927
928 #[test]
929 fn test_find_definitions() {
930 let rule = MD053LinkImageReferenceDefinitions::new();
931 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let defs = rule.find_definitions(&ctx);
934
935 assert_eq!(defs.len(), 3);
936 assert!(defs.contains_key("ref1"));
937 assert!(defs.contains_key("ref2"));
938 assert!(defs.contains_key("ref3"));
939 }
940
941 #[test]
942 fn test_find_usages() {
943 let rule = MD053LinkImageReferenceDefinitions::new();
944 let content = "[text][ref1] and [ref2] and ![img][ref3]";
945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let usages = rule.find_usages(&ctx);
947
948 assert!(usages.contains("ref1"));
949 assert!(usages.contains("ref2"));
950 assert!(usages.contains("ref3"));
951 }
952
953 #[test]
954 fn test_ignored_definitions_config() {
955 let config = MD053Config {
957 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
958 };
959 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
960
961 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963 let result = rule.check(&ctx).unwrap();
964
965 assert_eq!(result.len(), 1);
967 assert!(result[0].message.contains("unused"));
968 assert!(!result[0].message.contains("todo"));
969 assert!(!result[0].message.contains("draft"));
970 }
971
972 #[test]
973 fn test_ignored_definitions_case_insensitive() {
974 let config = MD053Config {
976 ignored_definitions: vec!["TODO".to_string()],
977 };
978 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
979
980 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982 let result = rule.check(&ctx).unwrap();
983
984 assert_eq!(result.len(), 1);
986 assert!(result[0].message.contains("unused"));
987 assert!(!result[0].message.contains("todo"));
988 }
989
990 #[test]
991 fn test_default_config_section() {
992 let rule = MD053LinkImageReferenceDefinitions::default();
993 let config_section = rule.default_config_section();
994
995 assert!(config_section.is_some());
996 let (name, value) = config_section.unwrap();
997 assert_eq!(name, "MD053");
998
999 if let toml::Value::Table(table) = value {
1001 assert!(table.contains_key("ignored-definitions"));
1002 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
1003 } else {
1004 panic!("Expected TOML table");
1005 }
1006 }
1007
1008 #[test]
1009 fn test_fix_with_ignored_definitions() {
1010 let config = MD053Config {
1012 ignored_definitions: vec!["template".to_string()],
1013 };
1014 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
1015
1016 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let fixed = rule.fix(&ctx).unwrap();
1019
1020 assert_eq!(fixed, content);
1022 }
1023
1024 #[test]
1025 fn test_duplicate_definitions_exact_case() {
1026 let rule = MD053LinkImageReferenceDefinitions::new();
1027 let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
1028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1029 let result = rule.check(&ctx).unwrap();
1030
1031 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1034 assert_eq!(duplicate_warnings.len(), 2);
1035 assert_eq!(duplicate_warnings[0].line, 2);
1036 assert_eq!(duplicate_warnings[1].line, 3);
1037 }
1038
1039 #[test]
1040 fn test_duplicate_definitions_case_variants() {
1041 let rule = MD053LinkImageReferenceDefinitions::new();
1042 let content =
1043 "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let result = rule.check(&ctx).unwrap();
1046
1047 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1050 assert_eq!(duplicate_warnings.len(), 2);
1051
1052 assert_eq!(duplicate_warnings[0].line, 2);
1055 assert_eq!(duplicate_warnings[1].line, 3);
1056 }
1057
1058 #[test]
1059 fn test_duplicate_and_unused() {
1060 let rule = MD053LinkImageReferenceDefinitions::new();
1061 let content = "[used]\n\n[used]: http://url1\n\n[used]: http://url2\n\n[unused]: http://url3";
1062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1063 let result = rule.check(&ctx).unwrap();
1064
1065 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1067 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
1068
1069 assert_eq!(duplicate_warnings.len(), 1);
1070 assert_eq!(unused_warnings.len(), 1);
1071 assert_eq!(duplicate_warnings[0].line, 5); assert_eq!(unused_warnings[0].line, 7); }
1074
1075 #[test]
1076 fn test_duplicate_with_usage() {
1077 let rule = MD053LinkImageReferenceDefinitions::new();
1078 let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 let result = rule.check(&ctx).unwrap();
1082
1083 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1085 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
1086
1087 assert_eq!(duplicate_warnings.len(), 1);
1088 assert_eq!(unused_warnings.len(), 0);
1089 assert_eq!(duplicate_warnings[0].line, 4);
1090 }
1091
1092 #[test]
1093 fn test_no_duplicate_different_ids() {
1094 let rule = MD053LinkImageReferenceDefinitions::new();
1095 let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
1096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1097 let result = rule.check(&ctx).unwrap();
1098
1099 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1101 assert_eq!(duplicate_warnings.len(), 0);
1102 }
1103
1104 #[test]
1105 fn test_comment_style_reference_double_slash() {
1106 let rule = MD053LinkImageReferenceDefinitions::new();
1107 let content = "[//]: # (This is a comment)\n\nSome regular text.";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111
1112 assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
1114 }
1115
1116 #[test]
1117 fn test_comment_style_reference_comment_label() {
1118 let rule = MD053LinkImageReferenceDefinitions::new();
1119 let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
1121 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1122 let result = rule.check(&ctx).unwrap();
1123
1124 assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
1126 }
1127
1128 #[test]
1129 fn test_comment_style_reference_todo_fixme() {
1130 let rule = MD053LinkImageReferenceDefinitions::new();
1131 let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134 let result = rule.check(&ctx).unwrap();
1135
1136 assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
1138 }
1139
1140 #[test]
1141 fn test_comment_style_reference_fragment_only() {
1142 let rule = MD053LinkImageReferenceDefinitions::new();
1143 let content = "[anything]: #\n[ref]: #\n\nSome text.";
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146 let result = rule.check(&ctx).unwrap();
1147
1148 assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
1150 }
1151
1152 #[test]
1153 fn test_comment_vs_real_reference() {
1154 let rule = MD053LinkImageReferenceDefinitions::new();
1155 let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158 let result = rule.check(&ctx).unwrap();
1159
1160 assert_eq!(result.len(), 1, "Only real unused references should be flagged");
1162 assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
1163 }
1164
1165 #[test]
1166 fn test_comment_with_fragment_section() {
1167 let rule = MD053LinkImageReferenceDefinitions::new();
1168 let content = "[//]: #section (Comment about section)\n\nSome text.";
1170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1171 let result = rule.check(&ctx).unwrap();
1172
1173 assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1175 }
1176
1177 #[test]
1178 fn test_is_likely_comment_reference_helper() {
1179 assert!(
1181 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1182 "[//]: # should be recognized as comment"
1183 );
1184 assert!(
1185 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1186 "[comment]: #section should be recognized as comment"
1187 );
1188 assert!(
1189 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1190 "[note]: # should be recognized as comment"
1191 );
1192 assert!(
1193 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1194 "[todo]: # should be recognized as comment"
1195 );
1196 assert!(
1197 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1198 "Any label with just '#' should be recognized as comment"
1199 );
1200 assert!(
1201 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1202 "Real URL should not be recognized as comment"
1203 );
1204 assert!(
1205 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1206 "Real URL should not be recognized as comment"
1207 );
1208 }
1209
1210 #[test]
1211 fn test_reference_with_colon_in_name() {
1212 let rule = MD053LinkImageReferenceDefinitions::new();
1214 let content = "Check [RFC: 1234] for specs.\n\n[RFC: 1234]: https://example.com\n";
1215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1216 let result = rule.check(&ctx).unwrap();
1217
1218 assert!(
1219 result.is_empty(),
1220 "Reference with colon should be recognized as used, got warnings: {result:?}"
1221 );
1222 }
1223
1224 #[test]
1225 fn test_reference_with_colon_various_styles() {
1226 let rule = MD053LinkImageReferenceDefinitions::new();
1228 let content = r#"See [RFC: 1234] and [Issue: 42] and [PR: 100].
1229
1230[RFC: 1234]: https://example.com/rfc1234
1231[Issue: 42]: https://example.com/issue42
1232[PR: 100]: https://example.com/pr100
1233"#;
1234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1235 let result = rule.check(&ctx).unwrap();
1236
1237 assert!(
1238 result.is_empty(),
1239 "All colon-style references should be recognized as used, got warnings: {result:?}"
1240 );
1241 }
1242
1243 #[test]
1244 fn test_should_skip_pattern_allows_rfc_style() {
1245 assert!(
1248 !MD053LinkImageReferenceDefinitions::should_skip_pattern("RFC: 1234"),
1249 "RFC-style references should NOT be skipped"
1250 );
1251 assert!(
1252 !MD053LinkImageReferenceDefinitions::should_skip_pattern("Issue: 42"),
1253 "Issue-style references should NOT be skipped"
1254 );
1255 assert!(
1256 !MD053LinkImageReferenceDefinitions::should_skip_pattern("PR: 100"),
1257 "PR-style references should NOT be skipped"
1258 );
1259 assert!(
1260 !MD053LinkImageReferenceDefinitions::should_skip_pattern("See: Section 2"),
1261 "References with 'See:' should NOT be skipped"
1262 );
1263 assert!(
1264 !MD053LinkImageReferenceDefinitions::should_skip_pattern("foo:bar"),
1265 "References without space after colon should NOT be skipped"
1266 );
1267 }
1268
1269 #[test]
1270 fn test_should_skip_pattern_skips_prose() {
1271 assert!(
1273 MD053LinkImageReferenceDefinitions::should_skip_pattern("default value is: something"),
1274 "Prose with 3+ words before colon SHOULD be skipped"
1275 );
1276 assert!(
1277 MD053LinkImageReferenceDefinitions::should_skip_pattern("this is a label: description"),
1278 "Prose with 4 words before colon SHOULD be skipped"
1279 );
1280 assert!(
1281 MD053LinkImageReferenceDefinitions::should_skip_pattern("the project root: path/to/dir"),
1282 "Prose-like descriptions SHOULD be skipped"
1283 );
1284 }
1285
1286 #[test]
1287 fn test_many_code_spans_with_shortcut_references() {
1288 let rule = MD053LinkImageReferenceDefinitions::new();
1291
1292 let mut lines = Vec::new();
1293 for i in 0..100 {
1295 lines.push(format!("Some `code{i}` text and [used_ref] here"));
1296 }
1297 lines.push(String::new());
1298 lines.push("[used_ref]: https://example.com".to_string());
1299 lines.push("[unused_ref]: https://unused.com".to_string());
1300
1301 let content = lines.join("\n");
1302 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1303 let result = rule.check(&ctx).unwrap();
1304
1305 assert_eq!(result.len(), 1);
1307 assert!(result[0].message.contains("unused_ref"));
1308 }
1309
1310 #[test]
1311 fn test_multiline_definition_continuation_tracking() {
1312 let rule = MD053LinkImageReferenceDefinitions::new();
1315 let content = "\
1316[ref1]: https://example.com
1317 \"Title on next line\"
1318
1319[ref2]: https://example2.com
1320 \"Another title\"
1321
1322Some text using [ref1] here.
1323";
1324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325 let result = rule.check(&ctx).unwrap();
1326
1327 assert_eq!(result.len(), 1);
1329 assert!(result[0].message.contains("ref2"));
1330 }
1331
1332 #[test]
1333 fn test_code_span_at_boundary_does_not_hide_reference() {
1334 let rule = MD053LinkImageReferenceDefinitions::new();
1336 let content = "`code`[ref]\n\n[ref]: https://example.com";
1337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1338 let result = rule.check(&ctx).unwrap();
1339
1340 assert_eq!(result.len(), 0);
1342 }
1343
1344 #[test]
1345 fn test_reference_inside_code_span_not_counted() {
1346 let rule = MD053LinkImageReferenceDefinitions::new();
1348 let content = "Use `[ref]` in code\n\n[ref]: https://example.com";
1349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1350 let result = rule.check(&ctx).unwrap();
1351
1352 assert_eq!(result.len(), 1);
1354 }
1355
1356 #[test]
1357 fn test_shortcut_ref_at_byte_zero() {
1358 let rule = MD053LinkImageReferenceDefinitions::default();
1359 let content = "[example]\n\n[example]: https://example.com\n";
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361 let result = rule.check(&ctx).unwrap();
1362 assert!(
1363 result.is_empty(),
1364 "[ref] at byte 0 should be recognized as usage: {result:?}"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_shortcut_ref_at_end_of_line() {
1370 let rule = MD053LinkImageReferenceDefinitions::default();
1371 let content = "Text [example]\n\n[example]: https://example.com\n";
1372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1373 let result = rule.check(&ctx).unwrap();
1374 assert!(
1375 result.is_empty(),
1376 "[ref] at end of line should be recognized as usage: {result:?}"
1377 );
1378 }
1379
1380 #[test]
1381 fn test_reference_in_multiline_footnote_not_false_positive() {
1382 let rule = MD053LinkImageReferenceDefinitions::new();
1386 let content = "\
1387# Greetings
1388
1389This is a paragraph that has a footnote.[^footnote]
1390
1391[^footnote]:
1392 This footnote is long enough that it doesn't fit on just one line.
1393 Here is my [website][web].
1394
1395[web]: https://web.evanchen.cc
1396";
1397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1398 let result = rule.check(&ctx).unwrap();
1399 assert!(
1400 result.is_empty(),
1401 "Reference used inside multi-line footnote should not be flagged: {result:?}"
1402 );
1403 }
1404
1405 #[test]
1406 fn test_reference_in_single_line_footnote() {
1407 let rule = MD053LinkImageReferenceDefinitions::new();
1408 let content = "\
1409# Greetings
1410
1411This is a paragraph that has a footnote.[^footnote]
1412
1413[^footnote]: Here is my [website][web].
1414
1415[web]: https://web.evanchen.cc
1416";
1417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1418 let result = rule.check(&ctx).unwrap();
1419 assert!(
1420 result.is_empty(),
1421 "Reference used inside single-line footnote should not be flagged: {result:?}"
1422 );
1423 }
1424
1425 #[test]
1426 fn test_shortcut_reference_in_multiline_footnote() {
1427 let rule = MD053LinkImageReferenceDefinitions::new();
1429 let content = "\
1430Text with footnote.[^note]
1431
1432[^note]:
1433 See [web] for details.
1434
1435[web]: https://example.com
1436";
1437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1438 let result = rule.check(&ctx).unwrap();
1439 assert!(
1440 result.is_empty(),
1441 "Shortcut reference inside multi-line footnote should not be flagged: {result:?}"
1442 );
1443 }
1444
1445 #[test]
1446 fn test_unused_reference_not_in_footnote_still_flagged() {
1447 let rule = MD053LinkImageReferenceDefinitions::new();
1449 let content = "\
1450# Greetings
1451
1452This is a paragraph that has a footnote.[^footnote]
1453
1454[^footnote]:
1455 This footnote is long enough.
1456
1457[unused]: https://example.com
1458";
1459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460 let result = rule.check(&ctx).unwrap();
1461 assert_eq!(result.len(), 1);
1462 assert!(result[0].message.contains("unused"));
1463 }
1464
1465 #[test]
1466 fn test_image_reference_in_multiline_footnote() {
1467 let rule = MD053LinkImageReferenceDefinitions::new();
1468 let content = "\
1469Text with footnote.[^note]
1470
1471[^note]:
1472 Here is a diagram:
1473 ![diagram][img]
1474
1475[img]: https://example.com/diagram.png
1476";
1477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1478 let result = rule.check(&ctx).unwrap();
1479 assert!(
1480 result.is_empty(),
1481 "Image reference inside multi-line footnote should not be flagged: {result:?}"
1482 );
1483 }
1484
1485 #[test]
1486 fn test_multiple_references_in_one_footnote() {
1487 let rule = MD053LinkImageReferenceDefinitions::new();
1488 let content = "\
1489Text.[^note]
1490
1491[^note]:
1492 See [link1][ref1] and [link2][ref2] for details.
1493
1494[ref1]: https://example.com
1495[ref2]: https://example.org
1496";
1497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498 let result = rule.check(&ctx).unwrap();
1499 assert!(
1500 result.is_empty(),
1501 "Multiple references inside one footnote should all be recognized: {result:?}"
1502 );
1503 }
1504
1505 #[test]
1506 fn test_reference_in_code_block_inside_footnote_not_counted() {
1507 let rule = MD053LinkImageReferenceDefinitions::new();
1510 let content = "\
1511Text.[^code]
1512
1513[^code]:
1514 ```python
1515 x = [ref_like_syntax]
1516 ```
1517
1518[ref_like_syntax]: https://example.com
1519";
1520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1521 let result = rule.check(&ctx).unwrap();
1522 assert_eq!(
1523 result.len(),
1524 1,
1525 "Reference inside fenced code block within footnote should still be unused: {result:?}"
1526 );
1527 assert!(result[0].message.contains("ref_like_syntax"));
1528 }
1529
1530 #[test]
1531 fn test_nested_list_in_footnote_with_reference() {
1532 let rule = MD053LinkImageReferenceDefinitions::new();
1533 let content = "\
1534Text.[^deep]
1535
1536[^deep]:
1537 - List item
1538 - Nested with [link text][deep-ref]
1539
1540[deep-ref]: https://example.com
1541";
1542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1543 let result = rule.check(&ctx).unwrap();
1544 assert!(
1545 result.is_empty(),
1546 "Reference in nested list inside footnote should not be flagged: {result:?}"
1547 );
1548 }
1549}