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\n\n[ref4]: url4";
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);
944 assert!(defs.contains_key("ref1"));
945 assert!(defs.contains_key("ref2"));
946 assert!(!defs.contains_key("ref3"));
947 assert!(defs.contains_key("ref4"));
948 }
949
950 #[test]
951 fn test_find_usages() {
952 let rule = MD053LinkImageReferenceDefinitions::new();
953 let content = "[text][ref1] and [ref2] and ![img][ref3]";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let usages = rule.find_usages(&ctx);
956
957 assert!(usages.contains("ref1"));
958 assert!(usages.contains("ref2"));
959 assert!(usages.contains("ref3"));
960 }
961
962 #[test]
963 fn test_ignored_definitions_config() {
964 let config = MD053Config {
966 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
967 };
968 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
969
970 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let result = rule.check(&ctx).unwrap();
973
974 assert_eq!(result.len(), 1);
976 assert!(result[0].message.contains("unused"));
977 assert!(!result[0].message.contains("todo"));
978 assert!(!result[0].message.contains("draft"));
979 }
980
981 #[test]
982 fn test_ignored_definitions_case_insensitive() {
983 let config = MD053Config {
985 ignored_definitions: vec!["TODO".to_string()],
986 };
987 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
988
989 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
990 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
991 let result = rule.check(&ctx).unwrap();
992
993 assert_eq!(result.len(), 1);
995 assert!(result[0].message.contains("unused"));
996 assert!(!result[0].message.contains("todo"));
997 }
998
999 #[test]
1000 fn test_default_config_section() {
1001 let rule = MD053LinkImageReferenceDefinitions::default();
1002 let config_section = rule.default_config_section();
1003
1004 assert!(config_section.is_some());
1005 let (name, value) = config_section.unwrap();
1006 assert_eq!(name, "MD053");
1007
1008 if let toml::Value::Table(table) = value {
1010 assert!(table.contains_key("ignored-definitions"));
1011 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
1012 } else {
1013 panic!("Expected TOML table");
1014 }
1015 }
1016
1017 #[test]
1018 fn test_fix_with_ignored_definitions() {
1019 let config = MD053Config {
1021 ignored_definitions: vec!["template".to_string()],
1022 };
1023 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
1024
1025 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027 let fixed = rule.fix(&ctx).unwrap();
1028
1029 assert_eq!(fixed, content);
1031 }
1032
1033 #[test]
1034 fn test_duplicate_definitions_exact_case() {
1035 let rule = MD053LinkImageReferenceDefinitions::new();
1036 let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038 let result = rule.check(&ctx).unwrap();
1039
1040 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1043 assert_eq!(duplicate_warnings.len(), 2);
1044 assert_eq!(duplicate_warnings[0].line, 2);
1045 assert_eq!(duplicate_warnings[1].line, 3);
1046 }
1047
1048 #[test]
1049 fn test_duplicate_definitions_case_variants() {
1050 let rule = MD053LinkImageReferenceDefinitions::new();
1051 let content =
1052 "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054 let result = rule.check(&ctx).unwrap();
1055
1056 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1059 assert_eq!(duplicate_warnings.len(), 2);
1060
1061 assert_eq!(duplicate_warnings[0].line, 2);
1064 assert_eq!(duplicate_warnings[1].line, 3);
1065 }
1066
1067 #[test]
1068 fn test_duplicate_and_unused() {
1069 let rule = MD053LinkImageReferenceDefinitions::new();
1070 let content = "[used]\n\n[used]: http://url1\n\n[used]: http://url2\n\n[unused]: http://url3";
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072 let result = rule.check(&ctx).unwrap();
1073
1074 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1076 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
1077
1078 assert_eq!(duplicate_warnings.len(), 1);
1079 assert_eq!(unused_warnings.len(), 1);
1080 assert_eq!(duplicate_warnings[0].line, 5); assert_eq!(unused_warnings[0].line, 7); }
1083
1084 #[test]
1085 fn test_duplicate_with_usage() {
1086 let rule = MD053LinkImageReferenceDefinitions::new();
1087 let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
1089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1090 let result = rule.check(&ctx).unwrap();
1091
1092 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1094 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
1095
1096 assert_eq!(duplicate_warnings.len(), 1);
1097 assert_eq!(unused_warnings.len(), 0);
1098 assert_eq!(duplicate_warnings[0].line, 4);
1099 }
1100
1101 #[test]
1102 fn test_no_duplicate_different_ids() {
1103 let rule = MD053LinkImageReferenceDefinitions::new();
1104 let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let result = rule.check(&ctx).unwrap();
1107
1108 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1110 assert_eq!(duplicate_warnings.len(), 0);
1111 }
1112
1113 #[test]
1114 fn test_comment_style_reference_double_slash() {
1115 let rule = MD053LinkImageReferenceDefinitions::new();
1116 let content = "[//]: # (This is a comment)\n\nSome regular text.";
1118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119 let result = rule.check(&ctx).unwrap();
1120
1121 assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
1123 }
1124
1125 #[test]
1126 fn test_comment_style_reference_comment_label() {
1127 let rule = MD053LinkImageReferenceDefinitions::new();
1128 let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131 let result = rule.check(&ctx).unwrap();
1132
1133 assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
1135 }
1136
1137 #[test]
1138 fn test_comment_style_reference_todo_fixme() {
1139 let rule = MD053LinkImageReferenceDefinitions::new();
1140 let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
1142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1143 let result = rule.check(&ctx).unwrap();
1144
1145 assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
1147 }
1148
1149 #[test]
1150 fn test_comment_style_reference_fragment_only() {
1151 let rule = MD053LinkImageReferenceDefinitions::new();
1152 let content = "[anything]: #\n[ref]: #\n\nSome text.";
1154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155 let result = rule.check(&ctx).unwrap();
1156
1157 assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
1159 }
1160
1161 #[test]
1162 fn test_comment_vs_real_reference() {
1163 let rule = MD053LinkImageReferenceDefinitions::new();
1164 let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 let result = rule.check(&ctx).unwrap();
1168
1169 assert_eq!(result.len(), 1, "Only real unused references should be flagged");
1171 assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
1172 }
1173
1174 #[test]
1175 fn test_comment_with_fragment_section() {
1176 let rule = MD053LinkImageReferenceDefinitions::new();
1177 let content = "[//]: #section (Comment about section)\n\nSome text.";
1179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180 let result = rule.check(&ctx).unwrap();
1181
1182 assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1184 }
1185
1186 #[test]
1187 fn test_is_likely_comment_reference_helper() {
1188 assert!(
1190 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1191 "[//]: # should be recognized as comment"
1192 );
1193 assert!(
1194 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1195 "[comment]: #section should be recognized as comment"
1196 );
1197 assert!(
1198 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1199 "[note]: # should be recognized as comment"
1200 );
1201 assert!(
1202 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1203 "[todo]: # should be recognized as comment"
1204 );
1205 assert!(
1206 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1207 "Any label with just '#' should be recognized as comment"
1208 );
1209 assert!(
1210 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1211 "Real URL should not be recognized as comment"
1212 );
1213 assert!(
1214 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1215 "Real URL should not be recognized as comment"
1216 );
1217 }
1218
1219 #[test]
1220 fn test_reference_with_colon_in_name() {
1221 let rule = MD053LinkImageReferenceDefinitions::new();
1223 let content = "Check [RFC: 1234] for specs.\n\n[RFC: 1234]: https://example.com\n";
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226
1227 assert!(
1228 result.is_empty(),
1229 "Reference with colon should be recognized as used, got warnings: {result:?}"
1230 );
1231 }
1232
1233 #[test]
1234 fn test_reference_with_colon_various_styles() {
1235 let rule = MD053LinkImageReferenceDefinitions::new();
1237 let content = r#"See [RFC: 1234] and [Issue: 42] and [PR: 100].
1238
1239[RFC: 1234]: https://example.com/rfc1234
1240[Issue: 42]: https://example.com/issue42
1241[PR: 100]: https://example.com/pr100
1242"#;
1243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244 let result = rule.check(&ctx).unwrap();
1245
1246 assert!(
1247 result.is_empty(),
1248 "All colon-style references should be recognized as used, got warnings: {result:?}"
1249 );
1250 }
1251
1252 #[test]
1253 fn test_should_skip_pattern_allows_rfc_style() {
1254 assert!(
1257 !MD053LinkImageReferenceDefinitions::should_skip_pattern("RFC: 1234"),
1258 "RFC-style references should NOT be skipped"
1259 );
1260 assert!(
1261 !MD053LinkImageReferenceDefinitions::should_skip_pattern("Issue: 42"),
1262 "Issue-style references should NOT be skipped"
1263 );
1264 assert!(
1265 !MD053LinkImageReferenceDefinitions::should_skip_pattern("PR: 100"),
1266 "PR-style references should NOT be skipped"
1267 );
1268 assert!(
1269 !MD053LinkImageReferenceDefinitions::should_skip_pattern("See: Section 2"),
1270 "References with 'See:' should NOT be skipped"
1271 );
1272 assert!(
1273 !MD053LinkImageReferenceDefinitions::should_skip_pattern("foo:bar"),
1274 "References without space after colon should NOT be skipped"
1275 );
1276 }
1277
1278 #[test]
1279 fn test_should_skip_pattern_skips_prose() {
1280 assert!(
1282 MD053LinkImageReferenceDefinitions::should_skip_pattern("default value is: something"),
1283 "Prose with 3+ words before colon SHOULD be skipped"
1284 );
1285 assert!(
1286 MD053LinkImageReferenceDefinitions::should_skip_pattern("this is a label: description"),
1287 "Prose with 4 words before colon SHOULD be skipped"
1288 );
1289 assert!(
1290 MD053LinkImageReferenceDefinitions::should_skip_pattern("the project root: path/to/dir"),
1291 "Prose-like descriptions SHOULD be skipped"
1292 );
1293 }
1294
1295 #[test]
1296 fn test_many_code_spans_with_shortcut_references() {
1297 let rule = MD053LinkImageReferenceDefinitions::new();
1300
1301 let mut lines = Vec::new();
1302 for i in 0..100 {
1304 lines.push(format!("Some `code{i}` text and [used_ref] here"));
1305 }
1306 lines.push(String::new());
1307 lines.push("[used_ref]: https://example.com".to_string());
1308 lines.push("[unused_ref]: https://unused.com".to_string());
1309
1310 let content = lines.join("\n");
1311 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1312 let result = rule.check(&ctx).unwrap();
1313
1314 assert_eq!(result.len(), 1);
1316 assert!(result[0].message.contains("unused_ref"));
1317 }
1318
1319 #[test]
1320 fn test_multiline_definition_continuation_tracking() {
1321 let rule = MD053LinkImageReferenceDefinitions::new();
1324 let content = "\
1325[ref1]: https://example.com
1326 \"Title on next line\"
1327
1328[ref2]: https://example2.com
1329 \"Another title\"
1330
1331Some text using [ref1] here.
1332";
1333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334 let result = rule.check(&ctx).unwrap();
1335
1336 assert_eq!(result.len(), 1);
1338 assert!(result[0].message.contains("ref2"));
1339 }
1340
1341 #[test]
1342 fn test_code_span_at_boundary_does_not_hide_reference() {
1343 let rule = MD053LinkImageReferenceDefinitions::new();
1345 let content = "`code`[ref]\n\n[ref]: https://example.com";
1346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347 let result = rule.check(&ctx).unwrap();
1348
1349 assert_eq!(result.len(), 0);
1351 }
1352
1353 #[test]
1354 fn test_reference_inside_code_span_not_counted() {
1355 let rule = MD053LinkImageReferenceDefinitions::new();
1357 let content = "Use `[ref]` in code\n\n[ref]: https://example.com";
1358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1359 let result = rule.check(&ctx).unwrap();
1360
1361 assert_eq!(result.len(), 1);
1363 }
1364
1365 #[test]
1366 fn test_shortcut_ref_at_byte_zero() {
1367 let rule = MD053LinkImageReferenceDefinitions::default();
1368 let content = "[example]\n\n[example]: https://example.com\n";
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370 let result = rule.check(&ctx).unwrap();
1371 assert!(
1372 result.is_empty(),
1373 "[ref] at byte 0 should be recognized as usage: {result:?}"
1374 );
1375 }
1376
1377 #[test]
1378 fn test_shortcut_ref_at_end_of_line() {
1379 let rule = MD053LinkImageReferenceDefinitions::default();
1380 let content = "Text [example]\n\n[example]: https://example.com\n";
1381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382 let result = rule.check(&ctx).unwrap();
1383 assert!(
1384 result.is_empty(),
1385 "[ref] at end of line should be recognized as usage: {result:?}"
1386 );
1387 }
1388
1389 #[test]
1390 fn test_reference_in_multiline_footnote_not_false_positive() {
1391 let rule = MD053LinkImageReferenceDefinitions::new();
1395 let content = "\
1396# Greetings
1397
1398This is a paragraph that has a footnote.[^footnote]
1399
1400[^footnote]:
1401 This footnote is long enough that it doesn't fit on just one line.
1402 Here is my [website][web].
1403
1404[web]: https://web.evanchen.cc
1405";
1406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407 let result = rule.check(&ctx).unwrap();
1408 assert!(
1409 result.is_empty(),
1410 "Reference used inside multi-line footnote should not be flagged: {result:?}"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_reference_in_single_line_footnote() {
1416 let rule = MD053LinkImageReferenceDefinitions::new();
1417 let content = "\
1418# Greetings
1419
1420This is a paragraph that has a footnote.[^footnote]
1421
1422[^footnote]: Here is my [website][web].
1423
1424[web]: https://web.evanchen.cc
1425";
1426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1427 let result = rule.check(&ctx).unwrap();
1428 assert!(
1429 result.is_empty(),
1430 "Reference used inside single-line footnote should not be flagged: {result:?}"
1431 );
1432 }
1433
1434 #[test]
1435 fn test_shortcut_reference_in_multiline_footnote() {
1436 let rule = MD053LinkImageReferenceDefinitions::new();
1438 let content = "\
1439Text with footnote.[^note]
1440
1441[^note]:
1442 See [web] for details.
1443
1444[web]: https://example.com
1445";
1446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1447 let result = rule.check(&ctx).unwrap();
1448 assert!(
1449 result.is_empty(),
1450 "Shortcut reference inside multi-line footnote should not be flagged: {result:?}"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_unused_reference_not_in_footnote_still_flagged() {
1456 let rule = MD053LinkImageReferenceDefinitions::new();
1458 let content = "\
1459# Greetings
1460
1461This is a paragraph that has a footnote.[^footnote]
1462
1463[^footnote]:
1464 This footnote is long enough.
1465
1466[unused]: https://example.com
1467";
1468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1469 let result = rule.check(&ctx).unwrap();
1470 assert_eq!(result.len(), 1);
1471 assert!(result[0].message.contains("unused"));
1472 }
1473
1474 #[test]
1475 fn test_image_reference_in_multiline_footnote() {
1476 let rule = MD053LinkImageReferenceDefinitions::new();
1477 let content = "\
1478Text with footnote.[^note]
1479
1480[^note]:
1481 Here is a diagram:
1482 ![diagram][img]
1483
1484[img]: https://example.com/diagram.png
1485";
1486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1487 let result = rule.check(&ctx).unwrap();
1488 assert!(
1489 result.is_empty(),
1490 "Image reference inside multi-line footnote should not be flagged: {result:?}"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_multiple_references_in_one_footnote() {
1496 let rule = MD053LinkImageReferenceDefinitions::new();
1497 let content = "\
1498Text.[^note]
1499
1500[^note]:
1501 See [link1][ref1] and [link2][ref2] for details.
1502
1503[ref1]: https://example.com
1504[ref2]: https://example.org
1505";
1506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1507 let result = rule.check(&ctx).unwrap();
1508 assert!(
1509 result.is_empty(),
1510 "Multiple references inside one footnote should all be recognized: {result:?}"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_reference_in_code_block_inside_footnote_not_counted() {
1516 let rule = MD053LinkImageReferenceDefinitions::new();
1519 let content = "\
1520Text.[^code]
1521
1522[^code]:
1523 ```python
1524 x = [ref_like_syntax]
1525 ```
1526
1527[ref_like_syntax]: https://example.com
1528";
1529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1530 let result = rule.check(&ctx).unwrap();
1531 assert_eq!(
1532 result.len(),
1533 1,
1534 "Reference inside fenced code block within footnote should still be unused: {result:?}"
1535 );
1536 assert!(result[0].message.contains("ref_like_syntax"));
1537 }
1538
1539 #[test]
1540 fn test_nested_list_in_footnote_with_reference() {
1541 let rule = MD053LinkImageReferenceDefinitions::new();
1542 let content = "\
1543Text.[^deep]
1544
1545[^deep]:
1546 - List item
1547 - Nested with [link text][deep-ref]
1548
1549[deep-ref]: https://example.com
1550";
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552 let result = rule.check(&ctx).unwrap();
1553 assert!(
1554 result.is_empty(),
1555 "Reference in nested list inside footnote should not be flagged: {result:?}"
1556 );
1557 }
1558}