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 is_likely_not_reference(text: &str) -> bool {
136 if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
139 return true;
140 }
141
142 if text == "*" || text == "..." || text == "**" {
144 return true;
145 }
146
147 if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
149 return true;
150 }
151
152 if text.len() <= 2 && !text.chars().all(|c| c.is_alphanumeric()) {
155 return true;
156 }
157
158 if 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::is_likely_not_reference(ref_id) {
364 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
365 usages.insert(normalized_id);
366 }
367 }
368 }
369 }
370 }
371
372 usages
378 }
379
380 fn get_unused_references(
387 &self,
388 definitions: &HashMap<String, Vec<(usize, usize)>>,
389 usages: &HashSet<String>,
390 ) -> Vec<(String, usize, usize)> {
391 let mut unused = Vec::new();
392 for (id, ranges) in definitions {
393 if !usages.contains(id) && !self.is_ignored_definition(id) {
395 if ranges.len() == 1 {
398 let (start, end) = ranges[0];
399 unused.push((id.clone(), start, end));
400 }
401 }
404 }
405 unused
406 }
407
408 fn is_ignored_definition(&self, definition_id: &str) -> bool {
410 self.config
411 .ignored_definitions
412 .iter()
413 .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
414 }
415}
416
417impl Default for MD053LinkImageReferenceDefinitions {
418 fn default() -> Self {
419 Self::new()
420 }
421}
422
423impl Rule for MD053LinkImageReferenceDefinitions {
424 fn name(&self) -> &'static str {
425 "MD053"
426 }
427
428 fn description(&self) -> &'static str {
429 "Link and image reference definitions should be needed"
430 }
431
432 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
436 let definitions = self.find_definitions(ctx);
438 let usages = self.find_usages(ctx);
439
440 let unused_refs = self.get_unused_references(&definitions, &usages);
442
443 let mut warnings = Vec::new();
444
445 let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); for (definition_id, ranges) in &definitions {
449 if self.is_ignored_definition(definition_id) {
451 continue;
452 }
453
454 if ranges.len() > 1 {
455 for (i, &(start_line, _)) in ranges.iter().enumerate() {
457 if i > 0 {
458 let line_num = start_line + 1;
460 let line_content = ctx.lines.get(start_line).map(|l| l.content.as_str()).unwrap_or("");
461 let (start_line_1idx, start_col, end_line, end_col) =
462 calculate_line_range(line_num, line_content);
463
464 warnings.push(LintWarning {
465 rule_name: Some(self.name().to_string()),
466 line: start_line_1idx,
467 column: start_col,
468 end_line,
469 end_column: end_col,
470 message: format!("Duplicate link or image reference definition: [{definition_id}]"),
471 severity: Severity::Warning,
472 fix: None,
473 });
474 }
475 }
476 }
477
478 if let Some(&(start_line, _)) = ranges.first() {
480 if let Some(line_info) = ctx.lines.get(start_line)
482 && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&line_info.content)
483 {
484 let original_id = caps.get(1).unwrap().as_str().trim();
485 let lower_id = original_id.to_lowercase();
486
487 if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
488 if first_original != original_id {
490 let line_num = start_line + 1;
491 let line_content = &line_info.content;
492 let (start_line_1idx, start_col, end_line, end_col) =
493 calculate_line_range(line_num, line_content);
494
495 warnings.push(LintWarning {
496 rule_name: Some(self.name().to_string()),
497 line: start_line_1idx,
498 column: start_col,
499 end_line,
500 end_column: end_col,
501 message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
502 original_id, first_original, first_line + 1),
503 severity: Severity::Warning,
504 fix: None,
505 });
506 }
507 } else {
508 seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
509 }
510 }
511 }
512 }
513
514 for (definition, start, _end) in unused_refs {
516 let line_num = start + 1; let line_content = ctx.lines.get(start).map(|l| l.content.as_str()).unwrap_or("");
518
519 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
521
522 warnings.push(LintWarning {
523 rule_name: Some(self.name().to_string()),
524 line: start_line,
525 column: start_col,
526 end_line,
527 end_column: end_col,
528 message: format!("Unused link/image reference: [{definition}]"),
529 severity: Severity::Warning,
530 fix: None, });
532 }
533
534 Ok(warnings)
535 }
536
537 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
539 Ok(ctx.content.to_string())
541 }
542
543 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
545 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
547 }
548
549 fn as_any(&self) -> &dyn std::any::Any {
550 self
551 }
552
553 fn default_config_section(&self) -> Option<(String, toml::Value)> {
554 let default_config = MD053Config::default();
555 let json_value = serde_json::to_value(&default_config).ok()?;
556 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
557 if let toml::Value::Table(table) = toml_value {
558 if !table.is_empty() {
559 Some((MD053Config::RULE_NAME.to_string(), toml::Value::Table(table)))
560 } else {
561 None
562 }
563 } else {
564 None
565 }
566 }
567
568 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
569 where
570 Self: Sized,
571 {
572 let rule_config = crate::rule_config_serde::load_rule_config::<MD053Config>(config);
573 Box::new(MD053LinkImageReferenceDefinitions::from_config_struct(rule_config))
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use crate::lint_context::LintContext;
581
582 #[test]
583 fn test_used_reference_link() {
584 let rule = MD053LinkImageReferenceDefinitions::new();
585 let content = "[text][ref]\n\n[ref]: https://example.com";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
587 let result = rule.check(&ctx).unwrap();
588
589 assert_eq!(result.len(), 0);
590 }
591
592 #[test]
593 fn test_unused_reference_definition() {
594 let rule = MD053LinkImageReferenceDefinitions::new();
595 let content = "[unused]: https://example.com";
596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
597 let result = rule.check(&ctx).unwrap();
598
599 assert_eq!(result.len(), 1);
600 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
601 }
602
603 #[test]
604 fn test_used_reference_image() {
605 let rule = MD053LinkImageReferenceDefinitions::new();
606 let content = "![alt][img]\n\n[img]: image.jpg";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
608 let result = rule.check(&ctx).unwrap();
609
610 assert_eq!(result.len(), 0);
611 }
612
613 #[test]
614 fn test_case_insensitive_matching() {
615 let rule = MD053LinkImageReferenceDefinitions::new();
616 let content = "[Text][REF]\n\n[ref]: https://example.com";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
618 let result = rule.check(&ctx).unwrap();
619
620 assert_eq!(result.len(), 0);
621 }
622
623 #[test]
624 fn test_shortcut_reference() {
625 let rule = MD053LinkImageReferenceDefinitions::new();
626 let content = "[ref]\n\n[ref]: https://example.com";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
628 let result = rule.check(&ctx).unwrap();
629
630 assert_eq!(result.len(), 0);
631 }
632
633 #[test]
634 fn test_collapsed_reference() {
635 let rule = MD053LinkImageReferenceDefinitions::new();
636 let content = "[ref][]\n\n[ref]: https://example.com";
637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
638 let result = rule.check(&ctx).unwrap();
639
640 assert_eq!(result.len(), 0);
641 }
642
643 #[test]
644 fn test_multiple_unused_definitions() {
645 let rule = MD053LinkImageReferenceDefinitions::new();
646 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
648 let result = rule.check(&ctx).unwrap();
649
650 assert_eq!(result.len(), 3);
651
652 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
654 assert!(messages.iter().any(|m| m.contains("unused1")));
655 assert!(messages.iter().any(|m| m.contains("unused2")));
656 assert!(messages.iter().any(|m| m.contains("unused3")));
657 }
658
659 #[test]
660 fn test_mixed_used_and_unused() {
661 let rule = MD053LinkImageReferenceDefinitions::new();
662 let content = "[used]\n\n[used]: url1\n[unused]: url2";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
664 let result = rule.check(&ctx).unwrap();
665
666 assert_eq!(result.len(), 1);
667 assert!(result[0].message.contains("unused"));
668 }
669
670 #[test]
671 fn test_multiline_definition() {
672 let rule = MD053LinkImageReferenceDefinitions::new();
673 let content = "[ref]: https://example.com\n \"Title on next line\"";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
675 let result = rule.check(&ctx).unwrap();
676
677 assert_eq!(result.len(), 1); }
679
680 #[test]
681 fn test_reference_in_code_block() {
682 let rule = MD053LinkImageReferenceDefinitions::new();
683 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
685 let result = rule.check(&ctx).unwrap();
686
687 assert_eq!(result.len(), 1);
689 }
690
691 #[test]
692 fn test_reference_in_inline_code() {
693 let rule = MD053LinkImageReferenceDefinitions::new();
694 let content = "`[ref]`\n\n[ref]: https://example.com";
695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
696 let result = rule.check(&ctx).unwrap();
697
698 assert_eq!(result.len(), 1);
700 }
701
702 #[test]
703 fn test_escaped_reference() {
704 let rule = MD053LinkImageReferenceDefinitions::new();
705 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
707 let result = rule.check(&ctx).unwrap();
708
709 assert_eq!(result.len(), 0);
711 }
712
713 #[test]
714 fn test_duplicate_definitions() {
715 let rule = MD053LinkImageReferenceDefinitions::new();
716 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
718 let result = rule.check(&ctx).unwrap();
719
720 assert_eq!(result.len(), 1);
722 }
723
724 #[test]
725 fn test_fix_returns_original() {
726 let rule = MD053LinkImageReferenceDefinitions::new();
728 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
730 let fixed = rule.fix(&ctx).unwrap();
731
732 assert_eq!(fixed, content);
733 }
734
735 #[test]
736 fn test_fix_preserves_content() {
737 let rule = MD053LinkImageReferenceDefinitions::new();
739 let content = "Content\n\n[unused]: url\n\nMore content";
740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
741 let fixed = rule.fix(&ctx).unwrap();
742
743 assert_eq!(fixed, content);
744 }
745
746 #[test]
747 fn test_fix_does_not_remove() {
748 let rule = MD053LinkImageReferenceDefinitions::new();
750 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
752 let fixed = rule.fix(&ctx).unwrap();
753
754 assert_eq!(fixed, content);
755 }
756
757 #[test]
758 fn test_special_characters_in_reference() {
759 let rule = MD053LinkImageReferenceDefinitions::new();
760 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
762 let result = rule.check(&ctx).unwrap();
763
764 assert_eq!(result.len(), 0);
765 }
766
767 #[test]
768 fn test_find_definitions() {
769 let rule = MD053LinkImageReferenceDefinitions::new();
770 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
772 let defs = rule.find_definitions(&ctx);
773
774 assert_eq!(defs.len(), 3);
775 assert!(defs.contains_key("ref1"));
776 assert!(defs.contains_key("ref2"));
777 assert!(defs.contains_key("ref3"));
778 }
779
780 #[test]
781 fn test_find_usages() {
782 let rule = MD053LinkImageReferenceDefinitions::new();
783 let content = "[text][ref1] and [ref2] and ![img][ref3]";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
785 let usages = rule.find_usages(&ctx);
786
787 assert!(usages.contains("ref1"));
788 assert!(usages.contains("ref2"));
789 assert!(usages.contains("ref3"));
790 }
791
792 #[test]
793 fn test_ignored_definitions_config() {
794 let config = MD053Config {
796 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
797 };
798 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
799
800 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
802 let result = rule.check(&ctx).unwrap();
803
804 assert_eq!(result.len(), 1);
806 assert!(result[0].message.contains("unused"));
807 assert!(!result[0].message.contains("todo"));
808 assert!(!result[0].message.contains("draft"));
809 }
810
811 #[test]
812 fn test_ignored_definitions_case_insensitive() {
813 let config = MD053Config {
815 ignored_definitions: vec!["TODO".to_string()],
816 };
817 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
818
819 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
821 let result = rule.check(&ctx).unwrap();
822
823 assert_eq!(result.len(), 1);
825 assert!(result[0].message.contains("unused"));
826 assert!(!result[0].message.contains("todo"));
827 }
828
829 #[test]
830 fn test_default_config_section() {
831 let rule = MD053LinkImageReferenceDefinitions::default();
832 let config_section = rule.default_config_section();
833
834 assert!(config_section.is_some());
835 let (name, value) = config_section.unwrap();
836 assert_eq!(name, "MD053");
837
838 if let toml::Value::Table(table) = value {
840 assert!(table.contains_key("ignored-definitions"));
841 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
842 } else {
843 panic!("Expected TOML table");
844 }
845 }
846
847 #[test]
848 fn test_fix_with_ignored_definitions() {
849 let config = MD053Config {
851 ignored_definitions: vec!["template".to_string()],
852 };
853 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
854
855 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
857 let fixed = rule.fix(&ctx).unwrap();
858
859 assert_eq!(fixed, content);
861 }
862
863 #[test]
864 fn test_duplicate_definitions_exact_case() {
865 let rule = MD053LinkImageReferenceDefinitions::new();
866 let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
868 let result = rule.check(&ctx).unwrap();
869
870 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
873 assert_eq!(duplicate_warnings.len(), 2);
874 assert_eq!(duplicate_warnings[0].line, 2);
875 assert_eq!(duplicate_warnings[1].line, 3);
876 }
877
878 #[test]
879 fn test_duplicate_definitions_case_variants() {
880 let rule = MD053LinkImageReferenceDefinitions::new();
881 let content =
882 "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
884 let result = rule.check(&ctx).unwrap();
885
886 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
889 assert_eq!(duplicate_warnings.len(), 2);
890
891 assert_eq!(duplicate_warnings[0].line, 2);
894 assert_eq!(duplicate_warnings[1].line, 3);
895 }
896
897 #[test]
898 fn test_duplicate_and_unused() {
899 let rule = MD053LinkImageReferenceDefinitions::new();
900 let content = "[used]\n[used]: url1\n[used]: url2\n[unused]: url3";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
902 let result = rule.check(&ctx).unwrap();
903
904 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
906 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
907
908 assert_eq!(duplicate_warnings.len(), 1);
909 assert_eq!(unused_warnings.len(), 1);
910 assert_eq!(duplicate_warnings[0].line, 3); assert_eq!(unused_warnings[0].line, 4); }
913
914 #[test]
915 fn test_duplicate_with_usage() {
916 let rule = MD053LinkImageReferenceDefinitions::new();
917 let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
920 let result = rule.check(&ctx).unwrap();
921
922 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
924 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
925
926 assert_eq!(duplicate_warnings.len(), 1);
927 assert_eq!(unused_warnings.len(), 0);
928 assert_eq!(duplicate_warnings[0].line, 4);
929 }
930
931 #[test]
932 fn test_no_duplicate_different_ids() {
933 let rule = MD053LinkImageReferenceDefinitions::new();
934 let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
936 let result = rule.check(&ctx).unwrap();
937
938 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
940 assert_eq!(duplicate_warnings.len(), 0);
941 }
942
943 #[test]
944 fn test_comment_style_reference_double_slash() {
945 let rule = MD053LinkImageReferenceDefinitions::new();
946 let content = "[//]: # (This is a comment)\n\nSome regular text.";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
949 let result = rule.check(&ctx).unwrap();
950
951 assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
953 }
954
955 #[test]
956 fn test_comment_style_reference_comment_label() {
957 let rule = MD053LinkImageReferenceDefinitions::new();
958 let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
961 let result = rule.check(&ctx).unwrap();
962
963 assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
965 }
966
967 #[test]
968 fn test_comment_style_reference_todo_fixme() {
969 let rule = MD053LinkImageReferenceDefinitions::new();
970 let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
973 let result = rule.check(&ctx).unwrap();
974
975 assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
977 }
978
979 #[test]
980 fn test_comment_style_reference_fragment_only() {
981 let rule = MD053LinkImageReferenceDefinitions::new();
982 let content = "[anything]: #\n[ref]: #\n\nSome text.";
984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
985 let result = rule.check(&ctx).unwrap();
986
987 assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
989 }
990
991 #[test]
992 fn test_comment_vs_real_reference() {
993 let rule = MD053LinkImageReferenceDefinitions::new();
994 let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
997 let result = rule.check(&ctx).unwrap();
998
999 assert_eq!(result.len(), 1, "Only real unused references should be flagged");
1001 assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
1002 }
1003
1004 #[test]
1005 fn test_comment_with_fragment_section() {
1006 let rule = MD053LinkImageReferenceDefinitions::new();
1007 let content = "[//]: #section (Comment about section)\n\nSome text.";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1010 let result = rule.check(&ctx).unwrap();
1011
1012 assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
1014 }
1015
1016 #[test]
1017 fn test_is_likely_comment_reference_helper() {
1018 assert!(
1020 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1021 "[//]: # should be recognized as comment"
1022 );
1023 assert!(
1024 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1025 "[comment]: #section should be recognized as comment"
1026 );
1027 assert!(
1028 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1029 "[note]: # should be recognized as comment"
1030 );
1031 assert!(
1032 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1033 "[todo]: # should be recognized as comment"
1034 );
1035 assert!(
1036 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1037 "Any label with just '#' should be recognized as comment"
1038 );
1039 assert!(
1040 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1041 "Real URL should not be recognized as comment"
1042 );
1043 assert!(
1044 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1045 "Real URL should not be recognized as comment"
1046 );
1047 }
1048}