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;
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) {
264 def_start -= 1;
265 }
266
267 if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&lines[def_start].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) {
329 continue;
330 }
331
332 for caps in SHORTCUT_REFERENCE_REGEX.captures_iter(&line_info.content).flatten() {
334 if let Some(full_match) = caps.get(0)
335 && let Some(ref_id_match) = caps.get(1)
336 {
337 let match_byte_offset = line_info.byte_offset + full_match.start();
339 let in_code_span = code_spans
340 .iter()
341 .any(|span| match_byte_offset >= span.byte_offset && match_byte_offset < span.byte_end);
342
343 if !in_code_span {
344 let ref_id = ref_id_match.as_str().trim();
345
346 if !Self::should_skip_pattern(ref_id) {
347 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
348 usages.insert(normalized_id);
349 }
350 }
351 }
352 }
353 }
354
355 usages
361 }
362
363 fn get_unused_references(
370 &self,
371 definitions: &HashMap<String, Vec<(usize, usize)>>,
372 usages: &HashSet<String>,
373 ) -> Vec<(String, usize, usize)> {
374 let mut unused = Vec::new();
375 for (id, ranges) in definitions {
376 if !usages.contains(id) && !self.is_ignored_definition(id) {
378 if ranges.len() == 1 {
381 let (start, end) = ranges[0];
382 unused.push((id.clone(), start, end));
383 }
384 }
387 }
388 unused
389 }
390
391 fn is_ignored_definition(&self, definition_id: &str) -> bool {
393 self.config
394 .ignored_definitions
395 .iter()
396 .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
397 }
398}
399
400impl Default for MD053LinkImageReferenceDefinitions {
401 fn default() -> Self {
402 Self::new()
403 }
404}
405
406impl Rule for MD053LinkImageReferenceDefinitions {
407 fn name(&self) -> &'static str {
408 "MD053"
409 }
410
411 fn description(&self) -> &'static str {
412 "Link and image reference definitions should be needed"
413 }
414
415 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
419 let definitions = self.find_definitions(ctx);
421 let usages = self.find_usages(ctx);
422
423 let unused_refs = self.get_unused_references(&definitions, &usages);
425
426 let mut warnings = Vec::new();
427
428 let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); for (definition_id, ranges) in &definitions {
432 if self.is_ignored_definition(definition_id) {
434 continue;
435 }
436
437 if ranges.len() > 1 {
438 for (i, &(start_line, _)) in ranges.iter().enumerate() {
440 if i > 0 {
441 let line_num = start_line + 1;
443 let line_content = ctx.lines.get(start_line).map(|l| l.content.as_str()).unwrap_or("");
444 let (start_line_1idx, start_col, end_line, end_col) =
445 calculate_line_range(line_num, line_content);
446
447 warnings.push(LintWarning {
448 rule_name: Some(self.name().to_string()),
449 line: start_line_1idx,
450 column: start_col,
451 end_line,
452 end_column: end_col,
453 message: format!("Duplicate link or image reference definition: [{definition_id}]"),
454 severity: Severity::Warning,
455 fix: None,
456 });
457 }
458 }
459 }
460
461 if let Some(&(start_line, _)) = ranges.first() {
463 if let Some(line_info) = ctx.lines.get(start_line)
465 && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&line_info.content)
466 {
467 let original_id = caps.get(1).unwrap().as_str().trim();
468 let lower_id = original_id.to_lowercase();
469
470 if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
471 if first_original != original_id {
473 let line_num = start_line + 1;
474 let line_content = &line_info.content;
475 let (start_line_1idx, start_col, end_line, end_col) =
476 calculate_line_range(line_num, line_content);
477
478 warnings.push(LintWarning {
479 rule_name: Some(self.name().to_string()),
480 line: start_line_1idx,
481 column: start_col,
482 end_line,
483 end_column: end_col,
484 message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
485 original_id, first_original, first_line + 1),
486 severity: Severity::Warning,
487 fix: None,
488 });
489 }
490 } else {
491 seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
492 }
493 }
494 }
495 }
496
497 for (definition, start, _end) in unused_refs {
499 let line_num = start + 1; let line_content = ctx.lines.get(start).map(|l| l.content.as_str()).unwrap_or("");
501
502 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
504
505 warnings.push(LintWarning {
506 rule_name: Some(self.name().to_string()),
507 line: start_line,
508 column: start_col,
509 end_line,
510 end_column: end_col,
511 message: format!("Unused link/image reference: [{definition}]"),
512 severity: Severity::Warning,
513 fix: None, });
515 }
516
517 Ok(warnings)
518 }
519
520 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
522 Ok(ctx.content.to_string())
524 }
525
526 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
528 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
530 }
531
532 fn as_any(&self) -> &dyn std::any::Any {
533 self
534 }
535
536 fn default_config_section(&self) -> Option<(String, toml::Value)> {
537 let default_config = MD053Config::default();
538 let json_value = serde_json::to_value(&default_config).ok()?;
539 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
540 if let toml::Value::Table(table) = toml_value {
541 if !table.is_empty() {
542 Some((MD053Config::RULE_NAME.to_string(), toml::Value::Table(table)))
543 } else {
544 None
545 }
546 } else {
547 None
548 }
549 }
550
551 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
552 where
553 Self: Sized,
554 {
555 let rule_config = crate::rule_config_serde::load_rule_config::<MD053Config>(config);
556 Box::new(MD053LinkImageReferenceDefinitions::from_config_struct(rule_config))
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use crate::lint_context::LintContext;
564
565 #[test]
566 fn test_used_reference_link() {
567 let rule = MD053LinkImageReferenceDefinitions::new();
568 let content = "[text][ref]\n\n[ref]: https://example.com";
569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
570 let result = rule.check(&ctx).unwrap();
571
572 assert_eq!(result.len(), 0);
573 }
574
575 #[test]
576 fn test_unused_reference_definition() {
577 let rule = MD053LinkImageReferenceDefinitions::new();
578 let content = "[unused]: https://example.com";
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
580 let result = rule.check(&ctx).unwrap();
581
582 assert_eq!(result.len(), 1);
583 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
584 }
585
586 #[test]
587 fn test_used_reference_image() {
588 let rule = MD053LinkImageReferenceDefinitions::new();
589 let content = "![alt][img]\n\n[img]: image.jpg";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
591 let result = rule.check(&ctx).unwrap();
592
593 assert_eq!(result.len(), 0);
594 }
595
596 #[test]
597 fn test_case_insensitive_matching() {
598 let rule = MD053LinkImageReferenceDefinitions::new();
599 let content = "[Text][REF]\n\n[ref]: https://example.com";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601 let result = rule.check(&ctx).unwrap();
602
603 assert_eq!(result.len(), 0);
604 }
605
606 #[test]
607 fn test_shortcut_reference() {
608 let rule = MD053LinkImageReferenceDefinitions::new();
609 let content = "[ref]\n\n[ref]: https://example.com";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
611 let result = rule.check(&ctx).unwrap();
612
613 assert_eq!(result.len(), 0);
614 }
615
616 #[test]
617 fn test_collapsed_reference() {
618 let rule = MD053LinkImageReferenceDefinitions::new();
619 let content = "[ref][]\n\n[ref]: https://example.com";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
621 let result = rule.check(&ctx).unwrap();
622
623 assert_eq!(result.len(), 0);
624 }
625
626 #[test]
627 fn test_multiple_unused_definitions() {
628 let rule = MD053LinkImageReferenceDefinitions::new();
629 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
631 let result = rule.check(&ctx).unwrap();
632
633 assert_eq!(result.len(), 3);
634
635 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
637 assert!(messages.iter().any(|m| m.contains("unused1")));
638 assert!(messages.iter().any(|m| m.contains("unused2")));
639 assert!(messages.iter().any(|m| m.contains("unused3")));
640 }
641
642 #[test]
643 fn test_mixed_used_and_unused() {
644 let rule = MD053LinkImageReferenceDefinitions::new();
645 let content = "[used]\n\n[used]: url1\n[unused]: url2";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
647 let result = rule.check(&ctx).unwrap();
648
649 assert_eq!(result.len(), 1);
650 assert!(result[0].message.contains("unused"));
651 }
652
653 #[test]
654 fn test_multiline_definition() {
655 let rule = MD053LinkImageReferenceDefinitions::new();
656 let content = "[ref]: https://example.com\n \"Title on next line\"";
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
658 let result = rule.check(&ctx).unwrap();
659
660 assert_eq!(result.len(), 1); }
662
663 #[test]
664 fn test_reference_in_code_block() {
665 let rule = MD053LinkImageReferenceDefinitions::new();
666 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
668 let result = rule.check(&ctx).unwrap();
669
670 assert_eq!(result.len(), 1);
672 }
673
674 #[test]
675 fn test_reference_in_inline_code() {
676 let rule = MD053LinkImageReferenceDefinitions::new();
677 let content = "`[ref]`\n\n[ref]: https://example.com";
678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
679 let result = rule.check(&ctx).unwrap();
680
681 assert_eq!(result.len(), 1);
683 }
684
685 #[test]
686 fn test_escaped_reference() {
687 let rule = MD053LinkImageReferenceDefinitions::new();
688 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
690 let result = rule.check(&ctx).unwrap();
691
692 assert_eq!(result.len(), 0);
694 }
695
696 #[test]
697 fn test_duplicate_definitions() {
698 let rule = MD053LinkImageReferenceDefinitions::new();
699 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
701 let result = rule.check(&ctx).unwrap();
702
703 assert_eq!(result.len(), 1);
705 }
706
707 #[test]
708 fn test_fix_returns_original() {
709 let rule = MD053LinkImageReferenceDefinitions::new();
711 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
713 let fixed = rule.fix(&ctx).unwrap();
714
715 assert_eq!(fixed, content);
716 }
717
718 #[test]
719 fn test_fix_preserves_content() {
720 let rule = MD053LinkImageReferenceDefinitions::new();
722 let content = "Content\n\n[unused]: url\n\nMore content";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
724 let fixed = rule.fix(&ctx).unwrap();
725
726 assert_eq!(fixed, content);
727 }
728
729 #[test]
730 fn test_fix_does_not_remove() {
731 let rule = MD053LinkImageReferenceDefinitions::new();
733 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
735 let fixed = rule.fix(&ctx).unwrap();
736
737 assert_eq!(fixed, content);
738 }
739
740 #[test]
741 fn test_special_characters_in_reference() {
742 let rule = MD053LinkImageReferenceDefinitions::new();
743 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
745 let result = rule.check(&ctx).unwrap();
746
747 assert_eq!(result.len(), 0);
748 }
749
750 #[test]
751 fn test_find_definitions() {
752 let rule = MD053LinkImageReferenceDefinitions::new();
753 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
755 let defs = rule.find_definitions(&ctx);
756
757 assert_eq!(defs.len(), 3);
758 assert!(defs.contains_key("ref1"));
759 assert!(defs.contains_key("ref2"));
760 assert!(defs.contains_key("ref3"));
761 }
762
763 #[test]
764 fn test_find_usages() {
765 let rule = MD053LinkImageReferenceDefinitions::new();
766 let content = "[text][ref1] and [ref2] and ![img][ref3]";
767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
768 let usages = rule.find_usages(&ctx);
769
770 assert!(usages.contains("ref1"));
771 assert!(usages.contains("ref2"));
772 assert!(usages.contains("ref3"));
773 }
774
775 #[test]
776 fn test_ignored_definitions_config() {
777 let config = MD053Config {
779 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
780 };
781 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
782
783 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
785 let result = rule.check(&ctx).unwrap();
786
787 assert_eq!(result.len(), 1);
789 assert!(result[0].message.contains("unused"));
790 assert!(!result[0].message.contains("todo"));
791 assert!(!result[0].message.contains("draft"));
792 }
793
794 #[test]
795 fn test_ignored_definitions_case_insensitive() {
796 let config = MD053Config {
798 ignored_definitions: vec!["TODO".to_string()],
799 };
800 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
801
802 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
804 let result = rule.check(&ctx).unwrap();
805
806 assert_eq!(result.len(), 1);
808 assert!(result[0].message.contains("unused"));
809 assert!(!result[0].message.contains("todo"));
810 }
811
812 #[test]
813 fn test_default_config_section() {
814 let rule = MD053LinkImageReferenceDefinitions::default();
815 let config_section = rule.default_config_section();
816
817 assert!(config_section.is_some());
818 let (name, value) = config_section.unwrap();
819 assert_eq!(name, "MD053");
820
821 if let toml::Value::Table(table) = value {
823 assert!(table.contains_key("ignored-definitions"));
824 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
825 } else {
826 panic!("Expected TOML table");
827 }
828 }
829
830 #[test]
831 fn test_fix_with_ignored_definitions() {
832 let config = MD053Config {
834 ignored_definitions: vec!["template".to_string()],
835 };
836 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
837
838 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
840 let fixed = rule.fix(&ctx).unwrap();
841
842 assert_eq!(fixed, content);
844 }
845
846 #[test]
847 fn test_duplicate_definitions_exact_case() {
848 let rule = MD053LinkImageReferenceDefinitions::new();
849 let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
851 let result = rule.check(&ctx).unwrap();
852
853 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
856 assert_eq!(duplicate_warnings.len(), 2);
857 assert_eq!(duplicate_warnings[0].line, 2);
858 assert_eq!(duplicate_warnings[1].line, 3);
859 }
860
861 #[test]
862 fn test_duplicate_definitions_case_variants() {
863 let rule = MD053LinkImageReferenceDefinitions::new();
864 let content =
865 "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: 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
874 assert_eq!(duplicate_warnings[0].line, 2);
877 assert_eq!(duplicate_warnings[1].line, 3);
878 }
879
880 #[test]
881 fn test_duplicate_and_unused() {
882 let rule = MD053LinkImageReferenceDefinitions::new();
883 let content = "[used]\n[used]: url1\n[used]: url2\n[unused]: url3";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
885 let result = rule.check(&ctx).unwrap();
886
887 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
889 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
890
891 assert_eq!(duplicate_warnings.len(), 1);
892 assert_eq!(unused_warnings.len(), 1);
893 assert_eq!(duplicate_warnings[0].line, 3); assert_eq!(unused_warnings[0].line, 4); }
896
897 #[test]
898 fn test_duplicate_with_usage() {
899 let rule = MD053LinkImageReferenceDefinitions::new();
900 let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
903 let result = rule.check(&ctx).unwrap();
904
905 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
907 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
908
909 assert_eq!(duplicate_warnings.len(), 1);
910 assert_eq!(unused_warnings.len(), 0);
911 assert_eq!(duplicate_warnings[0].line, 4);
912 }
913
914 #[test]
915 fn test_no_duplicate_different_ids() {
916 let rule = MD053LinkImageReferenceDefinitions::new();
917 let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: url3";
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 assert_eq!(duplicate_warnings.len(), 0);
924 }
925
926 #[test]
927 fn test_comment_style_reference_double_slash() {
928 let rule = MD053LinkImageReferenceDefinitions::new();
929 let content = "[//]: # (This is a comment)\n\nSome regular text.";
931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
932 let result = rule.check(&ctx).unwrap();
933
934 assert_eq!(result.len(), 0, "Comment-style reference [//]: # should not be flagged");
936 }
937
938 #[test]
939 fn test_comment_style_reference_comment_label() {
940 let rule = MD053LinkImageReferenceDefinitions::new();
941 let content = "[comment]: # (This is a semantic comment)\n\n[note]: # (This is a note)";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
944 let result = rule.check(&ctx).unwrap();
945
946 assert_eq!(result.len(), 0, "Comment-style references should not be flagged");
948 }
949
950 #[test]
951 fn test_comment_style_reference_todo_fixme() {
952 let rule = MD053LinkImageReferenceDefinitions::new();
953 let content = "[todo]: # (Add more examples)\n[fixme]: # (Fix this later)\n[hack]: # (Temporary workaround)";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
956 let result = rule.check(&ctx).unwrap();
957
958 assert_eq!(result.len(), 0, "TODO/FIXME comment patterns should not be flagged");
960 }
961
962 #[test]
963 fn test_comment_style_reference_fragment_only() {
964 let rule = MD053LinkImageReferenceDefinitions::new();
965 let content = "[anything]: #\n[ref]: #\n\nSome text.";
967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
968 let result = rule.check(&ctx).unwrap();
969
970 assert_eq!(result.len(), 0, "References with just '#' URL should not be flagged");
972 }
973
974 #[test]
975 fn test_comment_vs_real_reference() {
976 let rule = MD053LinkImageReferenceDefinitions::new();
977 let content = "[//]: # (This is a comment)\n[real-ref]: https://example.com\n\nSome text.";
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
980 let result = rule.check(&ctx).unwrap();
981
982 assert_eq!(result.len(), 1, "Only real unused references should be flagged");
984 assert!(result[0].message.contains("real-ref"), "Should flag the real reference");
985 }
986
987 #[test]
988 fn test_comment_with_fragment_section() {
989 let rule = MD053LinkImageReferenceDefinitions::new();
990 let content = "[//]: #section (Comment about section)\n\nSome text.";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
993 let result = rule.check(&ctx).unwrap();
994
995 assert_eq!(result.len(), 0, "Comment with fragment section should not be flagged");
997 }
998
999 #[test]
1000 fn test_is_likely_comment_reference_helper() {
1001 assert!(
1003 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("//", "#"),
1004 "[//]: # should be recognized as comment"
1005 );
1006 assert!(
1007 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("comment", "#section"),
1008 "[comment]: #section should be recognized as comment"
1009 );
1010 assert!(
1011 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("note", "#"),
1012 "[note]: # should be recognized as comment"
1013 );
1014 assert!(
1015 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("todo", "#"),
1016 "[todo]: # should be recognized as comment"
1017 );
1018 assert!(
1019 MD053LinkImageReferenceDefinitions::is_likely_comment_reference("anything", "#"),
1020 "Any label with just '#' should be recognized as comment"
1021 );
1022 assert!(
1023 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("ref", "https://example.com"),
1024 "Real URL should not be recognized as comment"
1025 );
1026 assert!(
1027 !MD053LinkImageReferenceDefinitions::is_likely_comment_reference("link", "http://test.com"),
1028 "Real URL should not be recognized as comment"
1029 );
1030 }
1031}