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