1use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mkdocs_patterns::is_mkdocs_auto_reference;
3use crate::utils::range_utils::calculate_match_range;
4use crate::utils::regex_cache::SHORTCUT_REF_REGEX;
5use crate::utils::skip_context::is_in_math_context;
6use pulldown_cmark::LinkType;
7use regex::Regex;
8use std::collections::{HashMap, HashSet};
9use std::sync::LazyLock;
10
11mod md052_config;
12use md052_config::MD052Config;
13
14static REF_REGEX: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"^\s*\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]:\s*.*").unwrap());
19
20static LIST_ITEM_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*[-*+]\s+(?:\[[xX\s]\]\s+)?").unwrap());
22
23static OUTPUT_EXAMPLE_START: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r"^#+\s*(?:Output|Example|Output Style|Output Format)\s*$").unwrap());
26
27static GITHUB_ALERT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
30 Regex::new(r"^\s*>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|INFO|SUCCESS|FAILURE|DANGER|BUG|EXAMPLE|QUOTE)\]")
31 .unwrap()
32});
33
34static URL_WITH_BRACKETS: LazyLock<Regex> =
42 LazyLock::new(|| Regex::new(r"https?://(?:\[[0-9a-fA-F:.%]+\]|[^\s\[\]]+/[^\s]*\[\d+\])").unwrap());
43
44#[derive(Clone, Default)]
57pub struct MD052ReferenceLinkImages {
58 config: MD052Config,
59}
60
61impl MD052ReferenceLinkImages {
62 pub fn new() -> Self {
63 Self {
64 config: MD052Config::default(),
65 }
66 }
67
68 pub fn from_config_struct(config: MD052Config) -> Self {
69 Self { config }
70 }
71
72 fn strip_backticks(s: &str) -> &str {
75 s.trim_start_matches('`').trim_end_matches('`')
76 }
77
78 fn is_valid_python_identifier(s: &str) -> bool {
82 if s.is_empty() {
83 return false;
84 }
85 let first_char = s.chars().next().unwrap();
86 if !first_char.is_ascii_alphabetic() && first_char != '_' {
87 return false;
88 }
89 s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
90 }
91
92 fn is_known_non_reference_pattern(&self, text: &str) -> bool {
101 if self.config.ignore.iter().any(|p| p.eq_ignore_ascii_case(text)) {
105 return true;
106 }
107 if text.chars().all(|c| c.is_ascii_digit()) {
109 return true;
110 }
111
112 if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
114 return true;
115 }
116
117 if text.contains('.')
121 && !text.contains(' ')
122 && !text.contains('-')
123 && !text.contains('_')
124 && !text.contains('`')
125 {
126 return true;
128 }
129
130 if text == "*" || text == "..." || text == "**" {
132 return true;
133 }
134
135 if text.contains('/') && !text.contains(' ') && !text.starts_with("http") {
137 return true;
138 }
139
140 if text.contains(',') || text.contains('[') || text.contains(']') {
143 return true;
145 }
146
147 if !text.contains('`')
154 && text.contains('.')
155 && !text.contains(' ')
156 && !text.contains('-')
157 && !text.contains('_')
158 {
159 return true;
160 }
161
162 if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
169 return true;
170 }
171
172 if text.len() <= 2 && !text.chars().all(char::is_alphabetic) {
174 return true;
175 }
176
177 if (text.starts_with('"') && text.ends_with('"'))
179 || (text.starts_with('\'') && text.ends_with('\''))
180 || text.contains('"')
181 || text.contains('\'')
182 {
183 return true;
184 }
185
186 if text.contains(':') && text.contains(' ') {
189 return true;
190 }
191
192 if text.starts_with('!') {
194 return true;
195 }
196
197 if text.starts_with('^') {
200 return true;
201 }
202
203 if text.starts_with('@') {
206 return true;
207 }
208
209 if text == "TOC" {
212 return true;
213 }
214
215 if text.len() == 1 && text.chars().all(|c| c.is_ascii_uppercase()) {
217 return true;
218 }
219
220 let common_non_refs = [
223 "object",
225 "Object",
226 "any",
227 "Any",
228 "inv",
229 "void",
230 "bool",
231 "int",
232 "float",
233 "str",
234 "char",
235 "i8",
236 "i16",
237 "i32",
238 "i64",
239 "i128",
240 "isize",
241 "u8",
242 "u16",
243 "u32",
244 "u64",
245 "u128",
246 "usize",
247 "f32",
248 "f64",
249 "null",
251 "true",
252 "false",
253 "NaN",
254 "Infinity",
255 "object Object",
257 ];
258
259 if common_non_refs.contains(&text) {
260 return true;
261 }
262
263 false
264 }
265
266 fn is_in_code_span(byte_pos: usize, code_spans: &[crate::lint_context::CodeSpan]) -> bool {
268 let idx = code_spans.partition_point(|span| span.byte_offset <= byte_pos);
269 idx > 0 && byte_pos < code_spans[idx - 1].byte_end
270 }
271
272 fn is_in_html_tag(html_tags: &[crate::lint_context::HtmlTag], byte_pos: usize) -> bool {
274 let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
275 idx > 0 && byte_pos < html_tags[idx - 1].byte_end
276 }
277
278 fn extract_references(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
279 use crate::utils::skip_context::is_mkdocs_snippet_line;
280
281 let mut references = HashSet::new();
282
283 for (line_num, line) in ctx.content.lines().enumerate() {
284 if let Some(line_info) = ctx.line_info(line_num + 1)
286 && line_info.in_code_block
287 {
288 continue;
289 }
290
291 if is_mkdocs_snippet_line(line, ctx.flavor) {
293 continue;
294 }
295
296 if line.trim_start().starts_with("*[") {
299 continue;
300 }
301
302 if let Some(cap) = REF_REGEX.captures(line) {
303 if let Some(reference) = cap.get(1) {
305 references.insert(reference.as_str().to_lowercase());
306 }
307 }
308 }
309
310 for def in ctx.reference_definitions() {
314 references.insert(def.id.clone());
315 }
316
317 references
318 }
319
320 fn compute_example_sections(ctx: &crate::lint_context::LintContext) -> HashSet<usize> {
321 let mut sections = HashSet::new();
322 let mut in_section = false;
323 for (line_num, line) in ctx.raw_lines().iter().enumerate() {
324 if OUTPUT_EXAMPLE_START.is_match(line) {
325 in_section = true;
326 } else if line.starts_with('#') {
327 in_section = false;
328 }
329 if in_section {
330 sections.insert(line_num + 1);
331 }
332 }
333 sections
334 }
335
336 fn find_undefined_references(
337 &self,
338 references: &HashSet<String>,
339 ctx: &crate::lint_context::LintContext,
340 mkdocs_mode: bool,
341 ) -> Vec<(usize, usize, usize, String)> {
342 let mut undefined = Vec::new();
343 let mut reported_refs = HashMap::new();
344
345 let example_sections = Self::compute_example_sections(ctx);
346
347 let code_spans = ctx.code_spans();
349 let html_tags = ctx.html_tags();
350
351 for link in ctx.links() {
353 if !link.is_reference {
354 continue; }
356
357 if !self.config.shortcut_syntax && matches!(link.link_type, LinkType::Shortcut | LinkType::ShortcutUnknown)
359 {
360 continue;
361 }
362
363 if link.byte_offset > 0 && ctx.content.as_bytes().get(link.byte_offset - 1) == Some(&b'^') {
365 continue;
366 }
367
368 if ctx.is_in_jinja_range(link.byte_offset) {
370 continue;
371 }
372
373 if Self::is_in_code_span(link.byte_offset, &code_spans) {
375 continue;
376 }
377
378 if ctx.is_in_html_comment(link.byte_offset) || ctx.is_in_mdx_comment(link.byte_offset) {
380 continue;
381 }
382
383 if Self::is_in_html_tag(&html_tags, link.byte_offset) {
385 continue;
386 }
387
388 if is_in_math_context(ctx, link.byte_offset) {
390 continue;
391 }
392
393 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
395 continue;
396 }
397
398 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
401 continue;
402 }
403
404 if ctx.is_in_shortcode(link.byte_offset) {
407 continue;
408 }
409
410 if let Some(ref_id) = &link.reference_id {
411 let reference_lower = ref_id.to_lowercase();
412
413 if ctx.flavor.is_pandoc_compatible() && ctx.matches_implicit_header_reference(ref_id) {
415 continue;
416 }
417
418 if self.is_known_non_reference_pattern(ref_id) {
420 continue;
421 }
422
423 let stripped_ref = Self::strip_backticks(ref_id);
427 let stripped_text = Self::strip_backticks(&link.text);
428 if mkdocs_mode
429 && (is_mkdocs_auto_reference(stripped_ref)
430 || is_mkdocs_auto_reference(stripped_text)
431 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
432 || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text)))
433 {
434 continue;
435 }
436
437 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
439 if example_sections.contains(&link.line) {
440 continue;
441 }
442
443 if let Some(line_info) = ctx.line_info(link.line) {
444 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
446 continue;
447 }
448
449 let trimmed = line_info.content(ctx.content).trim_start();
451 if trimmed.starts_with('<') {
452 continue;
453 }
454 }
455
456 let match_len = link.byte_end - link.byte_offset;
457 let line_start = ctx.line_start_byte(link.line).unwrap_or(0);
460 undefined.push((
461 link.line - 1,
462 link.byte_offset - line_start,
463 match_len,
464 original_case_label(&link.text, &reference_lower),
465 ));
466 reported_refs.insert(reference_lower, true);
467 }
468 }
469 }
470
471 for image in ctx.images() {
473 if !image.is_reference {
474 continue; }
476
477 if ctx.is_in_jinja_range(image.byte_offset) {
479 continue;
480 }
481
482 if Self::is_in_code_span(image.byte_offset, &code_spans) {
484 continue;
485 }
486
487 if ctx.is_in_html_comment(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
489 continue;
490 }
491
492 if Self::is_in_html_tag(&html_tags, image.byte_offset) {
494 continue;
495 }
496
497 if is_in_math_context(ctx, image.byte_offset) {
499 continue;
500 }
501
502 if ctx.line_info(image.line).is_some_and(|info| info.in_front_matter) {
504 continue;
505 }
506
507 if let Some(ref_id) = &image.reference_id {
508 let reference_lower = ref_id.to_lowercase();
509
510 if self.is_known_non_reference_pattern(ref_id) {
512 continue;
513 }
514
515 let stripped_ref = Self::strip_backticks(ref_id);
519 let stripped_alt = Self::strip_backticks(&image.alt_text);
520 if mkdocs_mode
521 && (is_mkdocs_auto_reference(stripped_ref)
522 || is_mkdocs_auto_reference(stripped_alt)
523 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
524 || (image.alt_text.as_ref() != stripped_alt && Self::is_valid_python_identifier(stripped_alt)))
525 {
526 continue;
527 }
528
529 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
531 if example_sections.contains(&image.line) {
532 continue;
533 }
534
535 if let Some(line_info) = ctx.line_info(image.line) {
536 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
538 continue;
539 }
540
541 let trimmed = line_info.content(ctx.content).trim_start();
543 if trimmed.starts_with('<') {
544 continue;
545 }
546 }
547
548 let match_len = image.byte_end - image.byte_offset;
549 let line_start = ctx.line_start_byte(image.line).unwrap_or(0);
552 undefined.push((
553 image.line - 1,
554 image.byte_offset - line_start,
555 match_len,
556 original_case_label(&image.alt_text, &reference_lower),
557 ));
558 reported_refs.insert(reference_lower, true);
559 }
560 }
561 }
562
563 let mut covered_ranges: Vec<(usize, usize)> = Vec::new();
565
566 for link in ctx.links() {
568 covered_ranges.push((link.byte_offset, link.byte_end));
569 }
570
571 for image in ctx.images() {
573 covered_ranges.push((image.byte_offset, image.byte_end));
574 }
575
576 covered_ranges.sort_by_key(|&(start, _)| start);
578
579 if !self.config.shortcut_syntax {
584 return undefined;
585 }
586
587 let lines = ctx.raw_lines();
589 for (line_num, line) in lines.iter().enumerate() {
590 if let Some(line_info) = ctx.line_info(line_num + 1)
592 && (line_info.in_front_matter || line_info.in_code_block)
593 {
594 continue;
595 }
596
597 if example_sections.contains(&(line_num + 1)) {
598 continue;
599 }
600
601 if LIST_ITEM_REGEX.is_match(line) {
603 continue;
604 }
605
606 let trimmed_line = line.trim_start();
608 if trimmed_line.starts_with('<') {
609 continue;
610 }
611
612 if GITHUB_ALERT_REGEX.is_match(line) {
614 continue;
615 }
616
617 if trimmed_line.starts_with("*[") {
620 continue;
621 }
622
623 let mut url_bracket_ranges: Vec<(usize, usize)> = Vec::new();
626 for mat in URL_WITH_BRACKETS.find_iter(line) {
627 let url_str = mat.as_str();
629 let url_start = mat.start();
630
631 let mut idx = 0;
633 while idx < url_str.len() {
634 if let Some(bracket_start) = url_str[idx..].find('[') {
635 let bracket_start_abs = url_start + idx + bracket_start;
636 if let Some(bracket_end) = url_str[idx + bracket_start + 1..].find(']') {
637 let bracket_end_abs = url_start + idx + bracket_start + 1 + bracket_end + 1;
638 url_bracket_ranges.push((bracket_start_abs, bracket_end_abs));
639 idx += bracket_start + bracket_end + 2;
640 } else {
641 break;
642 }
643 } else {
644 break;
645 }
646 }
647 }
648
649 if let Ok(captures) = SHORTCUT_REF_REGEX.captures_iter(line).collect::<Result<Vec<_>, _>>() {
651 for cap in captures {
652 if let Some(ref_match) = cap.get(1) {
653 let bracket_start = cap.get(0).unwrap().start();
655 let bracket_end = cap.get(0).unwrap().end();
656
657 let is_in_url = url_bracket_ranges
659 .iter()
660 .any(|&(url_start, url_end)| bracket_start >= url_start && bracket_end <= url_end);
661
662 if is_in_url {
663 continue;
664 }
665
666 if bracket_start > 0 {
669 if let Some(byte) = line.as_bytes().get(bracket_start.saturating_sub(1))
671 && *byte == b'^'
672 {
673 continue; }
675 }
676
677 let reference = ref_match.as_str();
678 let reference_lower = reference.to_lowercase();
679
680 if self.is_known_non_reference_pattern(reference) {
682 continue;
683 }
684
685 if let Some(alert_type) = reference.strip_prefix('!')
687 && matches!(
688 alert_type,
689 "NOTE"
690 | "TIP"
691 | "WARNING"
692 | "IMPORTANT"
693 | "CAUTION"
694 | "INFO"
695 | "SUCCESS"
696 | "FAILURE"
697 | "DANGER"
698 | "BUG"
699 | "EXAMPLE"
700 | "QUOTE"
701 )
702 {
703 continue;
704 }
705
706 if mkdocs_mode
709 && (reference.starts_with("start:") || reference.starts_with("end:"))
710 && (crate::utils::mkdocs_snippets::is_snippet_section_start(line)
711 || crate::utils::mkdocs_snippets::is_snippet_section_end(line))
712 {
713 continue;
714 }
715
716 let stripped_ref = Self::strip_backticks(reference);
719 if mkdocs_mode
720 && (is_mkdocs_auto_reference(stripped_ref)
721 || (reference != stripped_ref && Self::is_valid_python_identifier(stripped_ref)))
722 {
723 continue;
724 }
725
726 if ctx.flavor.is_pandoc_compatible() && ctx.matches_implicit_header_reference(reference) {
730 continue;
731 }
732
733 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
734 let full_match = cap.get(0).unwrap();
735 let col = full_match.start();
736 let line_start_byte = ctx.line_offsets[line_num];
737 let byte_pos = line_start_byte + col;
738
739 let code_spans = ctx.code_spans();
741 if Self::is_in_code_span(byte_pos, &code_spans) {
742 continue;
743 }
744
745 if ctx.is_in_jinja_range(byte_pos) {
747 continue;
748 }
749
750 if crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block(
752 &ctx.code_blocks,
753 byte_pos,
754 ) {
755 continue;
756 }
757
758 if ctx.is_in_html_comment(byte_pos) || ctx.is_in_mdx_comment(byte_pos) {
760 continue;
761 }
762
763 if Self::is_in_html_tag(&html_tags, byte_pos) {
765 continue;
766 }
767
768 if is_in_math_context(ctx, byte_pos) {
770 continue;
771 }
772
773 let byte_end = byte_pos + (full_match.end() - full_match.start());
774
775 let mut is_covered = false;
777 for &(range_start, range_end) in &covered_ranges {
778 if range_start <= byte_pos && byte_end <= range_end {
779 is_covered = true;
781 break;
782 }
783 if range_start > byte_end {
784 break;
786 }
787 }
788
789 if is_covered {
790 continue;
791 }
792
793 let line_chars: Vec<char> = line.chars().collect();
798 if col > 0 && col <= line_chars.len() && line_chars.get(col - 1) == Some(&']') {
799 let mut bracket_count = 1; let mut check_pos = col.saturating_sub(2);
802 let mut found_opening = false;
803
804 while check_pos > 0 && check_pos < line_chars.len() {
805 match line_chars.get(check_pos) {
806 Some(&']') => bracket_count += 1,
807 Some(&'[') => {
808 bracket_count -= 1;
809 if bracket_count == 0 {
810 if check_pos == 0 || line_chars.get(check_pos - 1) != Some(&'\\') {
812 found_opening = true;
813 }
814 break;
815 }
816 }
817 _ => {}
818 }
819 if check_pos == 0 {
820 break;
821 }
822 check_pos = check_pos.saturating_sub(1);
823 }
824
825 if found_opening {
826 continue;
828 }
829 }
830
831 let before_text = &line[..col];
834 if before_text.contains("\\]") {
835 if let Some(escaped_close_pos) = before_text.rfind("\\]") {
837 let search_text = &before_text[..escaped_close_pos];
838 if search_text.contains("\\[") {
839 continue;
841 }
842 }
843 }
844
845 let match_len = full_match.end() - full_match.start();
846 undefined.push((line_num, col, match_len, reference.to_string()));
847 reported_refs.insert(reference_lower, true);
848 }
849 }
850 }
851 }
852 }
853
854 undefined
855 }
856}
857
858fn original_case_label(text: &str, reference_lower: &str) -> String {
864 if !text.is_empty() && text.to_lowercase() == reference_lower {
865 text.to_string()
866 } else {
867 reference_lower.to_string()
868 }
869}
870
871impl Rule for MD052ReferenceLinkImages {
872 fn name(&self) -> &'static str {
873 "MD052"
874 }
875
876 fn description(&self) -> &'static str {
877 "Reference links and images should use a reference that exists"
878 }
879
880 fn category(&self) -> RuleCategory {
881 RuleCategory::Link
882 }
883
884 fn fix_capability(&self) -> FixCapability {
885 FixCapability::Unfixable
886 }
887
888 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
889 let content = ctx.content;
890 let mut warnings = Vec::new();
891
892 if !content.contains('[') {
894 return Ok(warnings);
895 }
896
897 let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
899
900 let references = self.extract_references(ctx);
901
902 let lines = ctx.raw_lines();
904 for (line_num, col, match_len, reference) in self.find_undefined_references(&references, ctx, mkdocs_mode) {
905 let line_content = lines.get(line_num).unwrap_or(&"");
906
907 let (start_line, start_col, end_line, end_col) =
909 calculate_match_range(line_num + 1, line_content, col, match_len);
910
911 warnings.push(LintWarning {
912 rule_name: Some(self.name().to_string()),
913 line: start_line,
914 column: start_col,
915 end_line,
916 end_column: end_col,
917 message: format!("Reference '{reference}' not found"),
918 severity: Severity::Warning,
919 fix: None,
920 });
921 }
922
923 Ok(warnings)
924 }
925
926 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
928 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
930 }
931
932 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
933 let content = ctx.content;
934 Ok(content.to_string())
936 }
937
938 fn as_any(&self) -> &dyn std::any::Any {
939 self
940 }
941
942 crate::impl_rule_config_methods!(MD052Config);
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948 use crate::lint_context::LintContext;
949
950 #[test]
951 fn test_valid_reference_link() {
952 let rule = MD052ReferenceLinkImages::new();
953 let content = "[text][ref]\n\n[ref]: https://example.com";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let result = rule.check(&ctx).unwrap();
956
957 assert_eq!(result.len(), 0);
958 }
959
960 #[test]
961 fn test_undefined_reference_link() {
962 let rule = MD052ReferenceLinkImages::new();
963 let content = "[text][undefined]";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965 let result = rule.check(&ctx).unwrap();
966
967 assert_eq!(result.len(), 1);
968 assert!(result[0].message.contains("Reference 'undefined' not found"));
969 }
970
971 #[test]
972 fn test_undefined_reference_column_non_ascii_prefix() {
973 let rule = MD052ReferenceLinkImages::new();
977 let content = "你好[text][undefined]";
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
980 let result = rule.check(&ctx).unwrap();
981
982 assert_eq!(result.len(), 1);
983 assert_eq!(
984 result[0].column, 3,
985 "Column must be a character offset, not a byte offset"
986 );
987 }
988
989 #[test]
990 fn test_valid_reference_image() {
991 let rule = MD052ReferenceLinkImages::new();
992 let content = "![alt][img]\n\n[img]: image.jpg";
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994 let result = rule.check(&ctx).unwrap();
995
996 assert_eq!(result.len(), 0);
997 }
998
999 #[test]
1000 fn test_undefined_reference_image() {
1001 let rule = MD052ReferenceLinkImages::new();
1002 let content = "![alt][missing]";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let result = rule.check(&ctx).unwrap();
1005
1006 assert_eq!(result.len(), 1);
1007 assert!(result[0].message.contains("Reference 'missing' not found"));
1008 }
1009
1010 #[test]
1011 fn test_case_insensitive_references() {
1012 let rule = MD052ReferenceLinkImages::new();
1013 let content = "[Text][REF]\n\n[ref]: https://example.com";
1014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015 let result = rule.check(&ctx).unwrap();
1016
1017 assert_eq!(result.len(), 0);
1018 }
1019
1020 #[test]
1021 fn test_shortcut_reference_valid() {
1022 let rule = MD052ReferenceLinkImages::new();
1023 let content = "[ref]\n\n[ref]: https://example.com";
1024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025 let result = rule.check(&ctx).unwrap();
1026
1027 assert_eq!(result.len(), 0);
1028 }
1029
1030 #[test]
1031 fn test_shortcut_reference_undefined_with_shortcut_syntax_enabled() {
1032 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1035 shortcut_syntax: true,
1036 ..Default::default()
1037 });
1038 let content = "[undefined]";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040 let result = rule.check(&ctx).unwrap();
1041
1042 assert_eq!(result.len(), 1);
1043 assert!(result[0].message.contains("Reference 'undefined' not found"));
1044 }
1045
1046 #[test]
1047 fn test_shortcut_reference_in_table_cell_with_shortcut_syntax_enabled() {
1048 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1051 shortcut_syntax: true,
1052 ..Default::default()
1053 });
1054 let content = "| A | B |\n| --- | --- |\n| [undefined] | [defined] |\n\n[defined]: https://example.com\n";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056 let result = rule.check(&ctx).unwrap();
1057
1058 assert_eq!(result.len(), 1, "{result:?}");
1059 assert_eq!(result[0].line, 3);
1060 assert!(result[0].message.contains("Reference 'undefined' not found"));
1061 }
1062
1063 #[test]
1064 fn test_shortcut_reference_not_checked_by_default() {
1065 let rule = MD052ReferenceLinkImages::new();
1067 let content = "[undefined]";
1068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1069 let result = rule.check(&ctx).unwrap();
1070
1071 assert_eq!(result.len(), 0);
1073 }
1074
1075 #[test]
1076 fn test_inline_links_ignored() {
1077 let rule = MD052ReferenceLinkImages::new();
1078 let content = "[text](https://example.com)";
1079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1080 let result = rule.check(&ctx).unwrap();
1081
1082 assert_eq!(result.len(), 0);
1083 }
1084
1085 #[test]
1086 fn test_inline_images_ignored() {
1087 let rule = MD052ReferenceLinkImages::new();
1088 let content = "";
1089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1090 let result = rule.check(&ctx).unwrap();
1091
1092 assert_eq!(result.len(), 0);
1093 }
1094
1095 #[test]
1096 fn test_references_in_code_blocks_ignored() {
1097 let rule = MD052ReferenceLinkImages::new();
1098 let content = "```\n[undefined]\n```\n\n[ref]: https://example.com";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101
1102 assert_eq!(result.len(), 0);
1103 }
1104
1105 #[test]
1106 fn test_references_in_inline_code_ignored() {
1107 let rule = MD052ReferenceLinkImages::new();
1108 let content = "`[undefined]`";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111
1112 assert_eq!(result.len(), 0);
1114 }
1115
1116 #[test]
1117 fn test_comprehensive_inline_code_detection() {
1118 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1120 shortcut_syntax: true,
1121 ..Default::default()
1122 });
1123 let content = r#"# Test
1124
1125This `[inside]` should be ignored.
1126This [outside] should be flagged.
1127Reference links `[text][ref]` in code are ignored.
1128Regular reference [text][missing] should be flagged.
1129Images `![alt][img]` in code are ignored.
1130Regular image ![alt][badimg] should be flagged.
1131
1132Multiple `[one]` and `[two]` in code ignored, but [three] is not.
1133
1134```
1135[code block content] should be ignored
1136```
1137
1138`Multiple [refs] in [same] code span` ignored."#;
1139
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.check(&ctx).unwrap();
1142
1143 assert_eq!(result.len(), 4);
1145
1146 let messages: Vec<&str> = result.iter().map(|w| &*w.message).collect();
1147 assert!(messages.iter().any(|m| m.contains("outside")));
1148 assert!(messages.iter().any(|m| m.contains("missing")));
1149 assert!(messages.iter().any(|m| m.contains("badimg")));
1150 assert!(messages.iter().any(|m| m.contains("three")));
1151
1152 assert!(!messages.iter().any(|m| m.contains("inside")));
1154 assert!(!messages.iter().any(|m| m.contains("one")));
1155 assert!(!messages.iter().any(|m| m.contains("two")));
1156 assert!(!messages.iter().any(|m| m.contains("refs")));
1157 assert!(!messages.iter().any(|m| m.contains("same")));
1158 }
1159
1160 #[test]
1161 fn test_multiple_undefined_references() {
1162 let rule = MD052ReferenceLinkImages::new();
1163 let content = "[link1][ref1] [link2][ref2] [link3][ref3]";
1164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1165 let result = rule.check(&ctx).unwrap();
1166
1167 assert_eq!(result.len(), 3);
1168 assert!(result[0].message.contains("ref1"));
1169 assert!(result[1].message.contains("ref2"));
1170 assert!(result[2].message.contains("ref3"));
1171 }
1172
1173 #[test]
1174 fn test_mixed_valid_and_undefined() {
1175 let rule = MD052ReferenceLinkImages::new();
1176 let content = "[valid][ref] [invalid][missing]\n\n[ref]: https://example.com";
1177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1178 let result = rule.check(&ctx).unwrap();
1179
1180 assert_eq!(result.len(), 1);
1181 assert!(result[0].message.contains("missing"));
1182 }
1183
1184 #[test]
1185 fn test_empty_reference() {
1186 let rule = MD052ReferenceLinkImages::new();
1187 let content = "[text][]\n\n[ref]: https://example.com";
1188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1189 let result = rule.check(&ctx).unwrap();
1190
1191 assert_eq!(result.len(), 1);
1193 }
1194
1195 #[test]
1196 fn test_escaped_brackets_ignored() {
1197 let rule = MD052ReferenceLinkImages::new();
1198 let content = "\\[not a link\\]";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200 let result = rule.check(&ctx).unwrap();
1201
1202 assert_eq!(result.len(), 0);
1203 }
1204
1205 #[test]
1206 fn test_list_items_ignored() {
1207 let rule = MD052ReferenceLinkImages::new();
1208 let content = "- [undefined]\n* [another]\n+ [third]";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211
1212 assert_eq!(result.len(), 0);
1214 }
1215
1216 #[test]
1217 fn test_output_example_section_ignored() {
1218 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1220 shortcut_syntax: true,
1221 ..Default::default()
1222 });
1223 let content = "## Output\n\n[undefined]\n\n## Normal Section\n\n[missing]";
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226
1227 assert_eq!(result.len(), 1);
1229 assert!(result[0].message.contains("missing"));
1230 }
1231
1232 #[test]
1233 fn test_reference_definitions_in_code_blocks_ignored() {
1234 let rule = MD052ReferenceLinkImages::new();
1235 let content = "[link][ref]\n\n```\n[ref]: https://example.com\n```";
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237 let result = rule.check(&ctx).unwrap();
1238
1239 assert_eq!(result.len(), 1);
1241 assert!(result[0].message.contains("ref"));
1242 }
1243
1244 #[test]
1245 fn test_multiple_references_to_same_undefined() {
1246 let rule = MD052ReferenceLinkImages::new();
1247 let content = "[first][missing] [second][missing] [third][missing]";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let result = rule.check(&ctx).unwrap();
1250
1251 assert_eq!(result.len(), 1);
1253 assert!(result[0].message.contains("missing"));
1254 }
1255
1256 #[test]
1257 fn test_reference_with_special_characters() {
1258 let rule = MD052ReferenceLinkImages::new();
1259 let content = "[text][ref-with-hyphens]\n\n[ref-with-hyphens]: https://example.com";
1260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1261 let result = rule.check(&ctx).unwrap();
1262
1263 assert_eq!(result.len(), 0);
1264 }
1265
1266 #[test]
1267 fn test_issue_51_html_attribute_not_reference() {
1268 let rule = MD052ReferenceLinkImages::new();
1270 let content = r#"# Example
1271
1272## Test
1273
1274Want to fill out this form?
1275
1276<form method="post">
1277 <input type="email" name="fields[email]" id="drip-email" placeholder="email@domain.com">
1278</form>"#;
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 let result = rule.check(&ctx).unwrap();
1281
1282 assert_eq!(
1283 result.len(),
1284 0,
1285 "HTML attributes with square brackets should not be flagged as undefined references"
1286 );
1287 }
1288
1289 #[test]
1290 fn test_extract_references() {
1291 let rule = MD052ReferenceLinkImages::new();
1292 let content = "[ref1]: url1\n[Ref2]: url2\n[REF3]: url3";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let refs = rule.extract_references(&ctx);
1295
1296 assert_eq!(refs.len(), 3);
1297 assert!(refs.contains("ref1"));
1298 assert!(refs.contains("ref2"));
1299 assert!(refs.contains("ref3"));
1300 }
1301
1302 #[test]
1303 fn test_inline_code_not_flagged() {
1304 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1306 shortcut_syntax: true,
1307 ..Default::default()
1308 });
1309
1310 let content = r#"# Test
1312
1313Configure with `["JavaScript", "GitHub", "Node.js"]` in your settings.
1314
1315Also, `[todo]` is not a reference link.
1316
1317But this [reference] should be flagged.
1318
1319And this `[inline code]` should not be flagged.
1320"#;
1321
1322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323 let warnings = rule.check(&ctx).unwrap();
1324
1325 assert_eq!(warnings.len(), 1, "Should only flag one undefined reference");
1327 assert!(warnings[0].message.contains("'reference'"));
1328 }
1329
1330 #[test]
1331 fn test_code_block_references_ignored() {
1332 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1334 shortcut_syntax: true,
1335 ..Default::default()
1336 });
1337
1338 let content = r#"# Test
1339
1340```markdown
1341[undefined] reference in code block
1342![undefined] image in code block
1343```
1344
1345[real-undefined] reference outside
1346"#;
1347
1348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349 let warnings = rule.check(&ctx).unwrap();
1350
1351 assert_eq!(warnings.len(), 1);
1353 assert!(warnings[0].message.contains("'real-undefined'"));
1354 }
1355
1356 #[test]
1357 fn test_html_comments_ignored() {
1358 let rule = MD052ReferenceLinkImages::new();
1360
1361 let content = r#"<!--- write fake_editor.py 'import sys\nopen(*sys.argv[1:], mode="wt").write("2 3 4 4 2 3 2")' -->
1363<!--- set_env EDITOR 'python3 fake_editor.py' -->
1364
1365```bash
1366$ python3 vote.py
13673 votes for: 2
13682 votes for: 3, 4
1369```"#;
1370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1371 let result = rule.check(&ctx).unwrap();
1372 assert_eq!(result.len(), 0, "Should not flag [1:] inside HTML comments");
1373
1374 let content = r#"<!-- This is [ref1] and [ref2][ref3] -->
1376Normal [text][undefined]
1377<!-- Another [comment][with] references -->"#;
1378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1379 let result = rule.check(&ctx).unwrap();
1380 assert_eq!(
1381 result.len(),
1382 1,
1383 "Should only flag the undefined reference outside comments"
1384 );
1385 assert!(result[0].message.contains("undefined"));
1386
1387 let content = r#"<!--
1389[ref1]
1390[ref2][ref3]
1391-->
1392[actual][undefined]"#;
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394 let result = rule.check(&ctx).unwrap();
1395 assert_eq!(
1396 result.len(),
1397 1,
1398 "Should not flag references in multi-line HTML comments"
1399 );
1400 assert!(result[0].message.contains("undefined"));
1401
1402 let content = r#"<!-- Comment with [1:] pattern -->
1404Valid [link][ref]
1405<!-- More [refs][in][comments] -->
1406![image][missing]
1407
1408[ref]: https://example.com"#;
1409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410 let result = rule.check(&ctx).unwrap();
1411 assert_eq!(result.len(), 1, "Should only flag missing image reference");
1412 assert!(result[0].message.contains("missing"));
1413 }
1414
1415 #[test]
1416 fn test_frontmatter_ignored() {
1417 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1420 shortcut_syntax: true,
1421 ..Default::default()
1422 });
1423
1424 let content = r#"---
1426layout: post
1427title: "My Jekyll Post"
1428date: 2023-01-01
1429categories: blog
1430tags: ["test", "example"]
1431author: John Doe
1432---
1433
1434# My Blog Post
1435
1436This is the actual markdown content that should be linted.
1437
1438[undefined] reference should be flagged.
1439
1440## Section 1
1441
1442Some content here."#;
1443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1444 let result = rule.check(&ctx).unwrap();
1445
1446 assert_eq!(
1448 result.len(),
1449 1,
1450 "Should only flag the undefined reference outside frontmatter"
1451 );
1452 assert!(result[0].message.contains("undefined"));
1453
1454 let content = r#"+++
1456title = "My Post"
1457tags = ["example", "test"]
1458+++
1459
1460# Content
1461
1462[missing] reference should be flagged."#;
1463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1464 let result = rule.check(&ctx).unwrap();
1465 assert_eq!(
1466 result.len(),
1467 1,
1468 "Should only flag the undefined reference outside TOML frontmatter"
1469 );
1470 assert!(result[0].message.contains("missing"));
1471 }
1472
1473 #[test]
1474 fn test_mkdocs_snippet_markers_not_flagged() {
1475 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1478 shortcut_syntax: true,
1479 ..Default::default()
1480 });
1481
1482 let content = r#"# Document with MkDocs Snippets
1484
1485Some content here.
1486
1487# -8<- [start:remote-content]
1488
1489This is the remote content section.
1490
1491# -8<- [end:remote-content]
1492
1493More content here.
1494
1495<!-- --8<-- [start:another-section] -->
1496Content in another section
1497<!-- --8<-- [end:another-section] -->"#;
1498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1499 let result = rule.check(&ctx).unwrap();
1500
1501 assert_eq!(
1503 result.len(),
1504 0,
1505 "Should not flag MkDocs snippet markers as undefined references"
1506 );
1507
1508 let content = r#"# Document
1511
1512# -8<- [start:section]
1513Content with [reference] inside snippet section
1514# -8<- [end:section]
1515
1516Regular [undefined] reference outside snippet markers."#;
1517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1518 let result = rule.check(&ctx).unwrap();
1519
1520 assert_eq!(
1521 result.len(),
1522 2,
1523 "Should flag undefined references but skip snippet marker lines"
1524 );
1525 assert!(result[0].message.contains("reference"));
1527 assert!(result[1].message.contains("undefined"));
1528
1529 let content = r#"# Document
1531
1532# -8<- [start:section]
1533# -8<- [end:section]"#;
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let result = rule.check(&ctx).unwrap();
1536
1537 assert_eq!(
1538 result.len(),
1539 2,
1540 "In standard mode, snippet markers should be flagged as undefined references"
1541 );
1542 }
1543
1544 #[test]
1545 fn test_pandoc_citations_not_flagged() {
1546 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1549 shortcut_syntax: true,
1550 ..Default::default()
1551 });
1552
1553 let content = r#"# Research Paper
1554
1555We are using the **bookdown** package [@R-bookdown] in this sample book.
1556This was built on top of R Markdown and **knitr** [@xie2015].
1557
1558Multiple citations [@citation1; @citation2; @citation3] are also supported.
1559
1560Regular [undefined] reference should still be flagged.
1561"#;
1562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1563 let result = rule.check(&ctx).unwrap();
1564
1565 assert_eq!(
1567 result.len(),
1568 1,
1569 "Should only flag the undefined reference, not Pandoc citations"
1570 );
1571 assert!(result[0].message.contains("undefined"));
1572 }
1573
1574 #[test]
1575 fn test_pandoc_inline_footnotes_not_flagged() {
1576 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1579 shortcut_syntax: true,
1580 ..Default::default()
1581 });
1582
1583 let content = r#"# Math Document
1584
1585You can use math in footnotes like this^[where we mention $p = \frac{a}{b}$].
1586
1587Another footnote^[with some text and a [link](https://example.com)].
1588
1589But this [reference] without ^ should be flagged.
1590"#;
1591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592 let result = rule.check(&ctx).unwrap();
1593
1594 assert_eq!(
1596 result.len(),
1597 1,
1598 "Should only flag the regular reference, not inline footnotes"
1599 );
1600 assert!(result[0].message.contains("reference"));
1601 }
1602
1603 #[test]
1604 fn test_github_alerts_not_flagged() {
1605 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1608 shortcut_syntax: true,
1609 ..Default::default()
1610 });
1611
1612 let content = r#"# Document with GitHub Alerts
1614
1615> [!NOTE]
1616> This is a note alert.
1617
1618> [!TIP]
1619> This is a tip alert.
1620
1621> [!IMPORTANT]
1622> This is an important alert.
1623
1624> [!WARNING]
1625> This is a warning alert.
1626
1627> [!CAUTION]
1628> This is a caution alert.
1629
1630Regular content with [undefined] reference."#;
1631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1632 let result = rule.check(&ctx).unwrap();
1633
1634 assert_eq!(
1636 result.len(),
1637 1,
1638 "Should only flag the undefined reference, not GitHub alerts"
1639 );
1640 assert!(result[0].message.contains("undefined"));
1641 assert_eq!(result[0].line, 18); let content = r#"> [!TIP]
1645> Here's a useful tip about [something].
1646> Multiple lines are allowed.
1647
1648[something] is mentioned but not defined."#;
1649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1650 let result = rule.check(&ctx).unwrap();
1651
1652 assert_eq!(result.len(), 1, "Should flag undefined reference");
1656 assert!(result[0].message.contains("something"));
1657
1658 let content = r#"> [!NOTE]
1660> See [reference] for more details.
1661
1662[reference]: https://example.com"#;
1663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1664 let result = rule.check(&ctx).unwrap();
1665
1666 assert_eq!(result.len(), 0, "Should not flag GitHub alerts or defined references");
1668 }
1669
1670 #[test]
1671 fn test_ignore_config() {
1672 let config = MD052Config {
1674 shortcut_syntax: true,
1675 ignore: vec!["Vec".to_string(), "HashMap".to_string(), "Option".to_string()],
1676 };
1677 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1678
1679 let content = r#"# Document with Custom Types
1680
1681Use [Vec] for dynamic arrays.
1682Use [HashMap] for key-value storage.
1683Use [Option] for nullable values.
1684Use [Result] for error handling.
1685"#;
1686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1687 let result = rule.check(&ctx).unwrap();
1688
1689 assert_eq!(result.len(), 1, "Should only flag names not in ignore");
1691 assert!(result[0].message.contains("Result"));
1693 }
1694
1695 #[test]
1696 fn test_ignore_case_insensitive() {
1697 let config = MD052Config {
1699 shortcut_syntax: true,
1700 ignore: vec!["Vec".to_string()],
1701 };
1702 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1703
1704 let content = r#"# Case Insensitivity Test
1705
1706[Vec] should be ignored.
1707[vec] should also be ignored (different case, same match).
1708[VEC] should also be ignored (different case, same match).
1709[undefined] should be flagged (not in ignore list).
1710"#;
1711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1712 let result = rule.check(&ctx).unwrap();
1713
1714 assert_eq!(result.len(), 1, "Should only flag non-ignored reference");
1716 assert!(result[0].message.contains("undefined"));
1717 }
1718
1719 #[test]
1720 fn test_ignore_empty_by_default() {
1721 let rule = MD052ReferenceLinkImages::new();
1723
1724 let content = "[text][undefined]";
1725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726 let result = rule.check(&ctx).unwrap();
1727
1728 assert_eq!(result.len(), 1);
1730 assert!(result[0].message.contains("undefined"));
1731 }
1732
1733 #[test]
1734 fn test_ignore_with_reference_links() {
1735 let config = MD052Config {
1737 shortcut_syntax: false,
1738 ignore: vec!["CustomType".to_string()],
1739 };
1740 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1741
1742 let content = r#"# Test
1743
1744See [documentation][CustomType] for details.
1745See [other docs][MissingRef] for more.
1746"#;
1747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1748 let result = rule.check(&ctx).unwrap();
1749
1750 for (i, w) in result.iter().enumerate() {
1752 eprintln!("Warning {}: {}", i, w.message);
1753 }
1754
1755 assert_eq!(result.len(), 1, "Expected 1 warning, got {}", result.len());
1758 assert!(
1759 result[0].message.contains("missingref"),
1760 "Expected 'missingref' in message: {}",
1761 result[0].message
1762 );
1763 }
1764
1765 #[test]
1766 fn test_ignore_multiple() {
1767 let config = MD052Config {
1769 shortcut_syntax: true,
1770 ignore: vec![
1771 "i32".to_string(),
1772 "u64".to_string(),
1773 "String".to_string(),
1774 "Arc".to_string(),
1775 "Mutex".to_string(),
1776 ],
1777 };
1778 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1779
1780 let content = r#"# Types
1781
1782[i32] [u64] [String] [Arc] [Mutex] [Box]
1783"#;
1784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1785 let result = rule.check(&ctx).unwrap();
1786
1787 assert_eq!(result.len(), 1);
1791 assert!(result[0].message.contains("Box"));
1793 }
1794
1795 #[test]
1796 fn test_nested_code_fences_reference_extraction() {
1797 let rule = MD052ReferenceLinkImages::new();
1802
1803 let content = "````\n```\n[ref-inside]: https://example.com\n```\n````\n\n[Use this link][ref-inside]";
1804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1805 let result = rule.check(&ctx).unwrap();
1806
1807 assert_eq!(
1811 result.len(),
1812 1,
1813 "Reference defined inside nested code fence should not count as a definition"
1814 );
1815 assert!(result[0].message.contains("ref-inside"));
1816 }
1817
1818 #[test]
1819 fn test_pandoc_flavor_skips_citations() {
1820 let rule = MD052ReferenceLinkImages::new();
1823 let content = "See [@smith2020] for details.\n";
1824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1825 let result = rule.check(&ctx).unwrap();
1826 assert!(
1827 result.is_empty(),
1828 "MD052 should skip Pandoc citations under Pandoc flavor: {result:?}"
1829 );
1830 }
1831
1832 #[test]
1833 fn md052_pandoc_skips_implicit_header_refs_with_shortcut_syntax() {
1834 use crate::config::MarkdownFlavor;
1841 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1842 shortcut_syntax: true,
1843 ..Default::default()
1844 });
1845 let content = "# My Section\n\nSee [My Section] for details.\n";
1846
1847 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1850 let std_result = rule.check(&ctx_std).unwrap();
1851 assert_eq!(
1852 std_result.len(),
1853 1,
1854 "Standard flavor with shortcut_syntax should flag [My Section]: {std_result:?}"
1855 );
1856
1857 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1859 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1860 assert!(
1861 pandoc_result.is_empty(),
1862 "Pandoc flavor should accept [My Section] as an implicit header ref: {pandoc_result:?}"
1863 );
1864 }
1865
1866 #[test]
1867 fn test_md052_complex_undefined_reference() {
1868 let rule = MD052ReferenceLinkImages::from_config(&crate::config::Config::default());
1869 let content = "Check [link `code [with brackets]` text][undefined_ref] for details.\n";
1871 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1872 let result = rule.check(&ctx).unwrap();
1873 assert_eq!(
1874 result.len(),
1875 1,
1876 "Undefined reference in complex link must be flagged: {result:?}"
1877 );
1878 assert_eq!(result[0].message, "Reference 'undefined_ref' not found");
1879 }
1880}