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 lazy_static::lazy_static;
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9
10lazy_static! {
11 static ref SHORTCUT_REFERENCE_REGEX: FancyRegex =
21 FancyRegex::new(r"(?<!\!)\[([^\]]+)\](?!\[)").unwrap();
22
23 static ref REFERENCE_DEFINITION_REGEX: Regex =
29 Regex::new(r"^\s*\[([^\]]+)\]:\s+(.+)$").unwrap();
30
31 static ref CONTINUATION_REGEX: Regex = Regex::new(r"^\s+(.+)$").unwrap();
33
34 static ref CODE_BLOCK_START_REGEX: Regex = Regex::new(r"^(\s*)(`{3,}|~{3,})").unwrap();
36 static ref CODE_BLOCK_END_REGEX: Regex = Regex::new(r"^(\s*)(`{3,}|~{3,})\s*$").unwrap();
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41#[serde(rename_all = "kebab-case")]
42pub struct MD053Config {
43 #[serde(default = "default_ignored_definitions")]
45 pub ignored_definitions: Vec<String>,
46}
47
48impl Default for MD053Config {
49 fn default() -> Self {
50 Self {
51 ignored_definitions: default_ignored_definitions(),
52 }
53 }
54}
55
56fn default_ignored_definitions() -> Vec<String> {
57 Vec::new()
58}
59
60impl RuleConfig for MD053Config {
61 const RULE_NAME: &'static str = "MD053";
62}
63
64#[derive(Clone)]
116pub struct MD053LinkImageReferenceDefinitions {
117 config: MD053Config,
118}
119
120impl MD053LinkImageReferenceDefinitions {
121 pub fn new() -> Self {
123 Self {
124 config: MD053Config::default(),
125 }
126 }
127
128 pub fn from_config_struct(config: MD053Config) -> Self {
130 Self { config }
131 }
132
133 fn should_skip_pattern(text: &str) -> bool {
135 if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
138 return true;
139 }
140
141 if text == "*" || text == "..." || text == "**" {
143 return true;
144 }
145
146 if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
148 return true;
149 }
150
151 if text.len() <= 2 && !text.chars().all(|c| c.is_alphanumeric()) {
154 return true;
155 }
156
157 if text.contains(':') && text.contains(' ') && !text.contains('`') {
161 return true;
162 }
163
164 if text.starts_with('!') {
166 return true;
167 }
168
169 false
181 }
182
183 fn unescape_reference(reference: &str) -> String {
190 reference.replace("\\", "")
192 }
193
194 fn is_likely_comment_reference(ref_id: &str, url: &str) -> bool {
213 const COMMENT_LABELS: &[&str] = &[
215 "//", "comment", "note", "todo", "fixme", "hack", ];
222
223 let normalized_id = ref_id.trim().to_lowercase();
224 let normalized_url = url.trim();
225
226 if COMMENT_LABELS.contains(&normalized_id.as_str()) && normalized_url.starts_with('#') {
229 return true;
230 }
231
232 if normalized_url == "#" {
235 return true;
236 }
237
238 false
239 }
240
241 fn find_definitions(&self, ctx: &crate::lint_context::LintContext) -> HashMap<String, Vec<(usize, usize)>> {
245 let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
246
247 for ref_def in &ctx.reference_defs {
249 if Self::is_likely_comment_reference(&ref_def.id, &ref_def.url) {
251 continue;
252 }
253
254 let normalized_id = Self::unescape_reference(&ref_def.id); definitions
257 .entry(normalized_id)
258 .or_default()
259 .push((ref_def.line - 1, ref_def.line - 1)); }
261
262 let lines = &ctx.lines;
264 let mut i = 0;
265 while i < lines.len() {
266 let line_info = &lines[i];
267 let line = &line_info.content;
268
269 if line_info.in_code_block || line_info.in_front_matter {
271 i += 1;
272 continue;
273 }
274
275 if i > 0 && CONTINUATION_REGEX.is_match(line) {
277 let mut def_start = i - 1;
279 while def_start > 0 && !REFERENCE_DEFINITION_REGEX.is_match(&lines[def_start].content) {
280 def_start -= 1;
281 }
282
283 if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&lines[def_start].content) {
284 let ref_id = caps.get(1).unwrap().as_str().trim();
285 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
286
287 if let Some(ranges) = definitions.get_mut(&normalized_id)
289 && let Some(last_range) = ranges.last_mut()
290 && last_range.0 == def_start
291 {
292 last_range.1 = i;
293 }
294 }
295 }
296 i += 1;
297 }
298 definitions
299 }
300
301 fn find_usages(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
306 let mut usages: HashSet<String> = HashSet::new();
307
308 for link in &ctx.links {
310 if link.is_reference
311 && let Some(ref_id) = &link.reference_id
312 {
313 if !ctx.line_info(link.line).is_some_and(|info| info.in_code_block) {
315 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
316 }
317 }
318 }
319
320 for image in &ctx.images {
322 if image.is_reference
323 && let Some(ref_id) = &image.reference_id
324 {
325 if !ctx.line_info(image.line).is_some_and(|info| info.in_code_block) {
327 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
328 }
329 }
330 }
331
332 let code_spans = ctx.code_spans();
336
337 for line_info in ctx.lines.iter() {
338 if line_info.in_code_block || line_info.in_front_matter {
340 continue;
341 }
342
343 if REFERENCE_DEFINITION_REGEX.is_match(&line_info.content) {
345 continue;
346 }
347
348 for caps in SHORTCUT_REFERENCE_REGEX.captures_iter(&line_info.content).flatten() {
350 if let Some(full_match) = caps.get(0)
351 && let Some(ref_id_match) = caps.get(1)
352 {
353 let match_byte_offset = line_info.byte_offset + full_match.start();
355 let in_code_span = code_spans
356 .iter()
357 .any(|span| match_byte_offset >= span.byte_offset && match_byte_offset < span.byte_end);
358
359 if !in_code_span {
360 let ref_id = ref_id_match.as_str().trim();
361
362 if !Self::should_skip_pattern(ref_id) {
363 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
364 usages.insert(normalized_id);
365 }
366 }
367 }
368 }
369 }
370
371 usages
377 }
378
379 fn get_unused_references(
386 &self,
387 definitions: &HashMap<String, Vec<(usize, usize)>>,
388 usages: &HashSet<String>,
389 ) -> Vec<(String, usize, usize)> {
390 let mut unused = Vec::new();
391 for (id, ranges) in definitions {
392 if !usages.contains(id) && !self.is_ignored_definition(id) {
394 if ranges.len() == 1 {
397 let (start, end) = ranges[0];
398 unused.push((id.clone(), start, end));
399 }
400 }
403 }
404 unused
405 }
406
407 fn is_ignored_definition(&self, definition_id: &str) -> bool {
409 self.config
410 .ignored_definitions
411 .iter()
412 .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
413 }
414}
415
416impl Default for MD053LinkImageReferenceDefinitions {
417 fn default() -> Self {
418 Self::new()
419 }
420}
421
422impl Rule for MD053LinkImageReferenceDefinitions {
423 fn name(&self) -> &'static str {
424 "MD053"
425 }
426
427 fn description(&self) -> &'static str {
428 "Link and image reference definitions should be needed"
429 }
430
431 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
435 let definitions = self.find_definitions(ctx);
437 let usages = self.find_usages(ctx);
438
439 let unused_refs = self.get_unused_references(&definitions, &usages);
441
442 let mut warnings = Vec::new();
443
444 let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); for (definition_id, ranges) in &definitions {
448 if self.is_ignored_definition(definition_id) {
450 continue;
451 }
452
453 if ranges.len() > 1 {
454 for (i, &(start_line, _)) in ranges.iter().enumerate() {
456 if i > 0 {
457 let line_num = start_line + 1;
459 let line_content = ctx.lines.get(start_line).map(|l| l.content.as_str()).unwrap_or("");
460 let (start_line_1idx, start_col, end_line, end_col) =
461 calculate_line_range(line_num, line_content);
462
463 warnings.push(LintWarning {
464 rule_name: Some(self.name().to_string()),
465 line: start_line_1idx,
466 column: start_col,
467 end_line,
468 end_column: end_col,
469 message: format!("Duplicate link or image reference definition: [{definition_id}]"),
470 severity: Severity::Warning,
471 fix: None,
472 });
473 }
474 }
475 }
476
477 if let Some(&(start_line, _)) = ranges.first() {
479 if let Some(line_info) = ctx.lines.get(start_line)
481 && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&line_info.content)
482 {
483 let original_id = caps.get(1).unwrap().as_str().trim();
484 let lower_id = original_id.to_lowercase();
485
486 if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
487 if first_original != original_id {
489 let line_num = start_line + 1;
490 let line_content = &line_info.content;
491 let (start_line_1idx, start_col, end_line, end_col) =
492 calculate_line_range(line_num, line_content);
493
494 warnings.push(LintWarning {
495 rule_name: Some(self.name().to_string()),
496 line: start_line_1idx,
497 column: start_col,
498 end_line,
499 end_column: end_col,
500 message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
501 original_id, first_original, first_line + 1),
502 severity: Severity::Warning,
503 fix: None,
504 });
505 }
506 } else {
507 seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
508 }
509 }
510 }
511 }
512
513 for (definition, start, _end) in unused_refs {
515 let line_num = start + 1; let line_content = ctx.lines.get(start).map(|l| l.content.as_str()).unwrap_or("");
517
518 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
520
521 warnings.push(LintWarning {
522 rule_name: Some(self.name().to_string()),
523 line: start_line,
524 column: start_col,
525 end_line,
526 end_column: end_col,
527 message: format!("Unused link/image reference: [{definition}]"),
528 severity: Severity::Warning,
529 fix: None, });
531 }
532
533 Ok(warnings)
534 }
535
536 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
538 Ok(ctx.content.to_string())
540 }
541
542 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
544 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
546 }
547
548 fn as_any(&self) -> &dyn std::any::Any {
549 self
550 }
551
552 fn default_config_section(&self) -> Option<(String, toml::Value)> {
553 let default_config = MD053Config::default();
554 let json_value = serde_json::to_value(&default_config).ok()?;
555 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
556 if let toml::Value::Table(table) = toml_value {
557 if !table.is_empty() {
558 Some((MD053Config::RULE_NAME.to_string(), toml::Value::Table(table)))
559 } else {
560 None
561 }
562 } else {
563 None
564 }
565 }
566
567 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
568 where
569 Self: Sized,
570 {
571 let rule_config = crate::rule_config_serde::load_rule_config::<MD053Config>(config);
572 Box::new(MD053LinkImageReferenceDefinitions::from_config_struct(rule_config))
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::lint_context::LintContext;
580
581 #[test]
582 fn test_used_reference_link() {
583 let rule = MD053LinkImageReferenceDefinitions::new();
584 let content = "[text][ref]\n\n[ref]: https://example.com";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
586 let result = rule.check(&ctx).unwrap();
587
588 assert_eq!(result.len(), 0);
589 }
590
591 #[test]
592 fn test_unused_reference_definition() {
593 let rule = MD053LinkImageReferenceDefinitions::new();
594 let content = "[unused]: https://example.com";
595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
596 let result = rule.check(&ctx).unwrap();
597
598 assert_eq!(result.len(), 1);
599 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
600 }
601
602 #[test]
603 fn test_used_reference_image() {
604 let rule = MD053LinkImageReferenceDefinitions::new();
605 let content = "![alt][img]\n\n[img]: image.jpg";
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
607 let result = rule.check(&ctx).unwrap();
608
609 assert_eq!(result.len(), 0);
610 }
611
612 #[test]
613 fn test_case_insensitive_matching() {
614 let rule = MD053LinkImageReferenceDefinitions::new();
615 let content = "[Text][REF]\n\n[ref]: https://example.com";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
617 let result = rule.check(&ctx).unwrap();
618
619 assert_eq!(result.len(), 0);
620 }
621
622 #[test]
623 fn test_shortcut_reference() {
624 let rule = MD053LinkImageReferenceDefinitions::new();
625 let content = "[ref]\n\n[ref]: https://example.com";
626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
627 let result = rule.check(&ctx).unwrap();
628
629 assert_eq!(result.len(), 0);
630 }
631
632 #[test]
633 fn test_collapsed_reference() {
634 let rule = MD053LinkImageReferenceDefinitions::new();
635 let content = "[ref][]\n\n[ref]: https://example.com";
636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
637 let result = rule.check(&ctx).unwrap();
638
639 assert_eq!(result.len(), 0);
640 }
641
642 #[test]
643 fn test_multiple_unused_definitions() {
644 let rule = MD053LinkImageReferenceDefinitions::new();
645 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
647 let result = rule.check(&ctx).unwrap();
648
649 assert_eq!(result.len(), 3);
650
651 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
653 assert!(messages.iter().any(|m| m.contains("unused1")));
654 assert!(messages.iter().any(|m| m.contains("unused2")));
655 assert!(messages.iter().any(|m| m.contains("unused3")));
656 }
657
658 #[test]
659 fn test_mixed_used_and_unused() {
660 let rule = MD053LinkImageReferenceDefinitions::new();
661 let content = "[used]\n\n[used]: url1\n[unused]: url2";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
663 let result = rule.check(&ctx).unwrap();
664
665 assert_eq!(result.len(), 1);
666 assert!(result[0].message.contains("unused"));
667 }
668
669 #[test]
670 fn test_multiline_definition() {
671 let rule = MD053LinkImageReferenceDefinitions::new();
672 let content = "[ref]: https://example.com\n \"Title on next line\"";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
674 let result = rule.check(&ctx).unwrap();
675
676 assert_eq!(result.len(), 1); }
678
679 #[test]
680 fn test_reference_in_code_block() {
681 let rule = MD053LinkImageReferenceDefinitions::new();
682 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
684 let result = rule.check(&ctx).unwrap();
685
686 assert_eq!(result.len(), 1);
688 }
689
690 #[test]
691 fn test_reference_in_inline_code() {
692 let rule = MD053LinkImageReferenceDefinitions::new();
693 let content = "`[ref]`\n\n[ref]: https://example.com";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
695 let result = rule.check(&ctx).unwrap();
696
697 assert_eq!(result.len(), 1);
699 }
700
701 #[test]
702 fn test_escaped_reference() {
703 let rule = MD053LinkImageReferenceDefinitions::new();
704 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
706 let result = rule.check(&ctx).unwrap();
707
708 assert_eq!(result.len(), 0);
710 }
711
712 #[test]
713 fn test_duplicate_definitions() {
714 let rule = MD053LinkImageReferenceDefinitions::new();
715 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
717 let result = rule.check(&ctx).unwrap();
718
719 assert_eq!(result.len(), 1);
721 }
722
723 #[test]
724 fn test_fix_returns_original() {
725 let rule = MD053LinkImageReferenceDefinitions::new();
727 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
729 let fixed = rule.fix(&ctx).unwrap();
730
731 assert_eq!(fixed, content);
732 }
733
734 #[test]
735 fn test_fix_preserves_content() {
736 let rule = MD053LinkImageReferenceDefinitions::new();
738 let content = "Content\n\n[unused]: url\n\nMore content";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
740 let fixed = rule.fix(&ctx).unwrap();
741
742 assert_eq!(fixed, content);
743 }
744
745 #[test]
746 fn test_fix_does_not_remove() {
747 let rule = MD053LinkImageReferenceDefinitions::new();
749 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
751 let fixed = rule.fix(&ctx).unwrap();
752
753 assert_eq!(fixed, content);
754 }
755
756 #[test]
757 fn test_special_characters_in_reference() {
758 let rule = MD053LinkImageReferenceDefinitions::new();
759 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
761 let result = rule.check(&ctx).unwrap();
762
763 assert_eq!(result.len(), 0);
764 }
765
766 #[test]
767 fn test_find_definitions() {
768 let rule = MD053LinkImageReferenceDefinitions::new();
769 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
771 let defs = rule.find_definitions(&ctx);
772
773 assert_eq!(defs.len(), 3);
774 assert!(defs.contains_key("ref1"));
775 assert!(defs.contains_key("ref2"));
776 assert!(defs.contains_key("ref3"));
777 }
778
779 #[test]
780 fn test_find_usages() {
781 let rule = MD053LinkImageReferenceDefinitions::new();
782 let content = "[text][ref1] and [ref2] and ![img][ref3]";
783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
784 let usages = rule.find_usages(&ctx);
785
786 assert!(usages.contains("ref1"));
787 assert!(usages.contains("ref2"));
788 assert!(usages.contains("ref3"));
789 }
790
791 #[test]
792 fn test_ignored_definitions_config() {
793 let config = MD053Config {
795 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
796 };
797 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
798
799 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
801 let result = rule.check(&ctx).unwrap();
802
803 assert_eq!(result.len(), 1);
805 assert!(result[0].message.contains("unused"));
806 assert!(!result[0].message.contains("todo"));
807 assert!(!result[0].message.contains("draft"));
808 }
809
810 #[test]
811 fn test_ignored_definitions_case_insensitive() {
812 let config = MD053Config {
814 ignored_definitions: vec!["TODO".to_string()],
815 };
816 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
817
818 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
820 let result = rule.check(&ctx).unwrap();
821
822 assert_eq!(result.len(), 1);
824 assert!(result[0].message.contains("unused"));
825 assert!(!result[0].message.contains("todo"));
826 }
827
828 #[test]
829 fn test_default_config_section() {
830 let rule = MD053LinkImageReferenceDefinitions::default();
831 let config_section = rule.default_config_section();
832
833 assert!(config_section.is_some());
834 let (name, value) = config_section.unwrap();
835 assert_eq!(name, "MD053");
836
837 if let toml::Value::Table(table) = value {
839 assert!(table.contains_key("ignored-definitions"));
840 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
841 } else {
842 panic!("Expected TOML table");
843 }
844 }
845
846 #[test]
847 fn test_fix_with_ignored_definitions() {
848 let config = MD053Config {
850 ignored_definitions: vec!["template".to_string()],
851 };
852 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
853
854 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
856 let fixed = rule.fix(&ctx).unwrap();
857
858 assert_eq!(fixed, content);
860 }
861
862 #[test]
863 fn test_duplicate_definitions_exact_case() {
864 let rule = MD053LinkImageReferenceDefinitions::new();
865 let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
867 let result = rule.check(&ctx).unwrap();
868
869 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
872 assert_eq!(duplicate_warnings.len(), 2);
873 assert_eq!(duplicate_warnings[0].line, 2);
874 assert_eq!(duplicate_warnings[1].line, 3);
875 }
876
877 #[test]
878 fn test_duplicate_definitions_case_variants() {
879 let rule = MD053LinkImageReferenceDefinitions::new();
880 let content =
881 "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
883 let result = rule.check(&ctx).unwrap();
884
885 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
888 assert_eq!(duplicate_warnings.len(), 2);
889
890 assert_eq!(duplicate_warnings[0].line, 2);
893 assert_eq!(duplicate_warnings[1].line, 3);
894 }
895
896 #[test]
897 fn test_duplicate_and_unused() {
898 let rule = MD053LinkImageReferenceDefinitions::new();
899 let content = "[used]\n[used]: url1\n[used]: url2\n[unused]: url3";
900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
901 let result = rule.check(&ctx).unwrap();
902
903 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
905 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
906
907 assert_eq!(duplicate_warnings.len(), 1);
908 assert_eq!(unused_warnings.len(), 1);
909 assert_eq!(duplicate_warnings[0].line, 3); assert_eq!(unused_warnings[0].line, 4); }
912
913 #[test]
914 fn test_duplicate_with_usage() {
915 let rule = MD053LinkImageReferenceDefinitions::new();
916 let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
919 let result = rule.check(&ctx).unwrap();
920
921 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
923 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
924
925 assert_eq!(duplicate_warnings.len(), 1);
926 assert_eq!(unused_warnings.len(), 0);
927 assert_eq!(duplicate_warnings[0].line, 4);
928 }
929
930 #[test]
931 fn test_no_duplicate_different_ids() {
932 let rule = MD053LinkImageReferenceDefinitions::new();
933 let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
935 let result = rule.check(&ctx).unwrap();
936
937 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
939 assert_eq!(duplicate_warnings.len(), 0);
940 }
941
942 #[test]
943 fn test_comment_style_reference_double_slash() {
944 let rule = MD053LinkImageReferenceDefinitions::new();
945 let content = "[//]: # (This is a comment)\n\nSome regular text.";
947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
948 let result = rule.check(&ctx).unwrap();
949
950 assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
952 }
953
954 #[test]
955 fn test_comment_style_reference_comment_label() {
956 let rule = MD053LinkImageReferenceDefinitions::new();
957 let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
960 let result = rule.check(&ctx).unwrap();
961
962 assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
964 }
965
966 #[test]
967 fn test_comment_style_reference_todo_fixme() {
968 let rule = MD053LinkImageReferenceDefinitions::new();
969 let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
972 let result = rule.check(&ctx).unwrap();
973
974 assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
976 }
977
978 #[test]
979 fn test_comment_style_reference_fragment_only() {
980 let rule = MD053LinkImageReferenceDefinitions::new();
981 let content = "[anything]: #\n[ref]: #\n\nSome text.";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
984 let result = rule.check(&ctx).unwrap();
985
986 assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
988 }
989
990 #[test]
991 fn test_comment_vs_real_reference() {
992 let rule = MD053LinkImageReferenceDefinitions::new();
993 let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\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(), 1, "Only real unused references should be flagged");
1000 assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
1001 }
1002
1003 #[test]
1004 fn test_comment_with_fragment_section() {
1005 let rule = MD053LinkImageReferenceDefinitions::new();
1006 let content = "[//]: #section (Comment about section)\n\nSome text.";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1009 let result = rule.check(&ctx).unwrap();
1010
1011 assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1013 }
1014
1015 #[test]
1016 fn test_is_likely_comment_reference_helper() {
1017 assert!(
1019 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1020 "[//]: # should be recognized as comment"
1021 );
1022 assert!(
1023 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1024 "[comment]: #section should be recognized as comment"
1025 );
1026 assert!(
1027 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1028 "[note]: # should be recognized as comment"
1029 );
1030 assert!(
1031 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1032 "[todo]: # should be recognized as comment"
1033 );
1034 assert!(
1035 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1036 "Any label with just '#' should be recognized as comment"
1037 );
1038 assert!(
1039 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1040 "Real URL should not be recognized as comment"
1041 );
1042 assert!(
1043 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1044 "Real URL should not be recognized as comment"
1045 );
1046 }
1047}