1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::document_structure::DocumentStructure;
4use crate::utils::range_utils::calculate_line_range;
5use fancy_regex::Regex as FancyRegex;
6use lazy_static::lazy_static;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10
11lazy_static! {
12 static ref SHORTCUT_REFERENCE_REGEX: FancyRegex =
22 FancyRegex::new(r"(?<!\!)\[([^\]]+)\](?!\[)").unwrap();
23
24 static ref REFERENCE_DEFINITION_REGEX: Regex =
30 Regex::new(r"^\s*\[([^\]]+)\]:\s+(.+)$").unwrap();
31
32 static ref CONTINUATION_REGEX: Regex = Regex::new(r"^\s+(.+)$").unwrap();
34
35 static ref CODE_BLOCK_START_REGEX: Regex = Regex::new(r"^(\s*)(`{3,}|~{3,})").unwrap();
37 static ref CODE_BLOCK_END_REGEX: Regex = Regex::new(r"^(\s*)(`{3,}|~{3,})\s*$").unwrap();
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42#[serde(rename_all = "kebab-case")]
43pub struct MD053Config {
44 #[serde(default = "default_ignored_definitions")]
46 pub ignored_definitions: Vec<String>,
47}
48
49impl Default for MD053Config {
50 fn default() -> Self {
51 Self {
52 ignored_definitions: default_ignored_definitions(),
53 }
54 }
55}
56
57fn default_ignored_definitions() -> Vec<String> {
58 Vec::new()
59}
60
61impl RuleConfig for MD053Config {
62 const RULE_NAME: &'static str = "MD053";
63}
64
65#[derive(Clone)]
117pub struct MD053LinkImageReferenceDefinitions {
118 config: MD053Config,
119}
120
121impl MD053LinkImageReferenceDefinitions {
122 pub fn new() -> Self {
124 Self {
125 config: MD053Config::default(),
126 }
127 }
128
129 pub fn from_config_struct(config: MD053Config) -> Self {
131 Self { config }
132 }
133
134 fn is_likely_not_reference(text: &str) -> bool {
137 if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
140 return true;
141 }
142
143 if text == "*" || text == "..." || text == "**" {
145 return true;
146 }
147
148 if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
150 return true;
151 }
152
153 if text.len() <= 2 && !text.chars().all(|c| c.is_alphanumeric()) {
156 return true;
157 }
158
159 if text.contains(':') && text.contains(' ') {
162 return true;
163 }
164
165 if text.starts_with('!') {
167 return true;
168 }
169
170 false
171 }
172
173 fn unescape_reference(reference: &str) -> String {
180 reference.replace("\\", "")
182 }
183
184 fn find_definitions(
188 &self,
189 ctx: &crate::lint_context::LintContext,
190 doc_structure: &DocumentStructure,
191 ) -> HashMap<String, Vec<(usize, usize)>> {
192 let mut definitions: HashMap<String, Vec<(usize, usize)>> = HashMap::new();
193
194 for ref_def in &ctx.reference_defs {
196 let normalized_id = Self::unescape_reference(&ref_def.id); definitions
199 .entry(normalized_id)
200 .or_default()
201 .push((ref_def.line - 1, ref_def.line - 1)); }
203
204 let lines = &ctx.lines;
206 let mut i = 0;
207 while i < lines.len() {
208 let line_info = &lines[i];
209 let line = &line_info.content;
210
211 if line_info.in_code_block || doc_structure.is_in_front_matter(i + 1) {
213 i += 1;
214 continue;
215 }
216
217 if i > 0 && CONTINUATION_REGEX.is_match(line) {
219 let mut def_start = i - 1;
221 while def_start > 0 && !REFERENCE_DEFINITION_REGEX.is_match(&lines[def_start].content) {
222 def_start -= 1;
223 }
224
225 if let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&lines[def_start].content) {
226 let ref_id = caps.get(1).unwrap().as_str().trim();
227 let normalized_id = Self::unescape_reference(ref_id).to_lowercase();
228
229 if let Some(ranges) = definitions.get_mut(&normalized_id)
231 && let Some(last_range) = ranges.last_mut()
232 && last_range.0 == def_start
233 {
234 last_range.1 = i;
235 }
236 }
237 }
238 i += 1;
239 }
240 definitions
241 }
242
243 fn find_usages(
248 &self,
249 doc_structure: &DocumentStructure,
250 ctx: &crate::lint_context::LintContext,
251 ) -> HashSet<String> {
252 let mut usages: HashSet<String> = HashSet::new();
253
254 for link in &ctx.links {
256 if link.is_reference
257 && let Some(ref_id) = &link.reference_id
258 {
259 if !doc_structure.is_in_code_block(link.line) {
261 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
262 }
263 }
264 }
265
266 for image in &ctx.images {
268 if image.is_reference
269 && let Some(ref_id) = &image.reference_id
270 {
271 if !doc_structure.is_in_code_block(image.line) {
273 usages.insert(Self::unescape_reference(ref_id).to_lowercase());
274 }
275 }
276 }
277
278 let code_spans = ctx.code_spans();
282
283 for (i, line_info) in ctx.lines.iter().enumerate() {
284 let line_num = i + 1; if line_info.in_code_block || doc_structure.is_in_front_matter(line_num) {
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 for (start, end) in ranges {
344 unused.push((id.clone(), *start, *end));
345 }
346 }
347 }
348 unused
349 }
350
351 fn is_ignored_definition(&self, definition_id: &str) -> bool {
353 self.config
354 .ignored_definitions
355 .iter()
356 .any(|ignored| ignored.eq_ignore_ascii_case(definition_id))
357 }
358}
359
360impl Default for MD053LinkImageReferenceDefinitions {
361 fn default() -> Self {
362 Self::new()
363 }
364}
365
366impl Rule for MD053LinkImageReferenceDefinitions {
367 fn name(&self) -> &'static str {
368 "MD053"
369 }
370
371 fn description(&self) -> &'static str {
372 "Link and image reference definitions should be needed"
373 }
374
375 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
379 let content = ctx.content;
380 let doc_structure = DocumentStructure::new(content);
382
383 let definitions = self.find_definitions(ctx, &doc_structure);
385 let usages = self.find_usages(&doc_structure, ctx);
386
387 let unused_refs = self.get_unused_references(&definitions, &usages);
389
390 let mut warnings = Vec::new();
391
392 let mut seen_definitions: HashMap<String, (String, usize)> = HashMap::new(); for (definition_id, ranges) in &definitions {
396 if self.is_ignored_definition(definition_id) {
398 continue;
399 }
400
401 if ranges.len() > 1 {
402 for (i, &(start_line, _)) in ranges.iter().enumerate() {
404 if i > 0 {
405 let line_num = start_line + 1;
407 let line_content = ctx.lines.get(start_line).map(|l| l.content.as_str()).unwrap_or("");
408 let (start_line_1idx, start_col, end_line, end_col) =
409 calculate_line_range(line_num, line_content);
410
411 warnings.push(LintWarning {
412 rule_name: Some(self.name()),
413 line: start_line_1idx,
414 column: start_col,
415 end_line,
416 end_column: end_col,
417 message: format!("Duplicate link or image reference definition: [{definition_id}]"),
418 severity: Severity::Warning,
419 fix: None,
420 });
421 }
422 }
423 }
424
425 if let Some(&(start_line, _)) = ranges.first() {
427 if let Some(line_info) = ctx.lines.get(start_line)
429 && let Some(caps) = REFERENCE_DEFINITION_REGEX.captures(&line_info.content)
430 {
431 let original_id = caps.get(1).unwrap().as_str().trim();
432 let lower_id = original_id.to_lowercase();
433
434 if let Some((first_original, first_line)) = seen_definitions.get(&lower_id) {
435 if first_original != original_id {
437 let line_num = start_line + 1;
438 let line_content = &line_info.content;
439 let (start_line_1idx, start_col, end_line, end_col) =
440 calculate_line_range(line_num, line_content);
441
442 warnings.push(LintWarning {
443 rule_name: Some(self.name()),
444 line: start_line_1idx,
445 column: start_col,
446 end_line,
447 end_column: end_col,
448 message: format!("Duplicate link or image reference definition: [{}] (conflicts with [{}] on line {})",
449 original_id, first_original, first_line + 1),
450 severity: Severity::Warning,
451 fix: None,
452 });
453 }
454 } else {
455 seen_definitions.insert(lower_id, (original_id.to_string(), start_line));
456 }
457 }
458 }
459 }
460
461 for (definition, start, _end) in unused_refs {
463 let line_num = start + 1; let line_content = ctx.lines.get(start).map(|l| l.content.as_str()).unwrap_or("");
465
466 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
468
469 warnings.push(LintWarning {
470 rule_name: Some(self.name()),
471 line: start_line,
472 column: start_col,
473 end_line,
474 end_column: end_col,
475 message: format!("Unused link/image reference: [{definition}]"),
476 severity: Severity::Warning,
477 fix: None, });
479 }
480
481 Ok(warnings)
482 }
483
484 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
486 Ok(ctx.content.to_string())
488 }
489
490 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
492 ctx.content.is_empty() || !ctx.content.contains("]:")
494 }
495
496 fn as_any(&self) -> &dyn std::any::Any {
497 self
498 }
499
500 fn default_config_section(&self) -> Option<(String, toml::Value)> {
501 let default_config = MD053Config::default();
502 let json_value = serde_json::to_value(&default_config).ok()?;
503 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
504 if let toml::Value::Table(table) = toml_value {
505 if !table.is_empty() {
506 Some((MD053Config::RULE_NAME.to_string(), toml::Value::Table(table)))
507 } else {
508 None
509 }
510 } else {
511 None
512 }
513 }
514
515 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
516 where
517 Self: Sized,
518 {
519 let rule_config = crate::rule_config_serde::load_rule_config::<MD053Config>(config);
520 Box::new(MD053LinkImageReferenceDefinitions::from_config_struct(rule_config))
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::lint_context::LintContext;
528
529 #[test]
530 fn test_used_reference_link() {
531 let rule = MD053LinkImageReferenceDefinitions::new();
532 let content = "[text][ref]\n\n[ref]: https://example.com";
533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
534 let result = rule.check(&ctx).unwrap();
535
536 assert_eq!(result.len(), 0);
537 }
538
539 #[test]
540 fn test_unused_reference_definition() {
541 let rule = MD053LinkImageReferenceDefinitions::new();
542 let content = "[unused]: https://example.com";
543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
544 let result = rule.check(&ctx).unwrap();
545
546 assert_eq!(result.len(), 1);
547 assert!(result[0].message.contains("Unused link/image reference: [unused]"));
548 }
549
550 #[test]
551 fn test_used_reference_image() {
552 let rule = MD053LinkImageReferenceDefinitions::new();
553 let content = "![alt][img]\n\n[img]: image.jpg";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
555 let result = rule.check(&ctx).unwrap();
556
557 assert_eq!(result.len(), 0);
558 }
559
560 #[test]
561 fn test_case_insensitive_matching() {
562 let rule = MD053LinkImageReferenceDefinitions::new();
563 let content = "[Text][REF]\n\n[ref]: https://example.com";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
565 let result = rule.check(&ctx).unwrap();
566
567 assert_eq!(result.len(), 0);
568 }
569
570 #[test]
571 fn test_shortcut_reference() {
572 let rule = MD053LinkImageReferenceDefinitions::new();
573 let content = "[ref]\n\n[ref]: https://example.com";
574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
575 let result = rule.check(&ctx).unwrap();
576
577 assert_eq!(result.len(), 0);
578 }
579
580 #[test]
581 fn test_collapsed_reference() {
582 let rule = MD053LinkImageReferenceDefinitions::new();
583 let content = "[ref][]\n\n[ref]: https://example.com";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
585 let result = rule.check(&ctx).unwrap();
586
587 assert_eq!(result.len(), 0);
588 }
589
590 #[test]
591 fn test_multiple_unused_definitions() {
592 let rule = MD053LinkImageReferenceDefinitions::new();
593 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
595 let result = rule.check(&ctx).unwrap();
596
597 assert_eq!(result.len(), 3);
598
599 let messages: Vec<String> = result.iter().map(|w| w.message.clone()).collect();
601 assert!(messages.iter().any(|m| m.contains("unused1")));
602 assert!(messages.iter().any(|m| m.contains("unused2")));
603 assert!(messages.iter().any(|m| m.contains("unused3")));
604 }
605
606 #[test]
607 fn test_mixed_used_and_unused() {
608 let rule = MD053LinkImageReferenceDefinitions::new();
609 let content = "[used]\n\n[used]: url1\n[unused]: url2";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
611 let result = rule.check(&ctx).unwrap();
612
613 assert_eq!(result.len(), 1);
614 assert!(result[0].message.contains("unused"));
615 }
616
617 #[test]
618 fn test_multiline_definition() {
619 let rule = MD053LinkImageReferenceDefinitions::new();
620 let content = "[ref]: https://example.com\n \"Title on next line\"";
621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
622 let result = rule.check(&ctx).unwrap();
623
624 assert_eq!(result.len(), 1); }
626
627 #[test]
628 fn test_reference_in_code_block() {
629 let rule = MD053LinkImageReferenceDefinitions::new();
630 let content = "```\n[ref]\n```\n\n[ref]: https://example.com";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
632 let result = rule.check(&ctx).unwrap();
633
634 assert_eq!(result.len(), 1);
636 }
637
638 #[test]
639 fn test_reference_in_inline_code() {
640 let rule = MD053LinkImageReferenceDefinitions::new();
641 let content = "`[ref]`\n\n[ref]: https://example.com";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
643 let result = rule.check(&ctx).unwrap();
644
645 assert_eq!(result.len(), 1);
647 }
648
649 #[test]
650 fn test_escaped_reference() {
651 let rule = MD053LinkImageReferenceDefinitions::new();
652 let content = "[example\\-ref]\n\n[example-ref]: https://example.com";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
654 let result = rule.check(&ctx).unwrap();
655
656 assert_eq!(result.len(), 0);
658 }
659
660 #[test]
661 fn test_duplicate_definitions() {
662 let rule = MD053LinkImageReferenceDefinitions::new();
663 let content = "[ref]: url1\n[ref]: url2\n\n[ref]";
664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
665 let result = rule.check(&ctx).unwrap();
666
667 assert_eq!(result.len(), 1);
669 }
670
671 #[test]
672 fn test_fix_returns_original() {
673 let rule = MD053LinkImageReferenceDefinitions::new();
675 let content = "[used]\n\n[used]: url1\n[unused]: url2\n\nMore content";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
677 let fixed = rule.fix(&ctx).unwrap();
678
679 assert_eq!(fixed, content);
680 }
681
682 #[test]
683 fn test_fix_preserves_content() {
684 let rule = MD053LinkImageReferenceDefinitions::new();
686 let content = "Content\n\n[unused]: url\n\nMore content";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
688 let fixed = rule.fix(&ctx).unwrap();
689
690 assert_eq!(fixed, content);
691 }
692
693 #[test]
694 fn test_fix_does_not_remove() {
695 let rule = MD053LinkImageReferenceDefinitions::new();
697 let content = "[unused1]: url1\n[unused2]: url2\n[unused3]: url3";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
699 let fixed = rule.fix(&ctx).unwrap();
700
701 assert_eq!(fixed, content);
702 }
703
704 #[test]
705 fn test_special_characters_in_reference() {
706 let rule = MD053LinkImageReferenceDefinitions::new();
707 let content = "[ref-with_special.chars]\n\n[ref-with_special.chars]: url";
708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
709 let result = rule.check(&ctx).unwrap();
710
711 assert_eq!(result.len(), 0);
712 }
713
714 #[test]
715 fn test_find_definitions() {
716 let rule = MD053LinkImageReferenceDefinitions::new();
717 let content = "[ref1]: url1\n[ref2]: url2\nSome text\n[ref3]: url3";
718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
719 let doc = DocumentStructure::new(content);
720 let defs = rule.find_definitions(&ctx, &doc);
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 doc = DocumentStructure::new(content);
734 let usages = rule.find_usages(&doc, &ctx);
735
736 assert!(usages.contains("ref1"));
737 assert!(usages.contains("ref2"));
738 assert!(usages.contains("ref3"));
739 }
740
741 #[test]
742 fn test_ignored_definitions_config() {
743 let config = MD053Config {
745 ignored_definitions: vec!["todo".to_string(), "draft".to_string()],
746 };
747 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
748
749 let content = "[todo]: https://example.com/todo\n[draft]: https://example.com/draft\n[unused]: https://example.com/unused";
750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
751 let result = rule.check(&ctx).unwrap();
752
753 assert_eq!(result.len(), 1);
755 assert!(result[0].message.contains("unused"));
756 assert!(!result[0].message.contains("todo"));
757 assert!(!result[0].message.contains("draft"));
758 }
759
760 #[test]
761 fn test_ignored_definitions_case_insensitive() {
762 let config = MD053Config {
764 ignored_definitions: vec!["TODO".to_string()],
765 };
766 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
767
768 let content = "[todo]: https://example.com/todo\n[unused]: https://example.com/unused";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
770 let result = rule.check(&ctx).unwrap();
771
772 assert_eq!(result.len(), 1);
774 assert!(result[0].message.contains("unused"));
775 assert!(!result[0].message.contains("todo"));
776 }
777
778 #[test]
779 fn test_default_config_section() {
780 let rule = MD053LinkImageReferenceDefinitions::default();
781 let config_section = rule.default_config_section();
782
783 assert!(config_section.is_some());
784 let (name, value) = config_section.unwrap();
785 assert_eq!(name, "MD053");
786
787 if let toml::Value::Table(table) = value {
789 assert!(table.contains_key("ignored-definitions"));
790 assert_eq!(table["ignored-definitions"], toml::Value::Array(vec![]));
791 } else {
792 panic!("Expected TOML table");
793 }
794 }
795
796 #[test]
797 fn test_fix_with_ignored_definitions() {
798 let config = MD053Config {
800 ignored_definitions: vec!["template".to_string()],
801 };
802 let rule = MD053LinkImageReferenceDefinitions::from_config_struct(config);
803
804 let content = "[template]: https://example.com/template\n[unused]: https://example.com/unused\n\nSome content.";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
806 let fixed = rule.fix(&ctx).unwrap();
807
808 assert_eq!(fixed, content);
810 }
811
812 #[test]
813 fn test_duplicate_definitions_exact_case() {
814 let rule = MD053LinkImageReferenceDefinitions::new();
815 let content = "[ref]: url1\n[ref]: url2\n[ref]: url3";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
817 let result = rule.check(&ctx).unwrap();
818
819 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
822 assert_eq!(duplicate_warnings.len(), 2);
823 assert_eq!(duplicate_warnings[0].line, 2);
824 assert_eq!(duplicate_warnings[1].line, 3);
825 }
826
827 #[test]
828 fn test_duplicate_definitions_case_variants() {
829 let rule = MD053LinkImageReferenceDefinitions::new();
830 let content =
831 "[method resolution order]: url1\n[Method Resolution Order]: url2\n[METHOD RESOLUTION ORDER]: url3";
832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
833 let result = rule.check(&ctx).unwrap();
834
835 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
838 assert_eq!(duplicate_warnings.len(), 2);
839
840 assert_eq!(duplicate_warnings[0].line, 2);
843 assert_eq!(duplicate_warnings[1].line, 3);
844 }
845
846 #[test]
847 fn test_duplicate_and_unused() {
848 let rule = MD053LinkImageReferenceDefinitions::new();
849 let content = "[used]\n[used]: url1\n[used]: url2\n[unused]: 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();
855 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
856
857 assert_eq!(duplicate_warnings.len(), 1);
858 assert_eq!(unused_warnings.len(), 1);
859 assert_eq!(duplicate_warnings[0].line, 3); assert_eq!(unused_warnings[0].line, 4); }
862
863 #[test]
864 fn test_duplicate_with_usage() {
865 let rule = MD053LinkImageReferenceDefinitions::new();
866 let content = "[ref]\n\n[ref]: url1\n[ref]: url2";
868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
869 let result = rule.check(&ctx).unwrap();
870
871 let duplicate_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Duplicate")).collect();
873 let unused_warnings: Vec<_> = result.iter().filter(|w| w.message.contains("Unused")).collect();
874
875 assert_eq!(duplicate_warnings.len(), 1);
876 assert_eq!(unused_warnings.len(), 0);
877 assert_eq!(duplicate_warnings[0].line, 4);
878 }
879
880 #[test]
881 fn test_no_duplicate_different_ids() {
882 let rule = MD053LinkImageReferenceDefinitions::new();
883 let content = "[ref1]: url1\n[ref2]: url2\n[ref3]: 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 assert_eq!(duplicate_warnings.len(), 0);
890 }
891}