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 is_likely_comment_reference(ref_id: &str, url: &str) -> bool {
209 const COMMENT_LABELS: &[&str] = &[
211 "//", "comment", "note", "todo", "fixme", "hack", ];
218
219 let normalized_id = ref_id.trim().to_lowercase();
220 let normalized_url = url.trim();
221
222 if COMMENT_LABELS.contains(&normalized_id.as_str()) && normalized_url.starts_with('#') {
225 return true;
226 }
227
228 if normalized_url == "#" {
231 return true;
232 }
233
234 false
235 }
236
237 fn find_definitions(&self, ctx: &crate::lint_context::LintContext) -> HashMap<String, Vec<(usize, usize)>> {
241 let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
242
243 for ref_def in ctx.reference_definitions() {
245 if Self::is_likely_comment_reference(&ref_def.id, &ref_def.url) {
247 continue;
248 }
249
250 let normalized_id = Self::unescape_reference(&ref_def.id); definitions
253 .entry(normalized_id)
254 .or_default()
255 .push((ref_def.line - 1, ref_def.line - 1)); }
257
258 let lines = &ctx.lines;
260 let mut last_def_line: Option<usize> = None;
261 let mut last_def_id: Option<String> = None;
262
263 for (i, line_info) in lines.iter().enumerate() {
264 if line_info.in_code_block || line_info.in_front_matter {
265 last_def_line = None;
266 last_def_id = None;
267 continue;
268 }
269
270 let line = line_info.content(ctx.content);
271
272 if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line) {
273 let ref_id = caps.get(1).unwrap().as_str().trim();
275 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
276 last_def_line = Some(i);
277 last_def_id = Some(normalized_id);
278 } else if let Some(def_start) = last_def_line
279 && let Some(ref def_id) = last_def_id
280 && CONTINUATION_REGEX.is_match(line)
281 {
282 if let Some(ranges) = definitions.get_mut(def_id.as_str())
284 && let Some(last_range) = ranges.last_mut()
285 && last_range.0 == def_start
286 {
287 last_range.1 = i;
288 }
289 } else {
290 last_def_line = None;
292 last_def_id = None;
293 }
294 }
295 definitions
296 }
297
298 fn find_usages(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
303 let mut usages: HashSet<String> = HashSet::new();
304
305 for link in ctx.links() {
307 if link.is_reference
308 && let Some(ref_id) = &link.reference_id
309 && !ctx.line_info(link.line).is_some_and(|info| info.in_code_block)
310 {
311 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
312 }
313 }
314
315 for image in ctx.images() {
317 if image.is_reference
318 && let Some(ref_id) = &image.reference_id
319 && !ctx.line_info(image.line).is_some_and(|info| info.in_code_block)
320 {
321 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
322 }
323 }
324
325 for footnote_ref in ctx.footnote_references() {
327 if !ctx.line_info(footnote_ref.line).is_some_and(|info| info.in_code_block) {
328 let ref_id = format!("^{}", footnote_ref.id);
329 usages.insert(ref_id.to_lowercase());
330 }
331 }
332
333 let code_spans = ctx.code_spans();
336
337 let mut span_ranges: Vec<(usize, usize)> = code_spans
339 .iter()
340 .map(|span| (span.byte_offset, span.byte_end))
341 .collect();
342 span_ranges.sort_unstable_by_key(|&(start, _)| start);
343
344 for line_info in &ctx.lines {
345 if line_info.in_code_block || line_info.in_front_matter {
346 continue;
347 }
348
349 let line_content = line_info.content(ctx.content);
350
351 if !line_content.contains('[') {
353 continue;
354 }
355
356 if REFERENCE_DEFINITION_REGEX.is_match(line_content) {
358 continue;
359 }
360
361 for caps in SHORTCUT_REFERENCE_REGEX.captures_iter(line_content) {
362 if let Some(full_match) = caps.get(0)
363 && let Some(ref_id_match) = caps.get(1)
364 {
365 let match_start = full_match.start();
366
367 if match_start > 0 && line_content.as_bytes()[match_start - 1] == b'!' {
369 continue;
370 }
371
372 let match_end = full_match.end();
374 if match_end < line_content.len() && line_content.as_bytes()[match_end] == b'[' {
375 continue;
376 }
377
378 let match_byte_offset = line_info.byte_offset + match_start;
379
380 let in_code_span = span_ranges
382 .binary_search_by(|&(start, end)| {
383 if match_byte_offset < start {
384 std::cmp::Ordering::Greater
385 } else if match_byte_offset >= end {
386 std::cmp::Ordering::Less
387 } else {
388 std::cmp::Ordering::Equal
389 }
390 })
391 .is_ok();
392
393 if !in_code_span {
394 let ref_id = ref_id_match.as_str().trim();
395
396 if !Self::should_skip_pattern(ref_id) {
397 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
398 usages.insert(normalized_id);
399 }
400 }
401 }
402 }
403 }
404
405 usages
406 }
407
408 fn get_unused_references(
415 &self,
416 definitions: &HashMap<String, Vec<(usize, usize)>>,
417 usages: &HashSet<String>,
418 ) -> Vec<(String, usize, usize)> {
419 let mut unused = Vec::new();
420 for (id, ranges) in definitions {
421 if !usages.contains(id) && !self.is_ignored_definition(id) {
423 if ranges.len() == 1 {
426 let (start, end) = ranges[0];
427 unused.push((id.clone(), start, end));
428 }
429 }
432 }
433 unused
434 }
435
436 fn is_ignored_definition(&self, definition_id: &str) -> bool {
438 self.config
439 .ignored_definitions
440 .iter()
441 .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
442 }
443}
444
445impl Default for MD053LinkImageReferenceDefinitions {
446 fn default() -> Self {
447 Self::new()
448 }
449}
450
451impl Rule for MD053LinkImageReferenceDefinitions {
452 fn name(&self) -> &'static str {
453 "MD053"
454 }
455
456 fn description(&self) -> &'static str {
457 "Link and image reference definitions should be needed"
458 }
459
460 fn category(&self) -> RuleCategory {
461 RuleCategory::Link
462 }
463
464 fn fix_capability(&self) -> FixCapability {
465 FixCapability::Unfixable
466 }
467
468 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
472 let definitions = self.find_definitions(ctx);
474 let usages = self.find_usages(ctx);
475
476 let unused_refs = self.get_unused_references(&definitions, &usages);
478
479 let mut warnings = Vec::new();
480
481 let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); for (definition_id, ranges) in &definitions {
485 if self.is_ignored_definition(definition_id) {
487 continue;
488 }
489
490 if ranges.len() > 1 {
491 for (i, &(start_line, _)) in ranges.iter().enumerate() {
493 if i > 0 {
494 let line_num = start_line + 1;
496 let line_content = ctx.lines.get(start_line).map_or("", |l| l.content(ctx.content));
497 let (start_line_1idx, start_col, end_line, end_col) =
498 calculate_line_range(line_num, line_content);
499
500 warnings.push(LintWarning {
501 rule_name: Some(self.name().to_string()),
502 line: start_line_1idx,
503 column: start_col,
504 end_line,
505 end_column: end_col,
506 message: format!("Duplicate link or image reference definition: [{definition_id}]"),
507 severity: Severity::Warning,
508 fix: None,
509 });
510 }
511 }
512 }
513
514 if let Some(&(start_line, _)) = ranges.first() {
516 if let Some(line_info) = ctx.lines.get(start_line)
518 && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line_info.content(ctx.content))
519 {
520 let original_id = caps.get(1).unwrap().as_str().trim();
521 let lower_id = original_id.to_lowercase();
522
523 if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
524 if first_original != original_id {
526 let line_num = start_line + 1;
527 let line_content = line_info.content(ctx.content);
528 let (start_line_1idx, start_col, end_line, end_col) =
529 calculate_line_range(line_num, line_content);
530
531 warnings.push(LintWarning {
532 rule_name: Some(self.name().to_string()),
533 line: start_line_1idx,
534 column: start_col,
535 end_line,
536 end_column: end_col,
537 message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
538 original_id, first_original, first_line + 1),
539 severity: Severity::Warning,
540 fix: None,
541 });
542 }
543 } else {
544 seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
545 }
546 }
547 }
548 }
549
550 for (definition, start, _end) in unused_refs {
552 let line_num = start + 1; let line_content = ctx.lines.get(start).map_or("", |l| l.content(ctx.content));
554
555 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
557
558 warnings.push(LintWarning {
559 rule_name: Some(self.name().to_string()),
560 line: start_line,
561 column: start_col,
562 end_line,
563 end_column: end_col,
564 message: format!("Unused link/image reference: [{definition}]"),
565 severity: Severity::Warning,
566 fix: None, });
568 }
569
570 Ok(warnings)
571 }
572
573 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
575 Ok(ctx.content.to_string())
577 }
578
579 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
581 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
583 }
584
585 fn as_any(&self) -> &dyn std::any::Any {
586 self
587 }
588
589 crate::impl_rule_config_methods!(MD053Config);
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use crate::lint_context::LintContext;
596
597 #[test]
598 fn test_used_reference_link() {
599 let rule = MD053LinkImageReferenceDefinitions::new();
600 let content = "[text][ref]\n\n[ref]: https://example.com";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602 let result = rule.check(&ctx).unwrap();
603
604 assert_eq!(result.len(), 0);
605 }
606
607 #[test]
608 fn test_unused_reference_definition() {
609 let rule = MD053LinkImageReferenceDefinitions::new();
610 let content = "[unused]: https://example.com";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 let result = rule.check(&ctx).unwrap();
613
614 assert_eq!(result.len(), 1);
615 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
616 }
617
618 #[test]
619 fn test_unused_reference_definition_with_escaped_bracket_label() {
620 let rule = MD053LinkImageReferenceDefinitions::new();
624 let content = "[unused\\[\\]]: https://example.com";
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626 let result = rule.check(&ctx).unwrap();
627
628 assert_eq!(result.len(), 1, "unused escaped-bracket definition must be reported");
629 }
630
631 #[test]
632 fn test_used_reference_definition_with_escaped_bracket_label() {
633 let rule = MD053LinkImageReferenceDefinitions::new();
640 let content = "[text][used\\[\\]]\n\n[used\\[\\]]: https://example.com";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642
643 assert_eq!(
644 ctx.reference_definitions()
645 .iter()
646 .map(|d| d.id.as_str())
647 .collect::<Vec<_>>(),
648 vec!["used\\[\\]"],
649 "precondition: the definition must be visible to the rule"
650 );
651
652 let result = rule.check(&ctx).unwrap();
653 assert!(
654 result.is_empty(),
655 "a used escaped-bracket definition must not be reported: {result:?}"
656 );
657 }
658
659 #[test]
660 fn test_escaped_bracket_definition_is_not_read_as_a_shortcut_usage() {
661 let rule = MD053LinkImageReferenceDefinitions::new();
667 let content = "[a\\[]: https://example.com/1\n[a\\[\\]]: https://example.com/2\n";
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669 let result = rule.check(&ctx).unwrap();
670
671 assert_eq!(
672 result.len(),
673 2,
674 "a definition line must never register as a usage of another definition: {result:?}"
675 );
676 }
677
678 #[test]
679 fn test_used_reference_image() {
680 let rule = MD053LinkImageReferenceDefinitions::new();
681 let content = "![alt][img]\n\n[img]: image.jpg";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let result = rule.check(&ctx).unwrap();
684
685 assert_eq!(result.len(), 0);
686 }
687
688 #[test]
689 fn test_case_insensitive_matching() {
690 let rule = MD053LinkImageReferenceDefinitions::new();
691 let content = "[Text][REF]\n\n[ref]: https://example.com";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694
695 assert_eq!(result.len(), 0);
696 }
697
698 #[test]
699 fn test_shortcut_reference() {
700 let rule = MD053LinkImageReferenceDefinitions::new();
701 let content = "[ref]\n\n[ref]: https://example.com";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let result = rule.check(&ctx).unwrap();
704
705 assert_eq!(result.len(), 0);
706 }
707
708 #[test]
709 fn test_collapsed_reference() {
710 let rule = MD053LinkImageReferenceDefinitions::new();
711 let content = "[ref][]\n\n[ref]: https://example.com";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let result = rule.check(&ctx).unwrap();
714
715 assert_eq!(result.len(), 0);
716 }
717
718 #[test]
719 fn test_multiple_unused_definitions() {
720 let rule = MD053LinkImageReferenceDefinitions::new();
721 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let result = rule.check(&ctx).unwrap();
724
725 assert_eq!(result.len(), 3);
726
727 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
729 assert!(messages.iter().any(|m| m.contains("unused1")));
730 assert!(messages.iter().any(|m| m.contains("unused2")));
731 assert!(messages.iter().any(|m| m.contains("unused3")));
732 }
733
734 #[test]
735 fn test_mixed_used_and_unused() {
736 let rule = MD053LinkImageReferenceDefinitions::new();
737 let content = "[used]\n\n[used]: url1\n[unused]: url2";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx).unwrap();
740
741 assert_eq!(result.len(), 1);
742 assert!(result[0].message.contains("unused"));
743 }
744
745 #[test]
746 fn test_multiline_definition() {
747 let rule = MD053LinkImageReferenceDefinitions::new();
748 let content = "[ref]: https://example.com\n \"Title on next line\"";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let result = rule.check(&ctx).unwrap();
751
752 assert_eq!(result.len(), 1); }
754
755 #[test]
756 fn test_reference_in_code_block() {
757 let rule = MD053LinkImageReferenceDefinitions::new();
758 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761
762 assert_eq!(result.len(), 1);
764 }
765
766 #[test]
767 fn test_reference_in_inline_code() {
768 let rule = MD053LinkImageReferenceDefinitions::new();
769 let content = "`[ref]`\n\n[ref]: https://example.com";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
772
773 assert_eq!(result.len(), 1);
775 }
776
777 #[test]
778 fn test_escaped_reference() {
779 let rule = MD053LinkImageReferenceDefinitions::new();
780 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let result = rule.check(&ctx).unwrap();
783
784 assert_eq!(result.len(), 0);
786 }
787
788 #[test]
789 fn test_duplicate_definitions() {
790 let rule = MD053LinkImageReferenceDefinitions::new();
791 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793 let result = rule.check(&ctx).unwrap();
794
795 assert_eq!(result.len(), 1);
797 }
798
799 #[test]
800 fn test_fix_returns_original() {
801 let rule = MD053LinkImageReferenceDefinitions::new();
803 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let fixed = rule.fix(&ctx).unwrap();
806
807 assert_eq!(fixed, content);
808 }
809
810 #[test]
811 fn test_fix_preserves_content() {
812 let rule = MD053LinkImageReferenceDefinitions::new();
814 let content = "Content\n\n[unused]: url\n\nMore content";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let fixed = rule.fix(&ctx).unwrap();
817
818 assert_eq!(fixed, content);
819 }
820
821 #[test]
822 fn test_fix_does_not_remove() {
823 let rule = MD053LinkImageReferenceDefinitions::new();
825 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827 let fixed = rule.fix(&ctx).unwrap();
828
829 assert_eq!(fixed, content);
830 }
831
832 #[test]
833 fn test_special_characters_in_reference() {
834 let rule = MD053LinkImageReferenceDefinitions::new();
835 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838
839 assert_eq!(result.len(), 0);
840 }
841
842 #[test]
843 fn test_find_definitions() {
844 let rule = MD053LinkImageReferenceDefinitions::new();
845 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let defs = rule.find_definitions(&ctx);
848
849 assert_eq!(defs.len(), 3);
850 assert!(defs.contains_key("ref1"));
851 assert!(defs.contains_key("ref2"));
852 assert!(defs.contains_key("ref3"));
853 }
854
855 #[test]
856 fn test_find_usages() {
857 let rule = MD053LinkImageReferenceDefinitions::new();
858 let content = "[text][ref1] and [ref2] and ![img][ref3]";
859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
860 let usages = rule.find_usages(&ctx);
861
862 assert!(usages.contains("ref1"));
863 assert!(usages.contains("ref2"));
864 assert!(usages.contains("ref3"));
865 }
866
867 #[test]
868 fn test_ignored_definitions_config() {
869 let config = MD053Config {
871 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
872 };
873 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
874
875 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877 let result = rule.check(&ctx).unwrap();
878
879 assert_eq!(result.len(), 1);
881 assert!(result[0].message.contains("unused"));
882 assert!(!result[0].message.contains("todo"));
883 assert!(!result[0].message.contains("draft"));
884 }
885
886 #[test]
887 fn test_ignored_definitions_case_insensitive() {
888 let config = MD053Config {
890 ignored_definitions: vec!["TODO".to_string()],
891 };
892 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
893
894 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let result = rule.check(&ctx).unwrap();
897
898 assert_eq!(result.len(), 1);
900 assert!(result[0].message.contains("unused"));
901 assert!(!result[0].message.contains("todo"));
902 }
903
904 #[test]
905 fn test_default_config_section() {
906 let rule = MD053LinkImageReferenceDefinitions::default();
907 let config_section = rule.default_config_section();
908
909 assert!(config_section.is_some());
910 let (name, value) = config_section.unwrap();
911 assert_eq!(name, "MD053");
912
913 if let toml::Value::Table(table) = value {
915 assert!(table.contains_key("ignored-definitions"));
916 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
917 } else {
918 panic!("Expected TOML table");
919 }
920 }
921
922 #[test]
923 fn test_fix_with_ignored_definitions() {
924 let config = MD053Config {
926 ignored_definitions: vec!["template".to_string()],
927 };
928 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
929
930 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
932 let fixed = rule.fix(&ctx).unwrap();
933
934 assert_eq!(fixed, content);
936 }
937
938 #[test]
939 fn test_duplicate_definitions_exact_case() {
940 let rule = MD053LinkImageReferenceDefinitions::new();
941 let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 let result = rule.check(&ctx).unwrap();
944
945 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
948 assert_eq!(duplicate_warnings.len(), 2);
949 assert_eq!(duplicate_warnings[0].line, 2);
950 assert_eq!(duplicate_warnings[1].line, 3);
951 }
952
953 #[test]
954 fn test_duplicate_definitions_case_variants() {
955 let rule = MD053LinkImageReferenceDefinitions::new();
956 let content =
957 "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959 let result = rule.check(&ctx).unwrap();
960
961 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
964 assert_eq!(duplicate_warnings.len(), 2);
965
966 assert_eq!(duplicate_warnings[0].line, 2);
969 assert_eq!(duplicate_warnings[1].line, 3);
970 }
971
972 #[test]
973 fn test_duplicate_and_unused() {
974 let rule = MD053LinkImageReferenceDefinitions::new();
975 let content = "[used]\n\n[used]: http://url1\n\n[used]: http://url2\n\n[unused]: http://url3";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977 let result = rule.check(&ctx).unwrap();
978
979 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
981 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
982
983 assert_eq!(duplicate_warnings.len(), 1);
984 assert_eq!(unused_warnings.len(), 1);
985 assert_eq!(duplicate_warnings[0].line, 5); assert_eq!(unused_warnings[0].line, 7); }
988
989 #[test]
990 fn test_duplicate_with_usage() {
991 let rule = MD053LinkImageReferenceDefinitions::new();
992 let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996
997 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
999 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
1000
1001 assert_eq!(duplicate_warnings.len(), 1);
1002 assert_eq!(unused_warnings.len(), 0);
1003 assert_eq!(duplicate_warnings[0].line, 4);
1004 }
1005
1006 #[test]
1007 fn test_no_duplicate_different_ids() {
1008 let rule = MD053LinkImageReferenceDefinitions::new();
1009 let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011 let result = rule.check(&ctx).unwrap();
1012
1013 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
1015 assert_eq!(duplicate_warnings.len(), 0);
1016 }
1017
1018 #[test]
1019 fn test_comment_style_reference_double_slash() {
1020 let rule = MD053LinkImageReferenceDefinitions::new();
1021 let content = "[//]: # (This is a comment)\n\nSome regular text.";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024 let result = rule.check(&ctx).unwrap();
1025
1026 assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
1028 }
1029
1030 #[test]
1031 fn test_comment_style_reference_comment_label() {
1032 let rule = MD053LinkImageReferenceDefinitions::new();
1033 let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
1035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036 let result = rule.check(&ctx).unwrap();
1037
1038 assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
1040 }
1041
1042 #[test]
1043 fn test_comment_style_reference_todo_fixme() {
1044 let rule = MD053LinkImageReferenceDefinitions::new();
1045 let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
1047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1048 let result = rule.check(&ctx).unwrap();
1049
1050 assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
1052 }
1053
1054 #[test]
1055 fn test_comment_style_reference_fragment_only() {
1056 let rule = MD053LinkImageReferenceDefinitions::new();
1057 let content = "[anything]: #\n[ref]: #\n\nSome text.";
1059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1060 let result = rule.check(&ctx).unwrap();
1061
1062 assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
1064 }
1065
1066 #[test]
1067 fn test_comment_vs_real_reference() {
1068 let rule = MD053LinkImageReferenceDefinitions::new();
1069 let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072 let result = rule.check(&ctx).unwrap();
1073
1074 assert_eq!(result.len(), 1, "Only real unused references should be flagged");
1076 assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
1077 }
1078
1079 #[test]
1080 fn test_comment_with_fragment_section() {
1081 let rule = MD053LinkImageReferenceDefinitions::new();
1082 let content = "[//]: #section (Comment about section)\n\nSome text.";
1084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1085 let result = rule.check(&ctx).unwrap();
1086
1087 assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1089 }
1090
1091 #[test]
1092 fn test_is_likely_comment_reference_helper() {
1093 assert!(
1095 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1096 "[//]: # should be recognized as comment"
1097 );
1098 assert!(
1099 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1100 "[comment]: #section should be recognized as comment"
1101 );
1102 assert!(
1103 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1104 "[note]: # should be recognized as comment"
1105 );
1106 assert!(
1107 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1108 "[todo]: # should be recognized as comment"
1109 );
1110 assert!(
1111 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1112 "Any label with just '#' should be recognized as comment"
1113 );
1114 assert!(
1115 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1116 "Real URL should not be recognized as comment"
1117 );
1118 assert!(
1119 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1120 "Real URL should not be recognized as comment"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_reference_with_colon_in_name() {
1126 let rule = MD053LinkImageReferenceDefinitions::new();
1128 let content = "Check [RFC: 1234] for specs.\n\n[RFC: 1234]: https://example.com\n";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130 let result = rule.check(&ctx).unwrap();
1131
1132 assert!(
1133 result.is_empty(),
1134 "Reference with colon should be recognized as used, got warnings: {result:?}"
1135 );
1136 }
1137
1138 #[test]
1139 fn test_reference_with_colon_various_styles() {
1140 let rule = MD053LinkImageReferenceDefinitions::new();
1142 let content = r#"See [RFC: 1234] and [Issue: 42] and [PR: 100].
1143
1144[RFC: 1234]: https://example.com/rfc1234
1145[Issue: 42]: https://example.com/issue42
1146[PR: 100]: https://example.com/pr100
1147"#;
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149 let result = rule.check(&ctx).unwrap();
1150
1151 assert!(
1152 result.is_empty(),
1153 "All colon-style references should be recognized as used, got warnings: {result:?}"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_should_skip_pattern_allows_rfc_style() {
1159 assert!(
1162 !MD053LinkImageReferenceDefinitions::should_skip_pattern("RFC: 1234"),
1163 "RFC-style references should NOT be skipped"
1164 );
1165 assert!(
1166 !MD053LinkImageReferenceDefinitions::should_skip_pattern("Issue: 42"),
1167 "Issue-style references should NOT be skipped"
1168 );
1169 assert!(
1170 !MD053LinkImageReferenceDefinitions::should_skip_pattern("PR: 100"),
1171 "PR-style references should NOT be skipped"
1172 );
1173 assert!(
1174 !MD053LinkImageReferenceDefinitions::should_skip_pattern("See: Section 2"),
1175 "References with 'See:' should NOT be skipped"
1176 );
1177 assert!(
1178 !MD053LinkImageReferenceDefinitions::should_skip_pattern("foo:bar"),
1179 "References without space after colon should NOT be skipped"
1180 );
1181 }
1182
1183 #[test]
1184 fn test_should_skip_pattern_skips_prose() {
1185 assert!(
1187 MD053LinkImageReferenceDefinitions::should_skip_pattern("default value is: something"),
1188 "Prose with 3+ words before colon SHOULD be skipped"
1189 );
1190 assert!(
1191 MD053LinkImageReferenceDefinitions::should_skip_pattern("this is a label: description"),
1192 "Prose with 4 words before colon SHOULD be skipped"
1193 );
1194 assert!(
1195 MD053LinkImageReferenceDefinitions::should_skip_pattern("the project root: path/to/dir"),
1196 "Prose-like descriptions SHOULD be skipped"
1197 );
1198 }
1199
1200 #[test]
1201 fn test_many_code_spans_with_shortcut_references() {
1202 let rule = MD053LinkImageReferenceDefinitions::new();
1205
1206 let mut lines = Vec::new();
1207 for i in 0..100 {
1209 lines.push(format!("Some `code{i}` text and [used_ref] here"));
1210 }
1211 lines.push(String::new());
1212 lines.push("[used_ref]: https://example.com".to_string());
1213 lines.push("[unused_ref]: https://unused.com".to_string());
1214
1215 let content = lines.join("\n");
1216 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1217 let result = rule.check(&ctx).unwrap();
1218
1219 assert_eq!(result.len(), 1);
1221 assert!(result[0].message.contains("unused_ref"));
1222 }
1223
1224 #[test]
1225 fn test_multiline_definition_continuation_tracking() {
1226 let rule = MD053LinkImageReferenceDefinitions::new();
1229 let content = "\
1230[ref1]: https://example.com
1231 \"Title on next line\"
1232
1233[ref2]: https://example2.com
1234 \"Another title\"
1235
1236Some text using [ref1] here.
1237";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239 let result = rule.check(&ctx).unwrap();
1240
1241 assert_eq!(result.len(), 1);
1243 assert!(result[0].message.contains("ref2"));
1244 }
1245
1246 #[test]
1247 fn test_code_span_at_boundary_does_not_hide_reference() {
1248 let rule = MD053LinkImageReferenceDefinitions::new();
1250 let content = "`code`[ref]\n\n[ref]: https://example.com";
1251 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1252 let result = rule.check(&ctx).unwrap();
1253
1254 assert_eq!(result.len(), 0);
1256 }
1257
1258 #[test]
1259 fn test_reference_inside_code_span_not_counted() {
1260 let rule = MD053LinkImageReferenceDefinitions::new();
1262 let content = "Use `[ref]` in code\n\n[ref]: https://example.com";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264 let result = rule.check(&ctx).unwrap();
1265
1266 assert_eq!(result.len(), 1);
1268 }
1269
1270 #[test]
1271 fn test_shortcut_ref_at_byte_zero() {
1272 let rule = MD053LinkImageReferenceDefinitions::default();
1273 let content = "[example]\n\n[example]: https://example.com\n";
1274 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1275 let result = rule.check(&ctx).unwrap();
1276 assert!(
1277 result.is_empty(),
1278 "[ref] at byte 0 should be recognized as usage: {result:?}"
1279 );
1280 }
1281
1282 #[test]
1283 fn test_shortcut_ref_at_end_of_line() {
1284 let rule = MD053LinkImageReferenceDefinitions::default();
1285 let content = "Text [example]\n\n[example]: https://example.com\n";
1286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287 let result = rule.check(&ctx).unwrap();
1288 assert!(
1289 result.is_empty(),
1290 "[ref] at end of line should be recognized as usage: {result:?}"
1291 );
1292 }
1293
1294 #[test]
1295 fn test_reference_in_multiline_footnote_not_false_positive() {
1296 let rule = MD053LinkImageReferenceDefinitions::new();
1300 let content = "\
1301# Greetings
1302
1303This is a paragraph that has a footnote.[^footnote]
1304
1305[^footnote]:
1306 This footnote is long enough that it doesn't fit on just one line.
1307 Here is my [website][web].
1308
1309[web]: https://web.evanchen.cc
1310";
1311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312 let result = rule.check(&ctx).unwrap();
1313 assert!(
1314 result.is_empty(),
1315 "Reference used inside multi-line footnote should not be flagged: {result:?}"
1316 );
1317 }
1318
1319 #[test]
1320 fn test_reference_in_single_line_footnote() {
1321 let rule = MD053LinkImageReferenceDefinitions::new();
1322 let content = "\
1323# Greetings
1324
1325This is a paragraph that has a footnote.[^footnote]
1326
1327[^footnote]: Here is my [website][web].
1328
1329[web]: https://web.evanchen.cc
1330";
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332 let result = rule.check(&ctx).unwrap();
1333 assert!(
1334 result.is_empty(),
1335 "Reference used inside single-line footnote should not be flagged: {result:?}"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_shortcut_reference_in_multiline_footnote() {
1341 let rule = MD053LinkImageReferenceDefinitions::new();
1343 let content = "\
1344Text with footnote.[^note]
1345
1346[^note]:
1347 See [web] for details.
1348
1349[web]: https://example.com
1350";
1351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1352 let result = rule.check(&ctx).unwrap();
1353 assert!(
1354 result.is_empty(),
1355 "Shortcut reference inside multi-line footnote should not be flagged: {result:?}"
1356 );
1357 }
1358
1359 #[test]
1360 fn test_unused_reference_not_in_footnote_still_flagged() {
1361 let rule = MD053LinkImageReferenceDefinitions::new();
1363 let content = "\
1364# Greetings
1365
1366This is a paragraph that has a footnote.[^footnote]
1367
1368[^footnote]:
1369 This footnote is long enough.
1370
1371[unused]: https://example.com
1372";
1373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1374 let result = rule.check(&ctx).unwrap();
1375 assert_eq!(result.len(), 1);
1376 assert!(result[0].message.contains("unused"));
1377 }
1378
1379 #[test]
1380 fn test_image_reference_in_multiline_footnote() {
1381 let rule = MD053LinkImageReferenceDefinitions::new();
1382 let content = "\
1383Text with footnote.[^note]
1384
1385[^note]:
1386 Here is a diagram:
1387 ![diagram][img]
1388
1389[img]: https://example.com/diagram.png
1390";
1391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1392 let result = rule.check(&ctx).unwrap();
1393 assert!(
1394 result.is_empty(),
1395 "Image reference inside multi-line footnote should not be flagged: {result:?}"
1396 );
1397 }
1398
1399 #[test]
1400 fn test_multiple_references_in_one_footnote() {
1401 let rule = MD053LinkImageReferenceDefinitions::new();
1402 let content = "\
1403Text.[^note]
1404
1405[^note]:
1406 See [link1][ref1] and [link2][ref2] for details.
1407
1408[ref1]: https://example.com
1409[ref2]: https://example.org
1410";
1411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1412 let result = rule.check(&ctx).unwrap();
1413 assert!(
1414 result.is_empty(),
1415 "Multiple references inside one footnote should all be recognized: {result:?}"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_reference_in_code_block_inside_footnote_not_counted() {
1421 let rule = MD053LinkImageReferenceDefinitions::new();
1424 let content = "\
1425Text.[^code]
1426
1427[^code]:
1428 ```python
1429 x = [ref_like_syntax]
1430 ```
1431
1432[ref_like_syntax]: https://example.com
1433";
1434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1435 let result = rule.check(&ctx).unwrap();
1436 assert_eq!(
1437 result.len(),
1438 1,
1439 "Reference inside fenced code block within footnote should still be unused: {result:?}"
1440 );
1441 assert!(result[0].message.contains("ref_like_syntax"));
1442 }
1443
1444 #[test]
1445 fn test_nested_list_in_footnote_with_reference() {
1446 let rule = MD053LinkImageReferenceDefinitions::new();
1447 let content = "\
1448Text.[^deep]
1449
1450[^deep]:
1451 - List item
1452 - Nested with [link text][deep-ref]
1453
1454[deep-ref]: https://example.com
1455";
1456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1457 let result = rule.check(&ctx).unwrap();
1458 assert!(
1459 result.is_empty(),
1460 "Reference in nested list inside footnote should not be flagged: {result:?}"
1461 );
1462 }
1463}