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_html_tag(html_tags: &[crate::lint_context::HtmlTag], byte_pos: usize) -> bool {
268 let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
269 idx > 0 && byte_pos < html_tags[idx - 1].byte_end
270 }
271
272 fn extract_references(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
273 use crate::utils::skip_context::is_mkdocs_snippet_line;
274
275 let mut references = HashSet::new();
276
277 for (line_num, line) in ctx.content.lines().enumerate() {
278 if let Some(line_info) = ctx.line_info(line_num + 1)
280 && line_info.in_code_block
281 {
282 continue;
283 }
284
285 if is_mkdocs_snippet_line(line, ctx.flavor) {
287 continue;
288 }
289
290 if line.trim_start().starts_with("*[") {
293 continue;
294 }
295
296 if let Some(cap) = REF_REGEX.captures(line) {
297 if let Some(reference) = cap.get(1) {
299 references.insert(reference.as_str().to_lowercase());
300 }
301 }
302 }
303
304 for def in ctx.reference_definitions() {
308 references.insert(def.id.clone());
309 }
310
311 references
312 }
313
314 fn compute_example_sections(ctx: &crate::lint_context::LintContext) -> HashSet<usize> {
315 let mut sections = HashSet::new();
316 let mut in_section = false;
317 for (line_num, line) in ctx.raw_lines().iter().enumerate() {
318 if OUTPUT_EXAMPLE_START.is_match(line) {
319 in_section = true;
320 } else if line.starts_with('#') {
321 in_section = false;
322 }
323 if in_section {
324 sections.insert(line_num + 1);
325 }
326 }
327 sections
328 }
329
330 fn find_undefined_references(
331 &self,
332 references: &HashSet<String>,
333 ctx: &crate::lint_context::LintContext,
334 mkdocs_mode: bool,
335 ) -> Vec<(usize, usize, usize, String)> {
336 let mut undefined = Vec::new();
337 let mut reported_refs = HashMap::new();
338
339 let example_sections = Self::compute_example_sections(ctx);
340
341 let html_tags = ctx.html_tags();
343
344 for link in ctx.links() {
346 if !link.is_reference {
347 continue; }
349
350 if !self.config.shortcut_syntax && matches!(link.link_type, LinkType::Shortcut | LinkType::ShortcutUnknown)
352 {
353 continue;
354 }
355
356 if link.byte_offset > 0 && ctx.content.as_bytes().get(link.byte_offset - 1) == Some(&b'^') {
358 continue;
359 }
360
361 if ctx.is_in_jinja_range(link.byte_offset) {
363 continue;
364 }
365
366 if ctx.is_byte_offset_in_code_span(link.byte_offset) {
368 continue;
369 }
370
371 if ctx.is_in_html_comment(link.byte_offset) || ctx.is_in_mdx_comment(link.byte_offset) {
373 continue;
374 }
375
376 if Self::is_in_html_tag(&html_tags, link.byte_offset) {
378 continue;
379 }
380
381 if is_in_math_context(ctx, link.byte_offset) {
383 continue;
384 }
385
386 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
388 continue;
389 }
390
391 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
394 continue;
395 }
396
397 if ctx.is_in_shortcode(link.byte_offset) {
400 continue;
401 }
402
403 if let Some(ref_id) = &link.reference_id {
404 let reference_lower = ref_id.to_lowercase();
405
406 if ctx.flavor.is_pandoc_compatible() && ctx.matches_implicit_header_reference(ref_id) {
408 continue;
409 }
410
411 if self.is_known_non_reference_pattern(ref_id) {
413 continue;
414 }
415
416 let stripped_ref = Self::strip_backticks(ref_id);
420 let stripped_text = Self::strip_backticks(&link.text);
421 if mkdocs_mode
422 && (is_mkdocs_auto_reference(stripped_ref)
423 || is_mkdocs_auto_reference(stripped_text)
424 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
425 || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text)))
426 {
427 continue;
428 }
429
430 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
432 if example_sections.contains(&link.line) {
433 continue;
434 }
435
436 if let Some(line_info) = ctx.line_info(link.line) {
437 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
439 continue;
440 }
441
442 let trimmed = line_info.content(ctx.content).trim_start();
444 if trimmed.starts_with('<') {
445 continue;
446 }
447 }
448
449 let match_len = link.byte_end - link.byte_offset;
450 let line_start = ctx.line_start_byte(link.line).unwrap_or(0);
453 undefined.push((
454 link.line - 1,
455 link.byte_offset - line_start,
456 match_len,
457 original_case_label(&link.text, &reference_lower),
458 ));
459 reported_refs.insert(reference_lower, true);
460 }
461 }
462 }
463
464 for image in ctx.images() {
466 if !image.is_reference {
467 continue; }
469
470 if !self.config.shortcut_syntax && matches!(image.link_type, LinkType::Shortcut | LinkType::ShortcutUnknown)
472 {
473 continue;
474 }
475
476 if ctx.is_in_jinja_range(image.byte_offset) {
478 continue;
479 }
480
481 if ctx.is_byte_offset_in_code_span(image.byte_offset) {
483 continue;
484 }
485
486 if ctx.is_in_html_comment(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
488 continue;
489 }
490
491 if Self::is_in_html_tag(&html_tags, image.byte_offset) {
493 continue;
494 }
495
496 if is_in_math_context(ctx, image.byte_offset) {
498 continue;
499 }
500
501 if ctx.line_info(image.line).is_some_and(|info| info.in_front_matter) {
503 continue;
504 }
505
506 if let Some(ref_id) = &image.reference_id {
507 let reference_lower = ref_id.to_lowercase();
508
509 if self.is_known_non_reference_pattern(ref_id) {
511 continue;
512 }
513
514 let stripped_ref = Self::strip_backticks(ref_id);
518 let stripped_alt = Self::strip_backticks(&image.alt_text);
519 if mkdocs_mode
520 && (is_mkdocs_auto_reference(stripped_ref)
521 || is_mkdocs_auto_reference(stripped_alt)
522 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
523 || (image.alt_text.as_ref() != stripped_alt && Self::is_valid_python_identifier(stripped_alt)))
524 {
525 continue;
526 }
527
528 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
530 if example_sections.contains(&image.line) {
531 continue;
532 }
533
534 if let Some(line_info) = ctx.line_info(image.line) {
535 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
537 continue;
538 }
539
540 let trimmed = line_info.content(ctx.content).trim_start();
542 if trimmed.starts_with('<') {
543 continue;
544 }
545 }
546
547 let match_len = image.byte_end - image.byte_offset;
548 let line_start = ctx.line_start_byte(image.line).unwrap_or(0);
551 undefined.push((
552 image.line - 1,
553 image.byte_offset - line_start,
554 match_len,
555 original_case_label(&image.alt_text, &reference_lower),
556 ));
557 reported_refs.insert(reference_lower, true);
558 }
559 }
560 }
561
562 let mut covered_ranges: Vec<(usize, usize)> = Vec::new();
564
565 for link in ctx.links() {
567 covered_ranges.push((link.byte_offset, link.byte_end));
568 }
569
570 for image in ctx.images() {
572 covered_ranges.push((image.byte_offset, image.byte_end));
573 }
574
575 covered_ranges.sort_by_key(|&(start, _)| start);
577
578 if !self.config.shortcut_syntax {
583 undefined.sort_by_key(|&(line, col, _, _)| (line, col));
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 if ctx.is_byte_offset_in_code_span(byte_pos) {
741 continue;
742 }
743
744 if ctx.is_in_jinja_range(byte_pos) {
746 continue;
747 }
748
749 if crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block(
751 &ctx.code_blocks,
752 byte_pos,
753 ) {
754 continue;
755 }
756
757 if ctx.is_in_html_comment(byte_pos) || ctx.is_in_mdx_comment(byte_pos) {
759 continue;
760 }
761
762 if Self::is_in_html_tag(&html_tags, byte_pos) {
764 continue;
765 }
766
767 if is_in_math_context(ctx, byte_pos) {
769 continue;
770 }
771
772 let byte_end = byte_pos + (full_match.end() - full_match.start());
773
774 let mut is_covered = false;
776 for &(range_start, range_end) in &covered_ranges {
777 if range_start <= byte_pos && byte_end <= range_end {
778 is_covered = true;
780 break;
781 }
782 if range_start > byte_end {
783 break;
785 }
786 }
787
788 if is_covered {
789 continue;
790 }
791
792 let line_chars: Vec<char> = line.chars().collect();
797 if col > 0 && col <= line_chars.len() && line_chars.get(col - 1) == Some(&']') {
798 let mut bracket_count = 1; let mut check_pos = col.saturating_sub(2);
801 let mut found_opening = false;
802
803 while check_pos > 0 && check_pos < line_chars.len() {
804 match line_chars.get(check_pos) {
805 Some(&']') => bracket_count += 1,
806 Some(&'[') => {
807 bracket_count -= 1;
808 if bracket_count == 0 {
809 if check_pos == 0 || line_chars.get(check_pos - 1) != Some(&'\\') {
811 found_opening = true;
812 }
813 break;
814 }
815 }
816 _ => {}
817 }
818 if check_pos == 0 {
819 break;
820 }
821 check_pos = check_pos.saturating_sub(1);
822 }
823
824 if found_opening {
825 continue;
827 }
828 }
829
830 let before_text = &line[..col];
833 if before_text.contains("\\]") {
834 if let Some(escaped_close_pos) = before_text.rfind("\\]") {
836 let search_text = &before_text[..escaped_close_pos];
837 if search_text.contains("\\[") {
838 continue;
840 }
841 }
842 }
843
844 let match_len = full_match.end() - full_match.start();
845 undefined.push((line_num, col, match_len, reference.to_string()));
846 reported_refs.insert(reference_lower, true);
847 }
848 }
849 }
850 }
851 }
852
853 undefined.sort_by_key(|&(line, col, _, _)| (line, col));
856 undefined
857 }
858}
859
860fn original_case_label(text: &str, reference_lower: &str) -> String {
866 if !text.is_empty() && text.to_lowercase() == reference_lower {
867 text.to_string()
868 } else {
869 reference_lower.to_string()
870 }
871}
872
873impl Rule for MD052ReferenceLinkImages {
874 fn name(&self) -> &'static str {
875 "MD052"
876 }
877
878 fn description(&self) -> &'static str {
879 "Reference links and images should use a reference that exists"
880 }
881
882 fn category(&self) -> RuleCategory {
883 RuleCategory::Link
884 }
885
886 fn fix_capability(&self) -> FixCapability {
887 FixCapability::Unfixable
888 }
889
890 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
891 let mut warnings = Vec::new();
892
893 if !ctx.has_char('[') {
895 return Ok(warnings);
896 }
897
898 let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
900
901 let references = self.extract_references(ctx);
902
903 let lines = ctx.raw_lines();
905 for (line_num, col, match_len, reference) in self.find_undefined_references(&references, ctx, mkdocs_mode) {
906 let line_content = lines.get(line_num).unwrap_or(&"");
907
908 let (start_line, start_col, end_line, end_col) =
910 calculate_match_range(line_num + 1, line_content, col, match_len);
911
912 warnings.push(LintWarning {
913 rule_name: Some(self.name().to_string()),
914 line: start_line,
915 column: start_col,
916 end_line,
917 end_column: end_col,
918 message: format!("Reference '{reference}' not found"),
919 severity: Severity::Warning,
920 fix: None,
921 });
922 }
923
924 Ok(warnings)
925 }
926
927 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
929 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
931 }
932
933 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
934 let content = ctx.content;
935 Ok(content.to_string())
937 }
938
939 fn as_any(&self) -> &dyn std::any::Any {
940 self
941 }
942
943 crate::impl_rule_config_methods!(MD052Config);
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949 use crate::lint_context::LintContext;
950
951 #[test]
952 fn test_valid_reference_link() {
953 let rule = MD052ReferenceLinkImages::new();
954 let content = "[text][ref]\n\n[ref]: https://example.com";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let result = rule.check(&ctx).unwrap();
957
958 assert_eq!(result.len(), 0);
959 }
960
961 #[test]
962 fn test_undefined_reference_link() {
963 let rule = MD052ReferenceLinkImages::new();
964 let content = "[text][undefined]";
965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966 let result = rule.check(&ctx).unwrap();
967
968 assert_eq!(result.len(), 1);
969 assert!(result[0].message.contains("Reference 'undefined' not found"));
970 }
971
972 #[test]
973 fn test_undefined_reference_column_non_ascii_prefix() {
974 let rule = MD052ReferenceLinkImages::new();
978 let content = "你好[text][undefined]";
980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981 let result = rule.check(&ctx).unwrap();
982
983 assert_eq!(result.len(), 1);
984 assert_eq!(
985 result[0].column, 3,
986 "Column must be a character offset, not a byte offset"
987 );
988 }
989
990 #[test]
991 fn test_valid_reference_image() {
992 let rule = MD052ReferenceLinkImages::new();
993 let content = "![alt][img]\n\n[img]: image.jpg";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996
997 assert_eq!(result.len(), 0);
998 }
999
1000 #[test]
1001 fn test_undefined_reference_image() {
1002 let rule = MD052ReferenceLinkImages::new();
1003 let content = "![alt][missing]";
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005 let result = rule.check(&ctx).unwrap();
1006
1007 assert_eq!(result.len(), 1);
1008 assert!(result[0].message.contains("Reference 'missing' not found"));
1009 }
1010
1011 #[test]
1012 fn test_case_insensitive_references() {
1013 let rule = MD052ReferenceLinkImages::new();
1014 let content = "[Text][REF]\n\n[ref]: https://example.com";
1015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016 let result = rule.check(&ctx).unwrap();
1017
1018 assert_eq!(result.len(), 0);
1019 }
1020
1021 #[test]
1022 fn test_shortcut_reference_valid() {
1023 let rule = MD052ReferenceLinkImages::new();
1024 let content = "[ref]\n\n[ref]: https://example.com";
1025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026 let result = rule.check(&ctx).unwrap();
1027
1028 assert_eq!(result.len(), 0);
1029 }
1030
1031 #[test]
1032 fn test_shortcut_reference_undefined_with_shortcut_syntax_enabled() {
1033 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1036 shortcut_syntax: true,
1037 ..Default::default()
1038 });
1039 let content = "[undefined]";
1040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1041 let result = rule.check(&ctx).unwrap();
1042
1043 assert_eq!(result.len(), 1);
1044 assert!(result[0].message.contains("Reference 'undefined' not found"));
1045 }
1046
1047 #[test]
1048 fn test_shortcut_image_reference_checked_with_shortcut_syntax_enabled() {
1049 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1050 shortcut_syntax: true,
1051 ..Default::default()
1052 });
1053 let content = "![alt]\n\n";
1054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055 let result = rule.check(&ctx).unwrap();
1056
1057 assert_eq!(result.len(), 2, "got {result:?}");
1058 assert!(result[0].message.contains("Reference 'alt' not found"));
1059 assert!(result[1].message.contains("Reference 'alt2' not found"));
1060 }
1061
1062 #[test]
1063 fn test_shortcut_reference_in_table_cell_with_shortcut_syntax_enabled() {
1064 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1067 shortcut_syntax: true,
1068 ..Default::default()
1069 });
1070 let content = "| A | B |\n| --- | --- |\n| [undefined] | [defined] |\n\n[defined]: https://example.com\n";
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072 let result = rule.check(&ctx).unwrap();
1073
1074 assert_eq!(result.len(), 1, "{result:?}");
1075 assert_eq!(result[0].line, 3);
1076 assert!(result[0].message.contains("Reference 'undefined' not found"));
1077 }
1078
1079 #[test]
1080 fn test_shortcut_reference_not_checked_by_default() {
1081 let rule = MD052ReferenceLinkImages::new();
1083 let content = "[undefined]";
1084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1085 let result = rule.check(&ctx).unwrap();
1086
1087 assert_eq!(result.len(), 0);
1089 }
1090
1091 #[test]
1092 fn test_inline_links_ignored() {
1093 let rule = MD052ReferenceLinkImages::new();
1094 let content = "[text](https://example.com)";
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096 let result = rule.check(&ctx).unwrap();
1097
1098 assert_eq!(result.len(), 0);
1099 }
1100
1101 #[test]
1102 fn test_inline_images_ignored() {
1103 let rule = MD052ReferenceLinkImages::new();
1104 let content = "";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let result = rule.check(&ctx).unwrap();
1107
1108 assert_eq!(result.len(), 0);
1109 }
1110
1111 #[test]
1112 fn test_references_in_code_blocks_ignored() {
1113 let rule = MD052ReferenceLinkImages::new();
1114 let content = "```\n[undefined]\n```\n\n[ref]: https://example.com";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116 let result = rule.check(&ctx).unwrap();
1117
1118 assert_eq!(result.len(), 0);
1119 }
1120
1121 #[test]
1122 fn test_references_in_inline_code_ignored() {
1123 let rule = MD052ReferenceLinkImages::new();
1124 let content = "`[undefined]`";
1125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1126 let result = rule.check(&ctx).unwrap();
1127
1128 assert_eq!(result.len(), 0);
1130 }
1131
1132 #[test]
1133 fn test_comprehensive_inline_code_detection() {
1134 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1136 shortcut_syntax: true,
1137 ..Default::default()
1138 });
1139 let content = r#"# Test
1140
1141This `[inside]` should be ignored.
1142This [outside] should be flagged.
1143Reference links `[text][ref]` in code are ignored.
1144Regular reference [text][missing] should be flagged.
1145Images `![alt][img]` in code are ignored.
1146Regular image ![alt][badimg] should be flagged.
1147
1148Multiple `[one]` and `[two]` in code ignored, but [three] is not.
1149
1150```
1151[code block content] should be ignored
1152```
1153
1154`Multiple [refs] in [same] code span` ignored."#;
1155
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157 let result = rule.check(&ctx).unwrap();
1158
1159 assert_eq!(result.len(), 4);
1161
1162 let messages: Vec<&str> = result.iter().map(|w| &*w.message).collect();
1163 assert!(messages.iter().any(|m| m.contains("outside")));
1164 assert!(messages.iter().any(|m| m.contains("missing")));
1165 assert!(messages.iter().any(|m| m.contains("badimg")));
1166 assert!(messages.iter().any(|m| m.contains("three")));
1167
1168 assert!(!messages.iter().any(|m| m.contains("inside")));
1170 assert!(!messages.iter().any(|m| m.contains("one")));
1171 assert!(!messages.iter().any(|m| m.contains("two")));
1172 assert!(!messages.iter().any(|m| m.contains("refs")));
1173 assert!(!messages.iter().any(|m| m.contains("same")));
1174 }
1175
1176 #[test]
1177 fn test_multiple_undefined_references() {
1178 let rule = MD052ReferenceLinkImages::new();
1179 let content = "[link1][ref1] [link2][ref2] [link3][ref3]";
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181 let result = rule.check(&ctx).unwrap();
1182
1183 assert_eq!(result.len(), 3);
1184 assert!(result[0].message.contains("ref1"));
1185 assert!(result[1].message.contains("ref2"));
1186 assert!(result[2].message.contains("ref3"));
1187 }
1188
1189 #[test]
1190 fn test_mixed_valid_and_undefined() {
1191 let rule = MD052ReferenceLinkImages::new();
1192 let content = "[valid][ref] [invalid][missing]\n\n[ref]: https://example.com";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194 let result = rule.check(&ctx).unwrap();
1195
1196 assert_eq!(result.len(), 1);
1197 assert!(result[0].message.contains("missing"));
1198 }
1199
1200 #[test]
1201 fn test_empty_reference() {
1202 let rule = MD052ReferenceLinkImages::new();
1203 let content = "[text][]\n\n[ref]: https://example.com";
1204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1205 let result = rule.check(&ctx).unwrap();
1206
1207 assert_eq!(result.len(), 1);
1209 }
1210
1211 #[test]
1212 fn test_escaped_brackets_ignored() {
1213 let rule = MD052ReferenceLinkImages::new();
1214 let content = "\\[not a link\\]";
1215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1216 let result = rule.check(&ctx).unwrap();
1217
1218 assert_eq!(result.len(), 0);
1219 }
1220
1221 #[test]
1222 fn test_list_items_ignored() {
1223 let rule = MD052ReferenceLinkImages::new();
1224 let content = "- [undefined]\n* [another]\n+ [third]";
1225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1226 let result = rule.check(&ctx).unwrap();
1227
1228 assert_eq!(result.len(), 0);
1230 }
1231
1232 #[test]
1233 fn test_output_example_section_ignored() {
1234 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1236 shortcut_syntax: true,
1237 ..Default::default()
1238 });
1239 let content = "## Output\n\n[undefined]\n\n## Normal Section\n\n[missing]";
1240 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1241 let result = rule.check(&ctx).unwrap();
1242
1243 assert_eq!(result.len(), 1);
1245 assert!(result[0].message.contains("missing"));
1246 }
1247
1248 #[test]
1249 fn test_reference_definitions_in_code_blocks_ignored() {
1250 let rule = MD052ReferenceLinkImages::new();
1251 let content = "[link][ref]\n\n```\n[ref]: https://example.com\n```";
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253 let result = rule.check(&ctx).unwrap();
1254
1255 assert_eq!(result.len(), 1);
1257 assert!(result[0].message.contains("ref"));
1258 }
1259
1260 #[test]
1261 fn test_multiple_references_to_same_undefined() {
1262 let rule = MD052ReferenceLinkImages::new();
1263 let content = "[first][missing] [second][missing] [third][missing]";
1264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1265 let result = rule.check(&ctx).unwrap();
1266
1267 assert_eq!(result.len(), 1);
1269 assert!(result[0].message.contains("missing"));
1270 }
1271
1272 #[test]
1273 fn test_reference_with_special_characters() {
1274 let rule = MD052ReferenceLinkImages::new();
1275 let content = "[text][ref-with-hyphens]\n\n[ref-with-hyphens]: https://example.com";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 let result = rule.check(&ctx).unwrap();
1278
1279 assert_eq!(result.len(), 0);
1280 }
1281
1282 #[test]
1283 fn test_issue_51_html_attribute_not_reference() {
1284 let rule = MD052ReferenceLinkImages::new();
1286 let content = r#"# Example
1287
1288## Test
1289
1290Want to fill out this form?
1291
1292<form method="post">
1293 <input type="email" name="fields[email]" id="drip-email" placeholder="email@domain.com">
1294</form>"#;
1295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1296 let result = rule.check(&ctx).unwrap();
1297
1298 assert_eq!(
1299 result.len(),
1300 0,
1301 "HTML attributes with square brackets should not be flagged as undefined references"
1302 );
1303 }
1304
1305 #[test]
1306 fn test_extract_references() {
1307 let rule = MD052ReferenceLinkImages::new();
1308 let content = "[ref1]: url1\n[Ref2]: url2\n[REF3]: url3";
1309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1310 let refs = rule.extract_references(&ctx);
1311
1312 assert_eq!(refs.len(), 3);
1313 assert!(refs.contains("ref1"));
1314 assert!(refs.contains("ref2"));
1315 assert!(refs.contains("ref3"));
1316 }
1317
1318 #[test]
1319 fn test_inline_code_not_flagged() {
1320 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1322 shortcut_syntax: true,
1323 ..Default::default()
1324 });
1325
1326 let content = r#"# Test
1328
1329Configure with `["JavaScript", "GitHub", "Node.js"]` in your settings.
1330
1331Also, `[todo]` is not a reference link.
1332
1333But this [reference] should be flagged.
1334
1335And this `[inline code]` should not be flagged.
1336"#;
1337
1338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339 let warnings = rule.check(&ctx).unwrap();
1340
1341 assert_eq!(warnings.len(), 1, "Should only flag one undefined reference");
1343 assert!(warnings[0].message.contains("'reference'"));
1344 }
1345
1346 #[test]
1347 fn test_code_block_references_ignored() {
1348 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1350 shortcut_syntax: true,
1351 ..Default::default()
1352 });
1353
1354 let content = r#"# Test
1355
1356```markdown
1357[undefined] reference in code block
1358![undefined] image in code block
1359```
1360
1361[real-undefined] reference outside
1362"#;
1363
1364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1365 let warnings = rule.check(&ctx).unwrap();
1366
1367 assert_eq!(warnings.len(), 1);
1369 assert!(warnings[0].message.contains("'real-undefined'"));
1370 }
1371
1372 #[test]
1373 fn test_html_comments_ignored() {
1374 let rule = MD052ReferenceLinkImages::new();
1376
1377 let content = r#"<!--- write fake_editor.py 'import sys\nopen(*sys.argv[1:], mode="wt").write("2 3 4 4 2 3 2")' -->
1379<!--- set_env EDITOR 'python3 fake_editor.py' -->
1380
1381```bash
1382$ python3 vote.py
13833 votes for: 2
13842 votes for: 3, 4
1385```"#;
1386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387 let result = rule.check(&ctx).unwrap();
1388 assert_eq!(result.len(), 0, "Should not flag [1:] inside HTML comments");
1389
1390 let content = r#"<!-- This is [ref1] and [ref2][ref3] -->
1392Normal [text][undefined]
1393<!-- Another [comment][with] references -->"#;
1394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1395 let result = rule.check(&ctx).unwrap();
1396 assert_eq!(
1397 result.len(),
1398 1,
1399 "Should only flag the undefined reference outside comments"
1400 );
1401 assert!(result[0].message.contains("undefined"));
1402
1403 let content = r#"<!--
1405[ref1]
1406[ref2][ref3]
1407-->
1408[actual][undefined]"#;
1409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410 let result = rule.check(&ctx).unwrap();
1411 assert_eq!(
1412 result.len(),
1413 1,
1414 "Should not flag references in multi-line HTML comments"
1415 );
1416 assert!(result[0].message.contains("undefined"));
1417
1418 let content = r#"<!-- Comment with [1:] pattern -->
1420Valid [link][ref]
1421<!-- More [refs][in][comments] -->
1422![image][missing]
1423
1424[ref]: https://example.com"#;
1425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1426 let result = rule.check(&ctx).unwrap();
1427 assert_eq!(result.len(), 1, "Should only flag missing image reference");
1428 assert!(result[0].message.contains("missing"));
1429 }
1430
1431 #[test]
1432 fn test_frontmatter_ignored() {
1433 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1436 shortcut_syntax: true,
1437 ..Default::default()
1438 });
1439
1440 let content = r#"---
1442layout: post
1443title: "My Jekyll Post"
1444date: 2023-01-01
1445categories: blog
1446tags: ["test", "example"]
1447author: John Doe
1448---
1449
1450# My Blog Post
1451
1452This is the actual markdown content that should be linted.
1453
1454[undefined] reference should be flagged.
1455
1456## Section 1
1457
1458Some content here."#;
1459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460 let result = rule.check(&ctx).unwrap();
1461
1462 assert_eq!(
1464 result.len(),
1465 1,
1466 "Should only flag the undefined reference outside frontmatter"
1467 );
1468 assert!(result[0].message.contains("undefined"));
1469
1470 let content = r#"+++
1472title = "My Post"
1473tags = ["example", "test"]
1474+++
1475
1476# Content
1477
1478[missing] reference should be flagged."#;
1479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1480 let result = rule.check(&ctx).unwrap();
1481 assert_eq!(
1482 result.len(),
1483 1,
1484 "Should only flag the undefined reference outside TOML frontmatter"
1485 );
1486 assert!(result[0].message.contains("missing"));
1487 }
1488
1489 #[test]
1490 fn test_mkdocs_snippet_markers_not_flagged() {
1491 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1494 shortcut_syntax: true,
1495 ..Default::default()
1496 });
1497
1498 let content = r#"# Document with MkDocs Snippets
1500
1501Some content here.
1502
1503# -8<- [start:remote-content]
1504
1505This is the remote content section.
1506
1507# -8<- [end:remote-content]
1508
1509More content here.
1510
1511<!-- --8<-- [start:another-section] -->
1512Content in another section
1513<!-- --8<-- [end:another-section] -->"#;
1514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1515 let result = rule.check(&ctx).unwrap();
1516
1517 assert_eq!(
1519 result.len(),
1520 0,
1521 "Should not flag MkDocs snippet markers as undefined references"
1522 );
1523
1524 let content = r#"# Document
1527
1528# -8<- [start:section]
1529Content with [reference] inside snippet section
1530# -8<- [end:section]
1531
1532Regular [undefined] reference outside snippet markers."#;
1533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1534 let result = rule.check(&ctx).unwrap();
1535
1536 assert_eq!(
1537 result.len(),
1538 2,
1539 "Should flag undefined references but skip snippet marker lines"
1540 );
1541 assert!(result[0].message.contains("reference"));
1543 assert!(result[1].message.contains("undefined"));
1544
1545 let content = r#"# Document
1547
1548# -8<- [start:section]
1549# -8<- [end:section]"#;
1550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1551 let result = rule.check(&ctx).unwrap();
1552
1553 assert_eq!(
1554 result.len(),
1555 2,
1556 "In standard mode, snippet markers should be flagged as undefined references"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_pandoc_citations_not_flagged() {
1562 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1565 shortcut_syntax: true,
1566 ..Default::default()
1567 });
1568
1569 let content = r#"# Research Paper
1570
1571We are using the **bookdown** package [@R-bookdown] in this sample book.
1572This was built on top of R Markdown and **knitr** [@xie2015].
1573
1574Multiple citations [@citation1; @citation2; @citation3] are also supported.
1575
1576Regular [undefined] reference should still be flagged.
1577"#;
1578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1579 let result = rule.check(&ctx).unwrap();
1580
1581 assert_eq!(
1583 result.len(),
1584 1,
1585 "Should only flag the undefined reference, not Pandoc citations"
1586 );
1587 assert!(result[0].message.contains("undefined"));
1588 }
1589
1590 #[test]
1591 fn test_pandoc_inline_footnotes_not_flagged() {
1592 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1595 shortcut_syntax: true,
1596 ..Default::default()
1597 });
1598
1599 let content = r#"# Math Document
1600
1601You can use math in footnotes like this^[where we mention $p = \frac{a}{b}$].
1602
1603Another footnote^[with some text and a [link](https://example.com)].
1604
1605But this [reference] without ^ should be flagged.
1606"#;
1607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1608 let result = rule.check(&ctx).unwrap();
1609
1610 assert_eq!(
1612 result.len(),
1613 1,
1614 "Should only flag the regular reference, not inline footnotes"
1615 );
1616 assert!(result[0].message.contains("reference"));
1617 }
1618
1619 #[test]
1620 fn test_github_alerts_not_flagged() {
1621 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1624 shortcut_syntax: true,
1625 ..Default::default()
1626 });
1627
1628 let content = r#"# Document with GitHub Alerts
1630
1631> [!NOTE]
1632> This is a note alert.
1633
1634> [!TIP]
1635> This is a tip alert.
1636
1637> [!IMPORTANT]
1638> This is an important alert.
1639
1640> [!WARNING]
1641> This is a warning alert.
1642
1643> [!CAUTION]
1644> This is a caution alert.
1645
1646Regular content with [undefined] reference."#;
1647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1648 let result = rule.check(&ctx).unwrap();
1649
1650 assert_eq!(
1652 result.len(),
1653 1,
1654 "Should only flag the undefined reference, not GitHub alerts"
1655 );
1656 assert!(result[0].message.contains("undefined"));
1657 assert_eq!(result[0].line, 18); let content = r#"> [!TIP]
1661> Here's a useful tip about [something].
1662> Multiple lines are allowed.
1663
1664[something] is mentioned but not defined."#;
1665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1666 let result = rule.check(&ctx).unwrap();
1667
1668 assert_eq!(result.len(), 1, "Should flag undefined reference");
1672 assert!(result[0].message.contains("something"));
1673
1674 let content = r#"> [!NOTE]
1676> See [reference] for more details.
1677
1678[reference]: https://example.com"#;
1679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1680 let result = rule.check(&ctx).unwrap();
1681
1682 assert_eq!(result.len(), 0, "Should not flag GitHub alerts or defined references");
1684 }
1685
1686 #[test]
1687 fn test_ignore_config() {
1688 let config = MD052Config {
1690 shortcut_syntax: true,
1691 ignore: vec!["Vec".to_string(), "HashMap".to_string(), "Option".to_string()],
1692 };
1693 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1694
1695 let content = r#"# Document with Custom Types
1696
1697Use [Vec] for dynamic arrays.
1698Use [HashMap] for key-value storage.
1699Use [Option] for nullable values.
1700Use [Result] for error handling.
1701"#;
1702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703 let result = rule.check(&ctx).unwrap();
1704
1705 assert_eq!(result.len(), 1, "Should only flag names not in ignore");
1707 assert!(result[0].message.contains("Result"));
1709 }
1710
1711 #[test]
1712 fn test_ignore_case_insensitive() {
1713 let config = MD052Config {
1715 shortcut_syntax: true,
1716 ignore: vec!["Vec".to_string()],
1717 };
1718 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1719
1720 let content = r#"# Case Insensitivity Test
1721
1722[Vec] should be ignored.
1723[vec] should also be ignored (different case, same match).
1724[VEC] should also be ignored (different case, same match).
1725[undefined] should be flagged (not in ignore list).
1726"#;
1727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1728 let result = rule.check(&ctx).unwrap();
1729
1730 assert_eq!(result.len(), 1, "Should only flag non-ignored reference");
1732 assert!(result[0].message.contains("undefined"));
1733 }
1734
1735 #[test]
1736 fn test_ignore_empty_by_default() {
1737 let rule = MD052ReferenceLinkImages::new();
1739
1740 let content = "[text][undefined]";
1741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1742 let result = rule.check(&ctx).unwrap();
1743
1744 assert_eq!(result.len(), 1);
1746 assert!(result[0].message.contains("undefined"));
1747 }
1748
1749 #[test]
1750 fn test_ignore_with_reference_links() {
1751 let config = MD052Config {
1753 shortcut_syntax: false,
1754 ignore: vec!["CustomType".to_string()],
1755 };
1756 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1757
1758 let content = r#"# Test
1759
1760See [documentation][CustomType] for details.
1761See [other docs][MissingRef] for more.
1762"#;
1763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1764 let result = rule.check(&ctx).unwrap();
1765
1766 for (i, w) in result.iter().enumerate() {
1768 eprintln!("Warning {}: {}", i, w.message);
1769 }
1770
1771 assert_eq!(result.len(), 1, "Expected 1 warning, got {}", result.len());
1774 assert!(
1775 result[0].message.contains("missingref"),
1776 "Expected 'missingref' in message: {}",
1777 result[0].message
1778 );
1779 }
1780
1781 #[test]
1782 fn test_ignore_multiple() {
1783 let config = MD052Config {
1785 shortcut_syntax: true,
1786 ignore: vec![
1787 "i32".to_string(),
1788 "u64".to_string(),
1789 "String".to_string(),
1790 "Arc".to_string(),
1791 "Mutex".to_string(),
1792 ],
1793 };
1794 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1795
1796 let content = r#"# Types
1797
1798[i32] [u64] [String] [Arc] [Mutex] [Box]
1799"#;
1800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1801 let result = rule.check(&ctx).unwrap();
1802
1803 assert_eq!(result.len(), 1);
1807 assert!(result[0].message.contains("Box"));
1809 }
1810
1811 #[test]
1812 fn test_nested_code_fences_reference_extraction() {
1813 let rule = MD052ReferenceLinkImages::new();
1818
1819 let content = "````\n```\n[ref-inside]: https://example.com\n```\n````\n\n[Use this link][ref-inside]";
1820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1821 let result = rule.check(&ctx).unwrap();
1822
1823 assert_eq!(
1827 result.len(),
1828 1,
1829 "Reference defined inside nested code fence should not count as a definition"
1830 );
1831 assert!(result[0].message.contains("ref-inside"));
1832 }
1833
1834 #[test]
1835 fn test_pandoc_flavor_skips_citations() {
1836 let rule = MD052ReferenceLinkImages::new();
1839 let content = "See [@smith2020] for details.\n";
1840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1841 let result = rule.check(&ctx).unwrap();
1842 assert!(
1843 result.is_empty(),
1844 "MD052 should skip Pandoc citations under Pandoc flavor: {result:?}"
1845 );
1846 }
1847
1848 #[test]
1849 fn md052_pandoc_skips_implicit_header_refs_with_shortcut_syntax() {
1850 use crate::config::MarkdownFlavor;
1857 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1858 shortcut_syntax: true,
1859 ..Default::default()
1860 });
1861 let content = "# My Section\n\nSee [My Section] for details.\n";
1862
1863 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1866 let std_result = rule.check(&ctx_std).unwrap();
1867 assert_eq!(
1868 std_result.len(),
1869 1,
1870 "Standard flavor with shortcut_syntax should flag [My Section]: {std_result:?}"
1871 );
1872
1873 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1875 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1876 assert!(
1877 pandoc_result.is_empty(),
1878 "Pandoc flavor should accept [My Section] as an implicit header ref: {pandoc_result:?}"
1879 );
1880 }
1881
1882 #[test]
1883 fn test_md052_complex_undefined_reference() {
1884 let rule = MD052ReferenceLinkImages::from_config(&crate::config::Config::default());
1885 let content = "Check [link `code [with brackets]` text][undefined_ref] for details.\n";
1887 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1888 let result = rule.check(&ctx).unwrap();
1889 assert_eq!(
1890 result.len(),
1891 1,
1892 "Undefined reference in complex link must be flagged: {result:?}"
1893 );
1894 assert_eq!(result[0].message, "Reference 'undefined_ref' not found");
1895 }
1896}