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 find_definitions(&self, ctx: &crate::lint_context::LintContext) -> HashMap<String, Vec<(usize, usize)>> {
198 let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
199
200 for ref_def in &ctx.reference_defs {
202 let normalized_id = Self::unescape_reference(&ref_def.id); definitions
205 .entry(normalized_id)
206 .or_default()
207 .push((ref_def.line - 1, ref_def.line - 1)); }
209
210 let lines = &ctx.lines;
212 let mut i = 0;
213 while i < lines.len() {
214 let line_info = &lines[i];
215 let line = &line_info.content;
216
217 if line_info.in_code_block || line_info.in_front_matter {
219 i += 1;
220 continue;
221 }
222
223 if i > 0 && CONTINUATION_REGEX.is_match(line) {
225 let mut def_start = i - 1;
227 while def_start > 0 && !REFERENCE_DEFINITION_REGEX.is_match(&lines[def_start].content) {
228 def_start -= 1;
229 }
230
231 if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&lines[def_start].content) {
232 let ref_id = caps.get(1).unwrap().as_str().trim();
233 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
234
235 if let Some(ranges) = definitions.get_mut(&normalized_id)
237 && let Some(last_range) = ranges.last_mut()
238 && last_range.0 == def_start
239 {
240 last_range.1 = i;
241 }
242 }
243 }
244 i += 1;
245 }
246 definitions
247 }
248
249 fn find_usages(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
254 let mut usages: HashSet<String> = HashSet::new();
255
256 for link in &ctx.links {
258 if link.is_reference
259 && let Some(ref_id) = &link.reference_id
260 {
261 if !ctx.line_info(link.line).is_some_and(|info| info.in_code_block) {
263 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
264 }
265 }
266 }
267
268 for image in &ctx.images {
270 if image.is_reference
271 && let Some(ref_id) = &image.reference_id
272 {
273 if !ctx.line_info(image.line).is_some_and(|info| info.in_code_block) {
275 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
276 }
277 }
278 }
279
280 let code_spans = ctx.code_spans();
284
285 for line_info in ctx.lines.iter() {
286 if line_info.in_code_block || line_info.in_front_matter {
288 continue;
289 }
290
291 if REFERENCE_DEFINITION_REGEX.is_match(&line_info.content) {
293 continue;
294 }
295
296 for caps in SHORTCUT_REFERENCE_REGEX.captures_iter(&line_info.content).flatten() {
298 if let Some(full_match) = caps.get(0)
299 && let Some(ref_id_match) = caps.get(1)
300 {
301 let match_byte_offset = line_info.byte_offset + full_match.start();
303 let in_code_span = code_spans
304 .iter()
305 .any(|span| match_byte_offset >= span.byte_offset && match_byte_offset < span.byte_end);
306
307 if !in_code_span {
308 let ref_id = ref_id_match.as_str().trim();
309
310 if !Self::is_likely_not_reference(ref_id) {
312 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
313 usages.insert(normalized_id);
314 }
315 }
316 }
317 }
318 }
319
320 usages
326 }
327
328 fn get_unused_references(
335 &self,
336 definitions: &HashMap<String, Vec<(usize, usize)>>,
337 usages: &HashSet<String>,
338 ) -> Vec<(String, usize, usize)> {
339 let mut unused = Vec::new();
340 for (id, ranges) in definitions {
341 if !usages.contains(id) && !self.is_ignored_definition(id) {
343 if ranges.len() == 1 {
346 let (start, end) = ranges[0];
347 unused.push((id.clone(), start, end));
348 }
349 }
352 }
353 unused
354 }
355
356 fn is_ignored_definition(&self, definition_id: &str) -> bool {
358 self.config
359 .ignored_definitions
360 .iter()
361 .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
362 }
363}
364
365impl Default for MD053LinkImageReferenceDefinitions {
366 fn default() -> Self {
367 Self::new()
368 }
369}
370
371impl Rule for MD053LinkImageReferenceDefinitions {
372 fn name(&self) -> &'static str {
373 "MD053"
374 }
375
376 fn description(&self) -> &'static str {
377 "Link and image reference definitions should be needed"
378 }
379
380 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
384 let definitions = self.find_definitions(ctx);
386 let usages = self.find_usages(ctx);
387
388 let unused_refs = self.get_unused_references(&definitions, &usages);
390
391 let mut warnings = Vec::new();
392
393 let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); for (definition_id, ranges) in &definitions {
397 if self.is_ignored_definition(definition_id) {
399 continue;
400 }
401
402 if ranges.len() > 1 {
403 for (i, &(start_line, _)) in ranges.iter().enumerate() {
405 if i > 0 {
406 let line_num = start_line + 1;
408 let line_content = ctx.lines.get(start_line).map(|l| l.content.as_str()).unwrap_or("");
409 let (start_line_1idx, start_col, end_line, end_col) =
410 calculate_line_range(line_num, line_content);
411
412 warnings.push(LintWarning {
413 rule_name: Some(self.name().to_string()),
414 line: start_line_1idx,
415 column: start_col,
416 end_line,
417 end_column: end_col,
418 message: format!("Duplicate link or image reference definition: [{definition_id}]"),
419 severity: Severity::Warning,
420 fix: None,
421 });
422 }
423 }
424 }
425
426 if let Some(&(start_line, _)) = ranges.first() {
428 if let Some(line_info) = ctx.lines.get(start_line)
430 && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&line_info.content)
431 {
432 let original_id = caps.get(1).unwrap().as_str().trim();
433 let lower_id = original_id.to_lowercase();
434
435 if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
436 if first_original != original_id {
438 let line_num = start_line + 1;
439 let line_content = &line_info.content;
440 let (start_line_1idx, start_col, end_line, end_col) =
441 calculate_line_range(line_num, line_content);
442
443 warnings.push(LintWarning {
444 rule_name: Some(self.name().to_string()),
445 line: start_line_1idx,
446 column: start_col,
447 end_line,
448 end_column: end_col,
449 message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
450 original_id, first_original, first_line + 1),
451 severity: Severity::Warning,
452 fix: None,
453 });
454 }
455 } else {
456 seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
457 }
458 }
459 }
460 }
461
462 for (definition, start, _end) in unused_refs {
464 let line_num = start + 1; let line_content = ctx.lines.get(start).map(|l| l.content.as_str()).unwrap_or("");
466
467 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
469
470 warnings.push(LintWarning {
471 rule_name: Some(self.name().to_string()),
472 line: start_line,
473 column: start_col,
474 end_line,
475 end_column: end_col,
476 message: format!("Unused link/image reference: [{definition}]"),
477 severity: Severity::Warning,
478 fix: None, });
480 }
481
482 Ok(warnings)
483 }
484
485 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
487 Ok(ctx.content.to_string())
489 }
490
491 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
493 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
495 }
496
497 fn as_any(&self) -> &dyn std::any::Any {
498 self
499 }
500
501 fn default_config_section(&self) -> Option<(String, toml::Value)> {
502 let default_config = MD053Config::default();
503 let json_value = serde_json::to_value(&default_config).ok()?;
504 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
505 if let toml::Value::Table(table) = toml_value {
506 if !table.is_empty() {
507 Some((MD053Config::RULE_NAME.to_string(), toml::Value::Table(table)))
508 } else {
509 None
510 }
511 } else {
512 None
513 }
514 }
515
516 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
517 where
518 Self: Sized,
519 {
520 let rule_config = crate::rule_config_serde::load_rule_config::<MD053Config>(config);
521 Box::new(MD053LinkImageReferenceDefinitions::from_config_struct(rule_config))
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use crate::lint_context::LintContext;
529
530 #[test]
531 fn test_used_reference_link() {
532 let rule = MD053LinkImageReferenceDefinitions::new();
533 let content = "[text][ref]\n\n[ref]: https://example.com";
534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
535 let result = rule.check(&ctx).unwrap();
536
537 assert_eq!(result.len(), 0);
538 }
539
540 #[test]
541 fn test_unused_reference_definition() {
542 let rule = MD053LinkImageReferenceDefinitions::new();
543 let content = "[unused]: https://example.com";
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
545 let result = rule.check(&ctx).unwrap();
546
547 assert_eq!(result.len(), 1);
548 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
549 }
550
551 #[test]
552 fn test_used_reference_image() {
553 let rule = MD053LinkImageReferenceDefinitions::new();
554 let content = "![alt][img]\n\n[img]: image.jpg";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
556 let result = rule.check(&ctx).unwrap();
557
558 assert_eq!(result.len(), 0);
559 }
560
561 #[test]
562 fn test_case_insensitive_matching() {
563 let rule = MD053LinkImageReferenceDefinitions::new();
564 let content = "[Text][REF]\n\n[ref]: https://example.com";
565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
566 let result = rule.check(&ctx).unwrap();
567
568 assert_eq!(result.len(), 0);
569 }
570
571 #[test]
572 fn test_shortcut_reference() {
573 let rule = MD053LinkImageReferenceDefinitions::new();
574 let content = "[ref]\n\n[ref]: https://example.com";
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
576 let result = rule.check(&ctx).unwrap();
577
578 assert_eq!(result.len(), 0);
579 }
580
581 #[test]
582 fn test_collapsed_reference() {
583 let rule = MD053LinkImageReferenceDefinitions::new();
584 let content = "[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_multiple_unused_definitions() {
593 let rule = MD053LinkImageReferenceDefinitions::new();
594 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
596 let result = rule.check(&ctx).unwrap();
597
598 assert_eq!(result.len(), 3);
599
600 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
602 assert!(messages.iter().any(|m| m.contains("unused1")));
603 assert!(messages.iter().any(|m| m.contains("unused2")));
604 assert!(messages.iter().any(|m| m.contains("unused3")));
605 }
606
607 #[test]
608 fn test_mixed_used_and_unused() {
609 let rule = MD053LinkImageReferenceDefinitions::new();
610 let content = "[used]\n\n[used]: url1\n[unused]: url2";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
612 let result = rule.check(&ctx).unwrap();
613
614 assert_eq!(result.len(), 1);
615 assert!(result[0].message.contains("unused"));
616 }
617
618 #[test]
619 fn test_multiline_definition() {
620 let rule = MD053LinkImageReferenceDefinitions::new();
621 let content = "[ref]: https://example.com\n \"Title on next line\"";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
623 let result = rule.check(&ctx).unwrap();
624
625 assert_eq!(result.len(), 1); }
627
628 #[test]
629 fn test_reference_in_code_block() {
630 let rule = MD053LinkImageReferenceDefinitions::new();
631 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
633 let result = rule.check(&ctx).unwrap();
634
635 assert_eq!(result.len(), 1);
637 }
638
639 #[test]
640 fn test_reference_in_inline_code() {
641 let rule = MD053LinkImageReferenceDefinitions::new();
642 let content = "`[ref]`\n\n[ref]: https://example.com";
643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
644 let result = rule.check(&ctx).unwrap();
645
646 assert_eq!(result.len(), 1);
648 }
649
650 #[test]
651 fn test_escaped_reference() {
652 let rule = MD053LinkImageReferenceDefinitions::new();
653 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
655 let result = rule.check(&ctx).unwrap();
656
657 assert_eq!(result.len(), 0);
659 }
660
661 #[test]
662 fn test_duplicate_definitions() {
663 let rule = MD053LinkImageReferenceDefinitions::new();
664 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
666 let result = rule.check(&ctx).unwrap();
667
668 assert_eq!(result.len(), 1);
670 }
671
672 #[test]
673 fn test_fix_returns_original() {
674 let rule = MD053LinkImageReferenceDefinitions::new();
676 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
678 let fixed = rule.fix(&ctx).unwrap();
679
680 assert_eq!(fixed, content);
681 }
682
683 #[test]
684 fn test_fix_preserves_content() {
685 let rule = MD053LinkImageReferenceDefinitions::new();
687 let content = "Content\n\n[unused]: url\n\nMore content";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
689 let fixed = rule.fix(&ctx).unwrap();
690
691 assert_eq!(fixed, content);
692 }
693
694 #[test]
695 fn test_fix_does_not_remove() {
696 let rule = MD053LinkImageReferenceDefinitions::new();
698 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
700 let fixed = rule.fix(&ctx).unwrap();
701
702 assert_eq!(fixed, content);
703 }
704
705 #[test]
706 fn test_special_characters_in_reference() {
707 let rule = MD053LinkImageReferenceDefinitions::new();
708 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
710 let result = rule.check(&ctx).unwrap();
711
712 assert_eq!(result.len(), 0);
713 }
714
715 #[test]
716 fn test_find_definitions() {
717 let rule = MD053LinkImageReferenceDefinitions::new();
718 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
720 let defs = rule.find_definitions(&ctx);
721
722 assert_eq!(defs.len(), 3);
723 assert!(defs.contains_key("ref1"));
724 assert!(defs.contains_key("ref2"));
725 assert!(defs.contains_key("ref3"));
726 }
727
728 #[test]
729 fn test_find_usages() {
730 let rule = MD053LinkImageReferenceDefinitions::new();
731 let content = "[text][ref1] and [ref2] and ![img][ref3]";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
733 let usages = rule.find_usages(&ctx);
734
735 assert!(usages.contains("ref1"));
736 assert!(usages.contains("ref2"));
737 assert!(usages.contains("ref3"));
738 }
739
740 #[test]
741 fn test_ignored_definitions_config() {
742 let config = MD053Config {
744 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
745 };
746 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
747
748 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
750 let result = rule.check(&ctx).unwrap();
751
752 assert_eq!(result.len(), 1);
754 assert!(result[0].message.contains("unused"));
755 assert!(!result[0].message.contains("todo"));
756 assert!(!result[0].message.contains("draft"));
757 }
758
759 #[test]
760 fn test_ignored_definitions_case_insensitive() {
761 let config = MD053Config {
763 ignored_definitions: vec!["TODO".to_string()],
764 };
765 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
766
767 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
769 let result = rule.check(&ctx).unwrap();
770
771 assert_eq!(result.len(), 1);
773 assert!(result[0].message.contains("unused"));
774 assert!(!result[0].message.contains("todo"));
775 }
776
777 #[test]
778 fn test_default_config_section() {
779 let rule = MD053LinkImageReferenceDefinitions::default();
780 let config_section = rule.default_config_section();
781
782 assert!(config_section.is_some());
783 let (name, value) = config_section.unwrap();
784 assert_eq!(name, "MD053");
785
786 if let toml::Value::Table(table) = value {
788 assert!(table.contains_key("ignored-definitions"));
789 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
790 } else {
791 panic!("Expected TOML table");
792 }
793 }
794
795 #[test]
796 fn test_fix_with_ignored_definitions() {
797 let config = MD053Config {
799 ignored_definitions: vec!["template".to_string()],
800 };
801 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
802
803 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
805 let fixed = rule.fix(&ctx).unwrap();
806
807 assert_eq!(fixed, content);
809 }
810
811 #[test]
812 fn test_duplicate_definitions_exact_case() {
813 let rule = MD053LinkImageReferenceDefinitions::new();
814 let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
816 let result = rule.check(&ctx).unwrap();
817
818 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
821 assert_eq!(duplicate_warnings.len(), 2);
822 assert_eq!(duplicate_warnings[0].line, 2);
823 assert_eq!(duplicate_warnings[1].line, 3);
824 }
825
826 #[test]
827 fn test_duplicate_definitions_case_variants() {
828 let rule = MD053LinkImageReferenceDefinitions::new();
829 let content =
830 "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
832 let result = rule.check(&ctx).unwrap();
833
834 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
837 assert_eq!(duplicate_warnings.len(), 2);
838
839 assert_eq!(duplicate_warnings[0].line, 2);
842 assert_eq!(duplicate_warnings[1].line, 3);
843 }
844
845 #[test]
846 fn test_duplicate_and_unused() {
847 let rule = MD053LinkImageReferenceDefinitions::new();
848 let content = "[used]\n[used]: url1\n[used]: url2\n[unused]: url3";
849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
850 let result = rule.check(&ctx).unwrap();
851
852 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
854 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
855
856 assert_eq!(duplicate_warnings.len(), 1);
857 assert_eq!(unused_warnings.len(), 1);
858 assert_eq!(duplicate_warnings[0].line, 3); assert_eq!(unused_warnings[0].line, 4); }
861
862 #[test]
863 fn test_duplicate_with_usage() {
864 let rule = MD053LinkImageReferenceDefinitions::new();
865 let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
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();
872 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
873
874 assert_eq!(duplicate_warnings.len(), 1);
875 assert_eq!(unused_warnings.len(), 0);
876 assert_eq!(duplicate_warnings[0].line, 4);
877 }
878
879 #[test]
880 fn test_no_duplicate_different_ids() {
881 let rule = MD053LinkImageReferenceDefinitions::new();
882 let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: 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();
888 assert_eq!(duplicate_warnings.len(), 0);
889 }
890}