1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::calculate_line_range;
4use fancy_regex::Regex as FancyRegex;
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8use std::sync::LazyLock;
9
10static SHORTCUT_REFERENCE_REGEX: LazyLock<FancyRegex> =
14 LazyLock::new(|| FancyRegex::new(r"(?<!\!)\[([^\]]+)\](?!\[)").unwrap());
15
16static REFERENCE_DEFINITION_REGEX: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"^\s*\[([^\]]+)\]:\s+(.+)$").unwrap());
19
20static CONTINUATION_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s+(.+)$").unwrap());
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(rename_all = "kebab-case")]
26pub struct MD053Config {
27 #[serde(default = "default_ignored_definitions")]
29 pub ignored_definitions: Vec<String>,
30}
31
32impl Default for MD053Config {
33 fn default() -> Self {
34 Self {
35 ignored_definitions: default_ignored_definitions(),
36 }
37 }
38}
39
40fn default_ignored_definitions() -> Vec<String> {
41 Vec::new()
42}
43
44impl RuleConfig for MD053Config {
45 const RULE_NAME: &'static str = "MD053";
46}
47
48#[derive(Clone)]
100pub struct MD053LinkImageReferenceDefinitions {
101 config: MD053Config,
102}
103
104impl MD053LinkImageReferenceDefinitions {
105 pub fn new() -> Self {
107 Self {
108 config: MD053Config::default(),
109 }
110 }
111
112 pub fn from_config_struct(config: MD053Config) -> Self {
114 Self { config }
115 }
116
117 fn should_skip_pattern(text: &str) -> bool {
119 if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
122 return true;
123 }
124
125 if text == "*" || text == "..." || text == "**" {
127 return true;
128 }
129
130 if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
132 return true;
133 }
134
135 if text.len() <= 2 && !text.chars().all(|c| c.is_alphanumeric()) {
138 return true;
139 }
140
141 if text.contains(':') && text.contains(' ') && !text.contains('`') {
145 return true;
146 }
147
148 if text.starts_with('!') {
150 return true;
151 }
152
153 false
165 }
166
167 fn unescape_reference(reference: &str) -> String {
174 reference.replace("\\", "")
176 }
177
178 fn is_likely_comment_reference(ref_id: &str, url: &str) -> bool {
197 const COMMENT_LABELS: &[&str] = &[
199 "//", "comment", "note", "todo", "fixme", "hack", ];
206
207 let normalized_id = ref_id.trim().to_lowercase();
208 let normalized_url = url.trim();
209
210 if COMMENT_LABELS.contains(&normalized_id.as_str()) && normalized_url.starts_with('#') {
213 return true;
214 }
215
216 if normalized_url == "#" {
219 return true;
220 }
221
222 false
223 }
224
225 fn find_definitions(&self, ctx: &crate::lint_context::LintContext) -> HashMap<String, Vec<(usize, usize)>> {
229 let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
230
231 for ref_def in &ctx.reference_defs {
233 if Self::is_likely_comment_reference(&ref_def.id, &ref_def.url) {
235 continue;
236 }
237
238 let normalized_id = Self::unescape_reference(&ref_def.id); definitions
241 .entry(normalized_id)
242 .or_default()
243 .push((ref_def.line - 1, ref_def.line - 1)); }
245
246 let lines = &ctx.lines;
248 let mut i = 0;
249 while i < lines.len() {
250 let line_info = &lines[i];
251 let line = line_info.content(ctx.content);
252
253 if line_info.in_code_block || line_info.in_front_matter {
255 i += 1;
256 continue;
257 }
258
259 if i > 0 && CONTINUATION_REGEX.is_match(line) {
261 let mut def_start = i - 1;
263 while def_start > 0 && !REFERENCE_DEFINITION_REGEX.is_match(lines[def_start].content(ctx.content)) {
264 def_start -= 1;
265 }
266
267 if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(lines[def_start].content(ctx.content)) {
268 let ref_id = caps.get(1).unwrap().as_str().trim();
269 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
270
271 if let Some(ranges) = definitions.get_mut(&normalized_id)
273 && let Some(last_range) = ranges.last_mut()
274 && last_range.0 == def_start
275 {
276 last_range.1 = i;
277 }
278 }
279 }
280 i += 1;
281 }
282 definitions
283 }
284
285 fn find_usages(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
290 let mut usages: HashSet<String> = HashSet::new();
291
292 for link in &ctx.links {
294 if link.is_reference
295 && let Some(ref_id) = &link.reference_id
296 {
297 if !ctx.line_info(link.line).is_some_and(|info| info.in_code_block) {
299 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
300 }
301 }
302 }
303
304 for image in &ctx.images {
306 if image.is_reference
307 && let Some(ref_id) = &image.reference_id
308 {
309 if !ctx.line_info(image.line).is_some_and(|info| info.in_code_block) {
311 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
312 }
313 }
314 }
315
316 let code_spans = ctx.code_spans();
320
321 for line_info in ctx.lines.iter() {
322 if line_info.in_code_block || line_info.in_front_matter {
324 continue;
325 }
326
327 if REFERENCE_DEFINITION_REGEX.is_match(line_info.content(ctx.content)) {
329 continue;
330 }
331
332 for caps in SHORTCUT_REFERENCE_REGEX
334 .captures_iter(line_info.content(ctx.content))
335 .flatten()
336 {
337 if let Some(full_match) = caps.get(0)
338 && let Some(ref_id_match) = caps.get(1)
339 {
340 let match_byte_offset = line_info.byte_offset + full_match.start();
342 let in_code_span = code_spans
343 .iter()
344 .any(|span| match_byte_offset >= span.byte_offset && match_byte_offset < span.byte_end);
345
346 if !in_code_span {
347 let ref_id = ref_id_match.as_str().trim();
348
349 if !Self::should_skip_pattern(ref_id) {
350 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
351 usages.insert(normalized_id);
352 }
353 }
354 }
355 }
356 }
357
358 usages
364 }
365
366 fn get_unused_references(
373 &self,
374 definitions: &HashMap<String, Vec<(usize, usize)>>,
375 usages: &HashSet<String>,
376 ) -> Vec<(String, usize, usize)> {
377 let mut unused = Vec::new();
378 for (id, ranges) in definitions {
379 if !usages.contains(id) && !self.is_ignored_definition(id) {
381 if ranges.len() == 1 {
384 let (start, end) = ranges[0];
385 unused.push((id.clone(), start, end));
386 }
387 }
390 }
391 unused
392 }
393
394 fn is_ignored_definition(&self, definition_id: &str) -> bool {
396 self.config
397 .ignored_definitions
398 .iter()
399 .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
400 }
401}
402
403impl Default for MD053LinkImageReferenceDefinitions {
404 fn default() -> Self {
405 Self::new()
406 }
407}
408
409impl Rule for MD053LinkImageReferenceDefinitions {
410 fn name(&self) -> &'static str {
411 "MD053"
412 }
413
414 fn description(&self) -> &'static str {
415 "Link and image reference definitions should be needed"
416 }
417
418 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
422 let definitions = self.find_definitions(ctx);
424 let usages = self.find_usages(ctx);
425
426 let unused_refs = self.get_unused_references(&definitions, &usages);
428
429 let mut warnings = Vec::new();
430
431 let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); for (definition_id, ranges) in &definitions {
435 if self.is_ignored_definition(definition_id) {
437 continue;
438 }
439
440 if ranges.len() > 1 {
441 for (i, &(start_line, _)) in ranges.iter().enumerate() {
443 if i > 0 {
444 let line_num = start_line + 1;
446 let line_content = ctx.lines.get(start_line).map(|l| l.content(ctx.content)).unwrap_or("");
447 let (start_line_1idx, start_col, end_line, end_col) =
448 calculate_line_range(line_num, line_content);
449
450 warnings.push(LintWarning {
451 rule_name: Some(self.name().to_string()),
452 line: start_line_1idx,
453 column: start_col,
454 end_line,
455 end_column: end_col,
456 message: format!("Duplicate link or image reference definition: [{definition_id}]"),
457 severity: Severity::Warning,
458 fix: None,
459 });
460 }
461 }
462 }
463
464 if let Some(&(start_line, _)) = ranges.first() {
466 if let Some(line_info) = ctx.lines.get(start_line)
468 && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(line_info.content(ctx.content))
469 {
470 let original_id = caps.get(1).unwrap().as_str().trim();
471 let lower_id = original_id.to_lowercase();
472
473 if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
474 if first_original != original_id {
476 let line_num = start_line + 1;
477 let line_content = line_info.content(ctx.content);
478 let (start_line_1idx, start_col, end_line, end_col) =
479 calculate_line_range(line_num, line_content);
480
481 warnings.push(LintWarning {
482 rule_name: Some(self.name().to_string()),
483 line: start_line_1idx,
484 column: start_col,
485 end_line,
486 end_column: end_col,
487 message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
488 original_id, first_original, first_line + 1),
489 severity: Severity::Warning,
490 fix: None,
491 });
492 }
493 } else {
494 seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
495 }
496 }
497 }
498 }
499
500 for (definition, start, _end) in unused_refs {
502 let line_num = start + 1; let line_content = ctx.lines.get(start).map(|l| l.content(ctx.content)).unwrap_or("");
504
505 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
507
508 warnings.push(LintWarning {
509 rule_name: Some(self.name().to_string()),
510 line: start_line,
511 column: start_col,
512 end_line,
513 end_column: end_col,
514 message: format!("Unused link/image reference: [{definition}]"),
515 severity: Severity::Warning,
516 fix: None, });
518 }
519
520 Ok(warnings)
521 }
522
523 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
525 Ok(ctx.content.to_string())
527 }
528
529 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
531 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
533 }
534
535 fn as_any(&self) -> &dyn std::any::Any {
536 self
537 }
538
539 fn default_config_section(&self) -> Option<(String, toml::Value)> {
540 let default_config = MD053Config::default();
541 let json_value = serde_json::to_value(&default_config).ok()?;
542 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
543 if let toml::Value::Table(table) = toml_value {
544 if !table.is_empty() {
545 Some((MD053Config::RULE_NAME.to_string(), toml::Value::Table(table)))
546 } else {
547 None
548 }
549 } else {
550 None
551 }
552 }
553
554 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
555 where
556 Self: Sized,
557 {
558 let rule_config = crate::rule_config_serde::load_rule_config::<MD053Config>(config);
559 Box::new(MD053LinkImageReferenceDefinitions::from_config_struct(rule_config))
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566 use crate::lint_context::LintContext;
567
568 #[test]
569 fn test_used_reference_link() {
570 let rule = MD053LinkImageReferenceDefinitions::new();
571 let content = "[text][ref]\n\n[ref]: https://example.com";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
573 let result = rule.check(&ctx).unwrap();
574
575 assert_eq!(result.len(), 0);
576 }
577
578 #[test]
579 fn test_unused_reference_definition() {
580 let rule = MD053LinkImageReferenceDefinitions::new();
581 let content = "[unused]: https://example.com";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
583 let result = rule.check(&ctx).unwrap();
584
585 assert_eq!(result.len(), 1);
586 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
587 }
588
589 #[test]
590 fn test_used_reference_image() {
591 let rule = MD053LinkImageReferenceDefinitions::new();
592 let content = "![alt][img]\n\n[img]: image.jpg";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
594 let result = rule.check(&ctx).unwrap();
595
596 assert_eq!(result.len(), 0);
597 }
598
599 #[test]
600 fn test_case_insensitive_matching() {
601 let rule = MD053LinkImageReferenceDefinitions::new();
602 let content = "[Text][REF]\n\n[ref]: https://example.com";
603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
604 let result = rule.check(&ctx).unwrap();
605
606 assert_eq!(result.len(), 0);
607 }
608
609 #[test]
610 fn test_shortcut_reference() {
611 let rule = MD053LinkImageReferenceDefinitions::new();
612 let content = "[ref]\n\n[ref]: https://example.com";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
614 let result = rule.check(&ctx).unwrap();
615
616 assert_eq!(result.len(), 0);
617 }
618
619 #[test]
620 fn test_collapsed_reference() {
621 let rule = MD053LinkImageReferenceDefinitions::new();
622 let content = "[ref][]\n\n[ref]: https://example.com";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
624 let result = rule.check(&ctx).unwrap();
625
626 assert_eq!(result.len(), 0);
627 }
628
629 #[test]
630 fn test_multiple_unused_definitions() {
631 let rule = MD053LinkImageReferenceDefinitions::new();
632 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
634 let result = rule.check(&ctx).unwrap();
635
636 assert_eq!(result.len(), 3);
637
638 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
640 assert!(messages.iter().any(|m| m.contains("unused1")));
641 assert!(messages.iter().any(|m| m.contains("unused2")));
642 assert!(messages.iter().any(|m| m.contains("unused3")));
643 }
644
645 #[test]
646 fn test_mixed_used_and_unused() {
647 let rule = MD053LinkImageReferenceDefinitions::new();
648 let content = "[used]\n\n[used]: url1\n[unused]: url2";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
650 let result = rule.check(&ctx).unwrap();
651
652 assert_eq!(result.len(), 1);
653 assert!(result[0].message.contains("unused"));
654 }
655
656 #[test]
657 fn test_multiline_definition() {
658 let rule = MD053LinkImageReferenceDefinitions::new();
659 let content = "[ref]: https://example.com\n \"Title on next line\"";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
661 let result = rule.check(&ctx).unwrap();
662
663 assert_eq!(result.len(), 1); }
665
666 #[test]
667 fn test_reference_in_code_block() {
668 let rule = MD053LinkImageReferenceDefinitions::new();
669 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
671 let result = rule.check(&ctx).unwrap();
672
673 assert_eq!(result.len(), 1);
675 }
676
677 #[test]
678 fn test_reference_in_inline_code() {
679 let rule = MD053LinkImageReferenceDefinitions::new();
680 let content = "`[ref]`\n\n[ref]: https://example.com";
681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
682 let result = rule.check(&ctx).unwrap();
683
684 assert_eq!(result.len(), 1);
686 }
687
688 #[test]
689 fn test_escaped_reference() {
690 let rule = MD053LinkImageReferenceDefinitions::new();
691 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
693 let result = rule.check(&ctx).unwrap();
694
695 assert_eq!(result.len(), 0);
697 }
698
699 #[test]
700 fn test_duplicate_definitions() {
701 let rule = MD053LinkImageReferenceDefinitions::new();
702 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
704 let result = rule.check(&ctx).unwrap();
705
706 assert_eq!(result.len(), 1);
708 }
709
710 #[test]
711 fn test_fix_returns_original() {
712 let rule = MD053LinkImageReferenceDefinitions::new();
714 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
716 let fixed = rule.fix(&ctx).unwrap();
717
718 assert_eq!(fixed, content);
719 }
720
721 #[test]
722 fn test_fix_preserves_content() {
723 let rule = MD053LinkImageReferenceDefinitions::new();
725 let content = "Content\n\n[unused]: url\n\nMore content";
726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
727 let fixed = rule.fix(&ctx).unwrap();
728
729 assert_eq!(fixed, content);
730 }
731
732 #[test]
733 fn test_fix_does_not_remove() {
734 let rule = MD053LinkImageReferenceDefinitions::new();
736 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
738 let fixed = rule.fix(&ctx).unwrap();
739
740 assert_eq!(fixed, content);
741 }
742
743 #[test]
744 fn test_special_characters_in_reference() {
745 let rule = MD053LinkImageReferenceDefinitions::new();
746 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
748 let result = rule.check(&ctx).unwrap();
749
750 assert_eq!(result.len(), 0);
751 }
752
753 #[test]
754 fn test_find_definitions() {
755 let rule = MD053LinkImageReferenceDefinitions::new();
756 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
758 let defs = rule.find_definitions(&ctx);
759
760 assert_eq!(defs.len(), 3);
761 assert!(defs.contains_key("ref1"));
762 assert!(defs.contains_key("ref2"));
763 assert!(defs.contains_key("ref3"));
764 }
765
766 #[test]
767 fn test_find_usages() {
768 let rule = MD053LinkImageReferenceDefinitions::new();
769 let content = "[text][ref1] and [ref2] and ![img][ref3]";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
771 let usages = rule.find_usages(&ctx);
772
773 assert!(usages.contains("ref1"));
774 assert!(usages.contains("ref2"));
775 assert!(usages.contains("ref3"));
776 }
777
778 #[test]
779 fn test_ignored_definitions_config() {
780 let config = MD053Config {
782 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
783 };
784 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
785
786 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
788 let result = rule.check(&ctx).unwrap();
789
790 assert_eq!(result.len(), 1);
792 assert!(result[0].message.contains("unused"));
793 assert!(!result[0].message.contains("todo"));
794 assert!(!result[0].message.contains("draft"));
795 }
796
797 #[test]
798 fn test_ignored_definitions_case_insensitive() {
799 let config = MD053Config {
801 ignored_definitions: vec!["TODO".to_string()],
802 };
803 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
804
805 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
807 let result = rule.check(&ctx).unwrap();
808
809 assert_eq!(result.len(), 1);
811 assert!(result[0].message.contains("unused"));
812 assert!(!result[0].message.contains("todo"));
813 }
814
815 #[test]
816 fn test_default_config_section() {
817 let rule = MD053LinkImageReferenceDefinitions::default();
818 let config_section = rule.default_config_section();
819
820 assert!(config_section.is_some());
821 let (name, value) = config_section.unwrap();
822 assert_eq!(name, "MD053");
823
824 if let toml::Value::Table(table) = value {
826 assert!(table.contains_key("ignored-definitions"));
827 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
828 } else {
829 panic!("Expected TOML table");
830 }
831 }
832
833 #[test]
834 fn test_fix_with_ignored_definitions() {
835 let config = MD053Config {
837 ignored_definitions: vec!["template".to_string()],
838 };
839 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
840
841 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
843 let fixed = rule.fix(&ctx).unwrap();
844
845 assert_eq!(fixed, content);
847 }
848
849 #[test]
850 fn test_duplicate_definitions_exact_case() {
851 let rule = MD053LinkImageReferenceDefinitions::new();
852 let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
854 let result = rule.check(&ctx).unwrap();
855
856 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
859 assert_eq!(duplicate_warnings.len(), 2);
860 assert_eq!(duplicate_warnings[0].line, 2);
861 assert_eq!(duplicate_warnings[1].line, 3);
862 }
863
864 #[test]
865 fn test_duplicate_definitions_case_variants() {
866 let rule = MD053LinkImageReferenceDefinitions::new();
867 let content =
868 "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
870 let result = rule.check(&ctx).unwrap();
871
872 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
875 assert_eq!(duplicate_warnings.len(), 2);
876
877 assert_eq!(duplicate_warnings[0].line, 2);
880 assert_eq!(duplicate_warnings[1].line, 3);
881 }
882
883 #[test]
884 fn test_duplicate_and_unused() {
885 let rule = MD053LinkImageReferenceDefinitions::new();
886 let content = "[used]\n[used]: url1\n[used]: url2\n[unused]: url3";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
888 let result = rule.check(&ctx).unwrap();
889
890 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
892 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
893
894 assert_eq!(duplicate_warnings.len(), 1);
895 assert_eq!(unused_warnings.len(), 1);
896 assert_eq!(duplicate_warnings[0].line, 3); assert_eq!(unused_warnings[0].line, 4); }
899
900 #[test]
901 fn test_duplicate_with_usage() {
902 let rule = MD053LinkImageReferenceDefinitions::new();
903 let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
906 let result = rule.check(&ctx).unwrap();
907
908 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
910 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
911
912 assert_eq!(duplicate_warnings.len(), 1);
913 assert_eq!(unused_warnings.len(), 0);
914 assert_eq!(duplicate_warnings[0].line, 4);
915 }
916
917 #[test]
918 fn test_no_duplicate_different_ids() {
919 let rule = MD053LinkImageReferenceDefinitions::new();
920 let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
922 let result = rule.check(&ctx).unwrap();
923
924 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
926 assert_eq!(duplicate_warnings.len(), 0);
927 }
928
929 #[test]
930 fn test_comment_style_reference_double_slash() {
931 let rule = MD053LinkImageReferenceDefinitions::new();
932 let content = "[//]: # (This is a comment)\n\nSome regular text.";
934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
935 let result = rule.check(&ctx).unwrap();
936
937 assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
939 }
940
941 #[test]
942 fn test_comment_style_reference_comment_label() {
943 let rule = MD053LinkImageReferenceDefinitions::new();
944 let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
946 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
947 let result = rule.check(&ctx).unwrap();
948
949 assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
951 }
952
953 #[test]
954 fn test_comment_style_reference_todo_fixme() {
955 let rule = MD053LinkImageReferenceDefinitions::new();
956 let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
959 let result = rule.check(&ctx).unwrap();
960
961 assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
963 }
964
965 #[test]
966 fn test_comment_style_reference_fragment_only() {
967 let rule = MD053LinkImageReferenceDefinitions::new();
968 let content = "[anything]: #\n[ref]: #\n\nSome text.";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
971 let result = rule.check(&ctx).unwrap();
972
973 assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
975 }
976
977 #[test]
978 fn test_comment_vs_real_reference() {
979 let rule = MD053LinkImageReferenceDefinitions::new();
980 let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
983 let result = rule.check(&ctx).unwrap();
984
985 assert_eq!(result.len(), 1, "Only real unused references should be flagged");
987 assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
988 }
989
990 #[test]
991 fn test_comment_with_fragment_section() {
992 let rule = MD053LinkImageReferenceDefinitions::new();
993 let content = "[//]: #section (Comment about section)\n\nSome text.";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
996 let result = rule.check(&ctx).unwrap();
997
998 assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1000 }
1001
1002 #[test]
1003 fn test_is_likely_comment_reference_helper() {
1004 assert!(
1006 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1007 "[//]: # should be recognized as comment"
1008 );
1009 assert!(
1010 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1011 "[comment]: #section should be recognized as comment"
1012 );
1013 assert!(
1014 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1015 "[note]: # should be recognized as comment"
1016 );
1017 assert!(
1018 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1019 "[todo]: # should be recognized as comment"
1020 );
1021 assert!(
1022 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1023 "Any label with just '#' should be recognized as comment"
1024 );
1025 assert!(
1026 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1027 "Real URL should not be recognized as comment"
1028 );
1029 assert!(
1030 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1031 "Real URL should not be recognized as comment"
1032 );
1033 }
1034}