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, is_in_table_cell};
6use regex::Regex;
7use std::collections::{HashMap, HashSet};
8use std::sync::LazyLock;
9
10mod md052_config;
11use md052_config::MD052Config;
12
13static REF_REGEX: LazyLock<Regex> =
17 LazyLock::new(|| Regex::new(r"^\s*\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]:\s*.*").unwrap());
18
19static LIST_ITEM_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*[-*+]\s+(?:\[[xX\s]\]\s+)?").unwrap());
21
22static OUTPUT_EXAMPLE_START: LazyLock<Regex> =
24 LazyLock::new(|| Regex::new(r"^#+\s*(?:Output|Example|Output Style|Output Format)\s*$").unwrap());
25
26static GITHUB_ALERT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
29 Regex::new(r"^\s*>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|INFO|SUCCESS|FAILURE|DANGER|BUG|EXAMPLE|QUOTE)\]")
30 .unwrap()
31});
32
33static URL_WITH_BRACKETS: LazyLock<Regex> =
41 LazyLock::new(|| Regex::new(r"https?://(?:\[[0-9a-fA-F:.%]+\]|[^\s\[\]]+/[^\s]*\[\d+\])").unwrap());
42
43#[derive(Clone, Default)]
56pub struct MD052ReferenceLinkImages {
57 config: MD052Config,
58}
59
60impl MD052ReferenceLinkImages {
61 pub fn new() -> Self {
62 Self {
63 config: MD052Config::default(),
64 }
65 }
66
67 pub fn from_config_struct(config: MD052Config) -> Self {
68 Self { config }
69 }
70
71 fn strip_backticks(s: &str) -> &str {
74 s.trim_start_matches('`').trim_end_matches('`')
75 }
76
77 fn is_valid_python_identifier(s: &str) -> bool {
81 if s.is_empty() {
82 return false;
83 }
84 let first_char = s.chars().next().unwrap();
85 if !first_char.is_ascii_alphabetic() && first_char != '_' {
86 return false;
87 }
88 s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
89 }
90
91 fn is_known_non_reference_pattern(&self, text: &str) -> bool {
100 if self.config.ignore.iter().any(|p| p.eq_ignore_ascii_case(text)) {
104 return true;
105 }
106 if text.chars().all(|c| c.is_ascii_digit()) {
108 return true;
109 }
110
111 if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
113 return true;
114 }
115
116 if text.contains('.')
120 && !text.contains(' ')
121 && !text.contains('-')
122 && !text.contains('_')
123 && !text.contains('`')
124 {
125 return true;
127 }
128
129 if text == "*" || text == "..." || text == "**" {
131 return true;
132 }
133
134 if text.contains('/') && !text.contains(' ') && !text.starts_with("http") {
136 return true;
137 }
138
139 if text.contains(',') || text.contains('[') || text.contains(']') {
142 return true;
144 }
145
146 if !text.contains('`')
153 && text.contains('.')
154 && !text.contains(' ')
155 && !text.contains('-')
156 && !text.contains('_')
157 {
158 return true;
159 }
160
161 if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
168 return true;
169 }
170
171 if text.len() <= 2 && !text.chars().all(char::is_alphabetic) {
173 return true;
174 }
175
176 if (text.starts_with('"') && text.ends_with('"'))
178 || (text.starts_with('\'') && text.ends_with('\''))
179 || text.contains('"')
180 || text.contains('\'')
181 {
182 return true;
183 }
184
185 if text.contains(':') && text.contains(' ') {
188 return true;
189 }
190
191 if text.starts_with('!') {
193 return true;
194 }
195
196 if text.starts_with('^') {
199 return true;
200 }
201
202 if text.starts_with('@') {
205 return true;
206 }
207
208 if text == "TOC" {
211 return true;
212 }
213
214 if text.len() == 1 && text.chars().all(|c| c.is_ascii_uppercase()) {
216 return true;
217 }
218
219 let common_non_refs = [
222 "object",
224 "Object",
225 "any",
226 "Any",
227 "inv",
228 "void",
229 "bool",
230 "int",
231 "float",
232 "str",
233 "char",
234 "i8",
235 "i16",
236 "i32",
237 "i64",
238 "i128",
239 "isize",
240 "u8",
241 "u16",
242 "u32",
243 "u64",
244 "u128",
245 "usize",
246 "f32",
247 "f64",
248 "null",
250 "true",
251 "false",
252 "NaN",
253 "Infinity",
254 "object Object",
256 ];
257
258 if common_non_refs.contains(&text) {
259 return true;
260 }
261
262 false
263 }
264
265 fn is_in_code_span(byte_pos: usize, code_spans: &[crate::lint_context::CodeSpan]) -> bool {
267 let idx = code_spans.partition_point(|span| span.byte_offset <= byte_pos);
268 idx > 0 && byte_pos < code_spans[idx - 1].byte_end
269 }
270
271 fn is_in_html_tag(html_tags: &[crate::lint_context::HtmlTag], byte_pos: usize) -> bool {
273 let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
274 idx > 0 && byte_pos < html_tags[idx - 1].byte_end
275 }
276
277 fn extract_references(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
278 use crate::utils::skip_context::is_mkdocs_snippet_line;
279
280 let mut references = HashSet::new();
281
282 for (line_num, line) in ctx.content.lines().enumerate() {
283 if let Some(line_info) = ctx.line_info(line_num + 1)
285 && line_info.in_code_block
286 {
287 continue;
288 }
289
290 if is_mkdocs_snippet_line(line, ctx.flavor) {
292 continue;
293 }
294
295 if line.trim_start().starts_with("*[") {
298 continue;
299 }
300
301 if let Some(cap) = REF_REGEX.captures(line) {
302 if let Some(reference) = cap.get(1) {
304 references.insert(reference.as_str().to_lowercase());
305 }
306 }
307 }
308
309 for def in &ctx.reference_defs {
313 references.insert(def.id.clone());
314 }
315
316 references
317 }
318
319 fn find_undefined_references(
320 &self,
321 references: &HashSet<String>,
322 ctx: &crate::lint_context::LintContext,
323 mkdocs_mode: bool,
324 ) -> Vec<(usize, usize, usize, String)> {
325 let mut undefined = Vec::new();
326 let mut reported_refs = HashMap::new();
327 let mut in_example_section = false;
328
329 let code_spans = ctx.code_spans();
331 let html_tags = ctx.html_tags();
332
333 for link in &ctx.links {
335 if !link.is_reference {
336 continue; }
338
339 if ctx.is_in_jinja_range(link.byte_offset) {
341 continue;
342 }
343
344 if Self::is_in_code_span(link.byte_offset, &code_spans) {
346 continue;
347 }
348
349 if ctx.is_in_html_comment(link.byte_offset) || ctx.is_in_mdx_comment(link.byte_offset) {
351 continue;
352 }
353
354 if Self::is_in_html_tag(&html_tags, link.byte_offset) {
356 continue;
357 }
358
359 if is_in_math_context(ctx, link.byte_offset) {
361 continue;
362 }
363
364 if is_in_table_cell(ctx, link.line, link.start_col) {
366 continue;
367 }
368
369 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
371 continue;
372 }
373
374 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
377 continue;
378 }
379
380 if ctx.is_in_shortcode(link.byte_offset) {
383 continue;
384 }
385
386 if let Some(ref_id) = &link.reference_id {
387 let reference_lower = ref_id.to_lowercase();
388
389 if self.is_known_non_reference_pattern(ref_id) {
391 continue;
392 }
393
394 let stripped_ref = Self::strip_backticks(ref_id);
398 let stripped_text = Self::strip_backticks(&link.text);
399 if mkdocs_mode
400 && (is_mkdocs_auto_reference(stripped_ref)
401 || is_mkdocs_auto_reference(stripped_text)
402 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
403 || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text)))
404 {
405 continue;
406 }
407
408 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
410 if let Some(line_info) = ctx.line_info(link.line) {
412 if OUTPUT_EXAMPLE_START.is_match(line_info.content(ctx.content)) {
413 in_example_section = true;
414 continue;
415 }
416
417 if in_example_section {
418 continue;
419 }
420
421 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
423 continue;
424 }
425
426 let trimmed = line_info.content(ctx.content).trim_start();
428 if trimmed.starts_with('<') {
429 continue;
430 }
431 }
432
433 let match_len = link.byte_end - link.byte_offset;
434 let line_start = ctx.line_index.get_line_start_byte(link.line).unwrap_or(0);
437 undefined.push((
438 link.line - 1,
439 link.byte_offset - line_start,
440 match_len,
441 ref_id.to_string(),
442 ));
443 reported_refs.insert(reference_lower, true);
444 }
445 }
446 }
447
448 for image in &ctx.images {
450 if !image.is_reference {
451 continue; }
453
454 if ctx.is_in_jinja_range(image.byte_offset) {
456 continue;
457 }
458
459 if Self::is_in_code_span(image.byte_offset, &code_spans) {
461 continue;
462 }
463
464 if ctx.is_in_html_comment(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
466 continue;
467 }
468
469 if Self::is_in_html_tag(&html_tags, image.byte_offset) {
471 continue;
472 }
473
474 if is_in_math_context(ctx, image.byte_offset) {
476 continue;
477 }
478
479 if is_in_table_cell(ctx, image.line, image.start_col) {
481 continue;
482 }
483
484 if ctx.line_info(image.line).is_some_and(|info| info.in_front_matter) {
486 continue;
487 }
488
489 if let Some(ref_id) = &image.reference_id {
490 let reference_lower = ref_id.to_lowercase();
491
492 if self.is_known_non_reference_pattern(ref_id) {
494 continue;
495 }
496
497 let stripped_ref = Self::strip_backticks(ref_id);
501 let stripped_alt = Self::strip_backticks(&image.alt_text);
502 if mkdocs_mode
503 && (is_mkdocs_auto_reference(stripped_ref)
504 || is_mkdocs_auto_reference(stripped_alt)
505 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
506 || (image.alt_text.as_ref() != stripped_alt && Self::is_valid_python_identifier(stripped_alt)))
507 {
508 continue;
509 }
510
511 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
513 if let Some(line_info) = ctx.line_info(image.line) {
515 if OUTPUT_EXAMPLE_START.is_match(line_info.content(ctx.content)) {
516 in_example_section = true;
517 continue;
518 }
519
520 if in_example_section {
521 continue;
522 }
523
524 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
526 continue;
527 }
528
529 let trimmed = line_info.content(ctx.content).trim_start();
531 if trimmed.starts_with('<') {
532 continue;
533 }
534 }
535
536 let match_len = image.byte_end - image.byte_offset;
537 let line_start = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
540 undefined.push((
541 image.line - 1,
542 image.byte_offset - line_start,
543 match_len,
544 ref_id.to_string(),
545 ));
546 reported_refs.insert(reference_lower, true);
547 }
548 }
549 }
550
551 let mut covered_ranges: Vec<(usize, usize)> = Vec::new();
553
554 for link in &ctx.links {
556 covered_ranges.push((link.byte_offset, link.byte_end));
557 }
558
559 for image in &ctx.images {
561 covered_ranges.push((image.byte_offset, image.byte_end));
562 }
563
564 covered_ranges.sort_by_key(|&(start, _)| start);
566
567 if !self.config.shortcut_syntax {
572 return undefined;
573 }
574
575 let lines = ctx.raw_lines();
577 in_example_section = false; for (line_num, line) in lines.iter().enumerate() {
580 if let Some(line_info) = ctx.line_info(line_num + 1)
582 && (line_info.in_front_matter || line_info.in_code_block)
583 {
584 continue;
585 }
586
587 if OUTPUT_EXAMPLE_START.is_match(line) {
589 in_example_section = true;
590 continue;
591 }
592
593 if in_example_section {
594 if line.starts_with('#') && !OUTPUT_EXAMPLE_START.is_match(line) {
596 in_example_section = false;
597 } else {
598 continue;
599 }
600 }
601
602 if LIST_ITEM_REGEX.is_match(line) {
604 continue;
605 }
606
607 let trimmed_line = line.trim_start();
609 if trimmed_line.starts_with('<') {
610 continue;
611 }
612
613 if GITHUB_ALERT_REGEX.is_match(line) {
615 continue;
616 }
617
618 if trimmed_line.starts_with("*[") {
621 continue;
622 }
623
624 let mut url_bracket_ranges: Vec<(usize, usize)> = Vec::new();
627 for mat in URL_WITH_BRACKETS.find_iter(line) {
628 let url_str = mat.as_str();
630 let url_start = mat.start();
631
632 let mut idx = 0;
634 while idx < url_str.len() {
635 if let Some(bracket_start) = url_str[idx..].find('[') {
636 let bracket_start_abs = url_start + idx + bracket_start;
637 if let Some(bracket_end) = url_str[idx + bracket_start + 1..].find(']') {
638 let bracket_end_abs = url_start + idx + bracket_start + 1 + bracket_end + 1;
639 url_bracket_ranges.push((bracket_start_abs, bracket_end_abs));
640 idx += bracket_start + bracket_end + 2;
641 } else {
642 break;
643 }
644 } else {
645 break;
646 }
647 }
648 }
649
650 if let Ok(captures) = SHORTCUT_REF_REGEX.captures_iter(line).collect::<Result<Vec<_>, _>>() {
652 for cap in captures {
653 if let Some(ref_match) = cap.get(1) {
654 let bracket_start = cap.get(0).unwrap().start();
656 let bracket_end = cap.get(0).unwrap().end();
657
658 let is_in_url = url_bracket_ranges
660 .iter()
661 .any(|&(url_start, url_end)| bracket_start >= url_start && bracket_end <= url_end);
662
663 if is_in_url {
664 continue;
665 }
666
667 if bracket_start > 0 {
670 if let Some(byte) = line.as_bytes().get(bracket_start.saturating_sub(1))
672 && *byte == b'^'
673 {
674 continue; }
676 }
677
678 let reference = ref_match.as_str();
679 let reference_lower = reference.to_lowercase();
680
681 if self.is_known_non_reference_pattern(reference) {
683 continue;
684 }
685
686 if let Some(alert_type) = reference.strip_prefix('!')
688 && matches!(
689 alert_type,
690 "NOTE"
691 | "TIP"
692 | "WARNING"
693 | "IMPORTANT"
694 | "CAUTION"
695 | "INFO"
696 | "SUCCESS"
697 | "FAILURE"
698 | "DANGER"
699 | "BUG"
700 | "EXAMPLE"
701 | "QUOTE"
702 )
703 {
704 continue;
705 }
706
707 if mkdocs_mode
710 && (reference.starts_with("start:") || reference.starts_with("end:"))
711 && (crate::utils::mkdocs_snippets::is_snippet_section_start(line)
712 || crate::utils::mkdocs_snippets::is_snippet_section_end(line))
713 {
714 continue;
715 }
716
717 let stripped_ref = Self::strip_backticks(reference);
720 if mkdocs_mode
721 && (is_mkdocs_auto_reference(stripped_ref)
722 || (reference != stripped_ref && Self::is_valid_python_identifier(stripped_ref)))
723 {
724 continue;
725 }
726
727 if ctx.flavor.is_pandoc_compatible() && ctx.matches_implicit_header_reference(reference) {
731 continue;
732 }
733
734 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
735 let full_match = cap.get(0).unwrap();
736 let col = full_match.start();
737 let line_start_byte = ctx.line_offsets[line_num];
738 let byte_pos = line_start_byte + col;
739
740 let code_spans = ctx.code_spans();
742 if Self::is_in_code_span(byte_pos, &code_spans) {
743 continue;
744 }
745
746 if ctx.is_in_jinja_range(byte_pos) {
748 continue;
749 }
750
751 if crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block(
753 &ctx.code_blocks,
754 byte_pos,
755 ) {
756 continue;
757 }
758
759 if ctx.is_in_html_comment(byte_pos) || ctx.is_in_mdx_comment(byte_pos) {
761 continue;
762 }
763
764 if Self::is_in_html_tag(&html_tags, byte_pos) {
766 continue;
767 }
768
769 if is_in_math_context(ctx, byte_pos) {
771 continue;
772 }
773
774 if is_in_table_cell(ctx, line_num + 1, col) {
776 continue;
777 }
778
779 let byte_end = byte_pos + (full_match.end() - full_match.start());
780
781 let mut is_covered = false;
783 for &(range_start, range_end) in &covered_ranges {
784 if range_start <= byte_pos && byte_end <= range_end {
785 is_covered = true;
787 break;
788 }
789 if range_start > byte_end {
790 break;
792 }
793 }
794
795 if is_covered {
796 continue;
797 }
798
799 let line_chars: Vec<char> = line.chars().collect();
804 if col > 0 && col <= line_chars.len() && line_chars.get(col - 1) == Some(&']') {
805 let mut bracket_count = 1; let mut check_pos = col.saturating_sub(2);
808 let mut found_opening = false;
809
810 while check_pos > 0 && check_pos < line_chars.len() {
811 match line_chars.get(check_pos) {
812 Some(&']') => bracket_count += 1,
813 Some(&'[') => {
814 bracket_count -= 1;
815 if bracket_count == 0 {
816 if check_pos == 0 || line_chars.get(check_pos - 1) != Some(&'\\') {
818 found_opening = true;
819 }
820 break;
821 }
822 }
823 _ => {}
824 }
825 if check_pos == 0 {
826 break;
827 }
828 check_pos = check_pos.saturating_sub(1);
829 }
830
831 if found_opening {
832 continue;
834 }
835 }
836
837 let before_text = &line[..col];
840 if before_text.contains("\\]") {
841 if let Some(escaped_close_pos) = before_text.rfind("\\]") {
843 let search_text = &before_text[..escaped_close_pos];
844 if search_text.contains("\\[") {
845 continue;
847 }
848 }
849 }
850
851 let match_len = full_match.end() - full_match.start();
852 undefined.push((line_num, col, match_len, reference.to_string()));
853 reported_refs.insert(reference_lower, true);
854 }
855 }
856 }
857 }
858 }
859
860 undefined
861 }
862}
863
864impl Rule for MD052ReferenceLinkImages {
865 fn name(&self) -> &'static str {
866 "MD052"
867 }
868
869 fn description(&self) -> &'static str {
870 "Reference links and images should use a reference that exists"
871 }
872
873 fn category(&self) -> RuleCategory {
874 RuleCategory::Link
875 }
876
877 fn fix_capability(&self) -> FixCapability {
878 FixCapability::Unfixable
879 }
880
881 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
882 let content = ctx.content;
883 let mut warnings = Vec::new();
884
885 if !content.contains('[') {
887 return Ok(warnings);
888 }
889
890 let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
892
893 let references = self.extract_references(ctx);
894
895 let lines = ctx.raw_lines();
897 for (line_num, col, match_len, reference) in self.find_undefined_references(&references, ctx, mkdocs_mode) {
898 let line_content = lines.get(line_num).unwrap_or(&"");
899
900 let (start_line, start_col, end_line, end_col) =
902 calculate_match_range(line_num + 1, line_content, col, match_len);
903
904 warnings.push(LintWarning {
905 rule_name: Some(self.name().to_string()),
906 line: start_line,
907 column: start_col,
908 end_line,
909 end_column: end_col,
910 message: format!("Reference '{reference}' not found"),
911 severity: Severity::Warning,
912 fix: None,
913 });
914 }
915
916 Ok(warnings)
917 }
918
919 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
921 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
923 }
924
925 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
926 let content = ctx.content;
927 Ok(content.to_string())
929 }
930
931 fn as_any(&self) -> &dyn std::any::Any {
932 self
933 }
934
935 crate::impl_rule_config_methods!(MD052Config);
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941 use crate::lint_context::LintContext;
942
943 #[test]
944 fn test_valid_reference_link() {
945 let rule = MD052ReferenceLinkImages::new();
946 let content = "[text][ref]\n\n[ref]: https://example.com";
947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
948 let result = rule.check(&ctx).unwrap();
949
950 assert_eq!(result.len(), 0);
951 }
952
953 #[test]
954 fn test_undefined_reference_link() {
955 let rule = MD052ReferenceLinkImages::new();
956 let content = "[text][undefined]";
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958 let result = rule.check(&ctx).unwrap();
959
960 assert_eq!(result.len(), 1);
961 assert!(result[0].message.contains("Reference 'undefined' not found"));
962 }
963
964 #[test]
965 fn test_undefined_reference_column_non_ascii_prefix() {
966 let rule = MD052ReferenceLinkImages::new();
970 let content = "你好[text][undefined]";
972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
973 let result = rule.check(&ctx).unwrap();
974
975 assert_eq!(result.len(), 1);
976 assert_eq!(
977 result[0].column, 3,
978 "Column must be a character offset, not a byte offset"
979 );
980 }
981
982 #[test]
983 fn test_valid_reference_image() {
984 let rule = MD052ReferenceLinkImages::new();
985 let content = "![alt][img]\n\n[img]: image.jpg";
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let result = rule.check(&ctx).unwrap();
988
989 assert_eq!(result.len(), 0);
990 }
991
992 #[test]
993 fn test_undefined_reference_image() {
994 let rule = MD052ReferenceLinkImages::new();
995 let content = "![alt][missing]";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let result = rule.check(&ctx).unwrap();
998
999 assert_eq!(result.len(), 1);
1000 assert!(result[0].message.contains("Reference 'missing' not found"));
1001 }
1002
1003 #[test]
1004 fn test_case_insensitive_references() {
1005 let rule = MD052ReferenceLinkImages::new();
1006 let content = "[Text][REF]\n\n[ref]: https://example.com";
1007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008 let result = rule.check(&ctx).unwrap();
1009
1010 assert_eq!(result.len(), 0);
1011 }
1012
1013 #[test]
1014 fn test_shortcut_reference_valid() {
1015 let rule = MD052ReferenceLinkImages::new();
1016 let content = "[ref]\n\n[ref]: https://example.com";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018 let result = rule.check(&ctx).unwrap();
1019
1020 assert_eq!(result.len(), 0);
1021 }
1022
1023 #[test]
1024 fn test_shortcut_reference_undefined_with_shortcut_syntax_enabled() {
1025 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1028 shortcut_syntax: true,
1029 ..Default::default()
1030 });
1031 let content = "[undefined]";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033 let result = rule.check(&ctx).unwrap();
1034
1035 assert_eq!(result.len(), 1);
1036 assert!(result[0].message.contains("Reference 'undefined' not found"));
1037 }
1038
1039 #[test]
1040 fn test_shortcut_reference_not_checked_by_default() {
1041 let rule = MD052ReferenceLinkImages::new();
1043 let content = "[undefined]";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let result = rule.check(&ctx).unwrap();
1046
1047 assert_eq!(result.len(), 0);
1049 }
1050
1051 #[test]
1052 fn test_inline_links_ignored() {
1053 let rule = MD052ReferenceLinkImages::new();
1054 let content = "[text](https://example.com)";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056 let result = rule.check(&ctx).unwrap();
1057
1058 assert_eq!(result.len(), 0);
1059 }
1060
1061 #[test]
1062 fn test_inline_images_ignored() {
1063 let rule = MD052ReferenceLinkImages::new();
1064 let content = "";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let result = rule.check(&ctx).unwrap();
1067
1068 assert_eq!(result.len(), 0);
1069 }
1070
1071 #[test]
1072 fn test_references_in_code_blocks_ignored() {
1073 let rule = MD052ReferenceLinkImages::new();
1074 let content = "```\n[undefined]\n```\n\n[ref]: https://example.com";
1075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1076 let result = rule.check(&ctx).unwrap();
1077
1078 assert_eq!(result.len(), 0);
1079 }
1080
1081 #[test]
1082 fn test_references_in_inline_code_ignored() {
1083 let rule = MD052ReferenceLinkImages::new();
1084 let content = "`[undefined]`";
1085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1086 let result = rule.check(&ctx).unwrap();
1087
1088 assert_eq!(result.len(), 0);
1090 }
1091
1092 #[test]
1093 fn test_comprehensive_inline_code_detection() {
1094 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1096 shortcut_syntax: true,
1097 ..Default::default()
1098 });
1099 let content = r#"# Test
1100
1101This `[inside]` should be ignored.
1102This [outside] should be flagged.
1103Reference links `[text][ref]` in code are ignored.
1104Regular reference [text][missing] should be flagged.
1105Images `![alt][img]` in code are ignored.
1106Regular image ![alt][badimg] should be flagged.
1107
1108Multiple `[one]` and `[two]` in code ignored, but [three] is not.
1109
1110```
1111[code block content] should be ignored
1112```
1113
1114`Multiple [refs] in [same] code span` ignored."#;
1115
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 let result = rule.check(&ctx).unwrap();
1118
1119 assert_eq!(result.len(), 4);
1121
1122 let messages: Vec<&str> = result.iter().map(|w| &*w.message).collect();
1123 assert!(messages.iter().any(|m| m.contains("outside")));
1124 assert!(messages.iter().any(|m| m.contains("missing")));
1125 assert!(messages.iter().any(|m| m.contains("badimg")));
1126 assert!(messages.iter().any(|m| m.contains("three")));
1127
1128 assert!(!messages.iter().any(|m| m.contains("inside")));
1130 assert!(!messages.iter().any(|m| m.contains("one")));
1131 assert!(!messages.iter().any(|m| m.contains("two")));
1132 assert!(!messages.iter().any(|m| m.contains("refs")));
1133 assert!(!messages.iter().any(|m| m.contains("same")));
1134 }
1135
1136 #[test]
1137 fn test_multiple_undefined_references() {
1138 let rule = MD052ReferenceLinkImages::new();
1139 let content = "[link1][ref1] [link2][ref2] [link3][ref3]";
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.check(&ctx).unwrap();
1142
1143 assert_eq!(result.len(), 3);
1144 assert!(result[0].message.contains("ref1"));
1145 assert!(result[1].message.contains("ref2"));
1146 assert!(result[2].message.contains("ref3"));
1147 }
1148
1149 #[test]
1150 fn test_mixed_valid_and_undefined() {
1151 let rule = MD052ReferenceLinkImages::new();
1152 let content = "[valid][ref] [invalid][missing]\n\n[ref]: https://example.com";
1153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154 let result = rule.check(&ctx).unwrap();
1155
1156 assert_eq!(result.len(), 1);
1157 assert!(result[0].message.contains("missing"));
1158 }
1159
1160 #[test]
1161 fn test_empty_reference() {
1162 let rule = MD052ReferenceLinkImages::new();
1163 let content = "[text][]\n\n[ref]: https://example.com";
1164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1165 let result = rule.check(&ctx).unwrap();
1166
1167 assert_eq!(result.len(), 1);
1169 }
1170
1171 #[test]
1172 fn test_escaped_brackets_ignored() {
1173 let rule = MD052ReferenceLinkImages::new();
1174 let content = "\\[not a link\\]";
1175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1176 let result = rule.check(&ctx).unwrap();
1177
1178 assert_eq!(result.len(), 0);
1179 }
1180
1181 #[test]
1182 fn test_list_items_ignored() {
1183 let rule = MD052ReferenceLinkImages::new();
1184 let content = "- [undefined]\n* [another]\n+ [third]";
1185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186 let result = rule.check(&ctx).unwrap();
1187
1188 assert_eq!(result.len(), 0);
1190 }
1191
1192 #[test]
1193 fn test_output_example_section_ignored() {
1194 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1196 shortcut_syntax: true,
1197 ..Default::default()
1198 });
1199 let content = "## Output\n\n[undefined]\n\n## Normal Section\n\n[missing]";
1200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1201 let result = rule.check(&ctx).unwrap();
1202
1203 assert_eq!(result.len(), 1);
1205 assert!(result[0].message.contains("missing"));
1206 }
1207
1208 #[test]
1209 fn test_reference_definitions_in_code_blocks_ignored() {
1210 let rule = MD052ReferenceLinkImages::new();
1211 let content = "[link][ref]\n\n```\n[ref]: https://example.com\n```";
1212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213 let result = rule.check(&ctx).unwrap();
1214
1215 assert_eq!(result.len(), 1);
1217 assert!(result[0].message.contains("ref"));
1218 }
1219
1220 #[test]
1221 fn test_multiple_references_to_same_undefined() {
1222 let rule = MD052ReferenceLinkImages::new();
1223 let content = "[first][missing] [second][missing] [third][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_with_special_characters() {
1234 let rule = MD052ReferenceLinkImages::new();
1235 let content = "[text][ref-with-hyphens]\n\n[ref-with-hyphens]: https://example.com";
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237 let result = rule.check(&ctx).unwrap();
1238
1239 assert_eq!(result.len(), 0);
1240 }
1241
1242 #[test]
1243 fn test_issue_51_html_attribute_not_reference() {
1244 let rule = MD052ReferenceLinkImages::new();
1246 let content = r#"# Example
1247
1248## Test
1249
1250Want to fill out this form?
1251
1252<form method="post">
1253 <input type="email" name="fields[email]" id="drip-email" placeholder="email@domain.com">
1254</form>"#;
1255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1256 let result = rule.check(&ctx).unwrap();
1257
1258 assert_eq!(
1259 result.len(),
1260 0,
1261 "HTML attributes with square brackets should not be flagged as undefined references"
1262 );
1263 }
1264
1265 #[test]
1266 fn test_extract_references() {
1267 let rule = MD052ReferenceLinkImages::new();
1268 let content = "[ref1]: url1\n[Ref2]: url2\n[REF3]: url3";
1269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1270 let refs = rule.extract_references(&ctx);
1271
1272 assert_eq!(refs.len(), 3);
1273 assert!(refs.contains("ref1"));
1274 assert!(refs.contains("ref2"));
1275 assert!(refs.contains("ref3"));
1276 }
1277
1278 #[test]
1279 fn test_inline_code_not_flagged() {
1280 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1282 shortcut_syntax: true,
1283 ..Default::default()
1284 });
1285
1286 let content = r#"# Test
1288
1289Configure with `["JavaScript", "GitHub", "Node.js"]` in your settings.
1290
1291Also, `[todo]` is not a reference link.
1292
1293But this [reference] should be flagged.
1294
1295And this `[inline code]` should not be flagged.
1296"#;
1297
1298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1299 let warnings = rule.check(&ctx).unwrap();
1300
1301 assert_eq!(warnings.len(), 1, "Should only flag one undefined reference");
1303 assert!(warnings[0].message.contains("'reference'"));
1304 }
1305
1306 #[test]
1307 fn test_code_block_references_ignored() {
1308 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1310 shortcut_syntax: true,
1311 ..Default::default()
1312 });
1313
1314 let content = r#"# Test
1315
1316```markdown
1317[undefined] reference in code block
1318![undefined] image in code block
1319```
1320
1321[real-undefined] reference outside
1322"#;
1323
1324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325 let warnings = rule.check(&ctx).unwrap();
1326
1327 assert_eq!(warnings.len(), 1);
1329 assert!(warnings[0].message.contains("'real-undefined'"));
1330 }
1331
1332 #[test]
1333 fn test_html_comments_ignored() {
1334 let rule = MD052ReferenceLinkImages::new();
1336
1337 let content = r#"<!--- write fake_editor.py 'import sys\nopen(*sys.argv[1:], mode="wt").write("2 3 4 4 2 3 2")' -->
1339<!--- set_env EDITOR 'python3 fake_editor.py' -->
1340
1341```bash
1342$ python3 vote.py
13433 votes for: 2
13442 votes for: 3, 4
1345```"#;
1346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347 let result = rule.check(&ctx).unwrap();
1348 assert_eq!(result.len(), 0, "Should not flag [1:] inside HTML comments");
1349
1350 let content = r#"<!-- This is [ref1] and [ref2][ref3] -->
1352Normal [text][undefined]
1353<!-- Another [comment][with] references -->"#;
1354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355 let result = rule.check(&ctx).unwrap();
1356 assert_eq!(
1357 result.len(),
1358 1,
1359 "Should only flag the undefined reference outside comments"
1360 );
1361 assert!(result[0].message.contains("undefined"));
1362
1363 let content = r#"<!--
1365[ref1]
1366[ref2][ref3]
1367-->
1368[actual][undefined]"#;
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370 let result = rule.check(&ctx).unwrap();
1371 assert_eq!(
1372 result.len(),
1373 1,
1374 "Should not flag references in multi-line HTML comments"
1375 );
1376 assert!(result[0].message.contains("undefined"));
1377
1378 let content = r#"<!-- Comment with [1:] pattern -->
1380Valid [link][ref]
1381<!-- More [refs][in][comments] -->
1382![image][missing]
1383
1384[ref]: https://example.com"#;
1385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386 let result = rule.check(&ctx).unwrap();
1387 assert_eq!(result.len(), 1, "Should only flag missing image reference");
1388 assert!(result[0].message.contains("missing"));
1389 }
1390
1391 #[test]
1392 fn test_frontmatter_ignored() {
1393 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1396 shortcut_syntax: true,
1397 ..Default::default()
1398 });
1399
1400 let content = r#"---
1402layout: post
1403title: "My Jekyll Post"
1404date: 2023-01-01
1405categories: blog
1406tags: ["test", "example"]
1407author: John Doe
1408---
1409
1410# My Blog Post
1411
1412This is the actual markdown content that should be linted.
1413
1414[undefined] reference should be flagged.
1415
1416## Section 1
1417
1418Some content here."#;
1419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420 let result = rule.check(&ctx).unwrap();
1421
1422 assert_eq!(
1424 result.len(),
1425 1,
1426 "Should only flag the undefined reference outside frontmatter"
1427 );
1428 assert!(result[0].message.contains("undefined"));
1429
1430 let content = r#"+++
1432title = "My Post"
1433tags = ["example", "test"]
1434+++
1435
1436# Content
1437
1438[missing] reference should be flagged."#;
1439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440 let result = rule.check(&ctx).unwrap();
1441 assert_eq!(
1442 result.len(),
1443 1,
1444 "Should only flag the undefined reference outside TOML frontmatter"
1445 );
1446 assert!(result[0].message.contains("missing"));
1447 }
1448
1449 #[test]
1450 fn test_mkdocs_snippet_markers_not_flagged() {
1451 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1454 shortcut_syntax: true,
1455 ..Default::default()
1456 });
1457
1458 let content = r#"# Document with MkDocs Snippets
1460
1461Some content here.
1462
1463# -8<- [start:remote-content]
1464
1465This is the remote content section.
1466
1467# -8<- [end:remote-content]
1468
1469More content here.
1470
1471<!-- --8<-- [start:another-section] -->
1472Content in another section
1473<!-- --8<-- [end:another-section] -->"#;
1474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1475 let result = rule.check(&ctx).unwrap();
1476
1477 assert_eq!(
1479 result.len(),
1480 0,
1481 "Should not flag MkDocs snippet markers as undefined references"
1482 );
1483
1484 let content = r#"# Document
1487
1488# -8<- [start:section]
1489Content with [reference] inside snippet section
1490# -8<- [end:section]
1491
1492Regular [undefined] reference outside snippet markers."#;
1493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1494 let result = rule.check(&ctx).unwrap();
1495
1496 assert_eq!(
1497 result.len(),
1498 2,
1499 "Should flag undefined references but skip snippet marker lines"
1500 );
1501 assert!(result[0].message.contains("reference"));
1503 assert!(result[1].message.contains("undefined"));
1504
1505 let content = r#"# Document
1507
1508# -8<- [start:section]
1509# -8<- [end:section]"#;
1510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511 let result = rule.check(&ctx).unwrap();
1512
1513 assert_eq!(
1514 result.len(),
1515 2,
1516 "In standard mode, snippet markers should be flagged as undefined references"
1517 );
1518 }
1519
1520 #[test]
1521 fn test_pandoc_citations_not_flagged() {
1522 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1525 shortcut_syntax: true,
1526 ..Default::default()
1527 });
1528
1529 let content = r#"# Research Paper
1530
1531We are using the **bookdown** package [@R-bookdown] in this sample book.
1532This was built on top of R Markdown and **knitr** [@xie2015].
1533
1534Multiple citations [@citation1; @citation2; @citation3] are also supported.
1535
1536Regular [undefined] reference should still be flagged.
1537"#;
1538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1539 let result = rule.check(&ctx).unwrap();
1540
1541 assert_eq!(
1543 result.len(),
1544 1,
1545 "Should only flag the undefined reference, not Pandoc citations"
1546 );
1547 assert!(result[0].message.contains("undefined"));
1548 }
1549
1550 #[test]
1551 fn test_pandoc_inline_footnotes_not_flagged() {
1552 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1555 shortcut_syntax: true,
1556 ..Default::default()
1557 });
1558
1559 let content = r#"# Math Document
1560
1561You can use math in footnotes like this^[where we mention $p = \frac{a}{b}$].
1562
1563Another footnote^[with some text and a [link](https://example.com)].
1564
1565But this [reference] without ^ should be flagged.
1566"#;
1567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568 let result = rule.check(&ctx).unwrap();
1569
1570 assert_eq!(
1572 result.len(),
1573 1,
1574 "Should only flag the regular reference, not inline footnotes"
1575 );
1576 assert!(result[0].message.contains("reference"));
1577 }
1578
1579 #[test]
1580 fn test_github_alerts_not_flagged() {
1581 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1584 shortcut_syntax: true,
1585 ..Default::default()
1586 });
1587
1588 let content = r#"# Document with GitHub Alerts
1590
1591> [!NOTE]
1592> This is a note alert.
1593
1594> [!TIP]
1595> This is a tip alert.
1596
1597> [!IMPORTANT]
1598> This is an important alert.
1599
1600> [!WARNING]
1601> This is a warning alert.
1602
1603> [!CAUTION]
1604> This is a caution alert.
1605
1606Regular content with [undefined] reference."#;
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 undefined reference, not GitHub alerts"
1615 );
1616 assert!(result[0].message.contains("undefined"));
1617 assert_eq!(result[0].line, 18); let content = r#"> [!TIP]
1621> Here's a useful tip about [something].
1622> Multiple lines are allowed.
1623
1624[something] is mentioned but not defined."#;
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626 let result = rule.check(&ctx).unwrap();
1627
1628 assert_eq!(result.len(), 1, "Should flag undefined reference");
1632 assert!(result[0].message.contains("something"));
1633
1634 let content = r#"> [!NOTE]
1636> See [reference] for more details.
1637
1638[reference]: https://example.com"#;
1639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1640 let result = rule.check(&ctx).unwrap();
1641
1642 assert_eq!(result.len(), 0, "Should not flag GitHub alerts or defined references");
1644 }
1645
1646 #[test]
1647 fn test_ignore_config() {
1648 let config = MD052Config {
1650 shortcut_syntax: true,
1651 ignore: vec!["Vec".to_string(), "HashMap".to_string(), "Option".to_string()],
1652 };
1653 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1654
1655 let content = r#"# Document with Custom Types
1656
1657Use [Vec] for dynamic arrays.
1658Use [HashMap] for key-value storage.
1659Use [Option] for nullable values.
1660Use [Result] for error handling.
1661"#;
1662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1663 let result = rule.check(&ctx).unwrap();
1664
1665 assert_eq!(result.len(), 1, "Should only flag names not in ignore");
1667 assert!(result[0].message.contains("Result"));
1668 }
1669
1670 #[test]
1671 fn test_ignore_case_insensitive() {
1672 let config = MD052Config {
1674 shortcut_syntax: true,
1675 ignore: vec!["Vec".to_string()],
1676 };
1677 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1678
1679 let content = r#"# Case Insensitivity Test
1680
1681[Vec] should be ignored.
1682[vec] should also be ignored (different case, same match).
1683[VEC] should also be ignored (different case, same match).
1684[undefined] should be flagged (not in ignore list).
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 non-ignored reference");
1691 assert!(result[0].message.contains("undefined"));
1692 }
1693
1694 #[test]
1695 fn test_ignore_empty_by_default() {
1696 let rule = MD052ReferenceLinkImages::new();
1698
1699 let content = "[text][undefined]";
1700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1701 let result = rule.check(&ctx).unwrap();
1702
1703 assert_eq!(result.len(), 1);
1705 assert!(result[0].message.contains("undefined"));
1706 }
1707
1708 #[test]
1709 fn test_ignore_with_reference_links() {
1710 let config = MD052Config {
1712 shortcut_syntax: false,
1713 ignore: vec!["CustomType".to_string()],
1714 };
1715 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1716
1717 let content = r#"# Test
1718
1719See [documentation][CustomType] for details.
1720See [other docs][MissingRef] for more.
1721"#;
1722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1723 let result = rule.check(&ctx).unwrap();
1724
1725 for (i, w) in result.iter().enumerate() {
1727 eprintln!("Warning {}: {}", i, w.message);
1728 }
1729
1730 assert_eq!(result.len(), 1, "Expected 1 warning, got {}", result.len());
1733 assert!(
1734 result[0].message.contains("missingref"),
1735 "Expected 'missingref' in message: {}",
1736 result[0].message
1737 );
1738 }
1739
1740 #[test]
1741 fn test_ignore_multiple() {
1742 let config = MD052Config {
1744 shortcut_syntax: true,
1745 ignore: vec![
1746 "i32".to_string(),
1747 "u64".to_string(),
1748 "String".to_string(),
1749 "Arc".to_string(),
1750 "Mutex".to_string(),
1751 ],
1752 };
1753 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1754
1755 let content = r#"# Types
1756
1757[i32] [u64] [String] [Arc] [Mutex] [Box]
1758"#;
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760 let result = rule.check(&ctx).unwrap();
1761
1762 assert_eq!(result.len(), 1);
1766 assert!(result[0].message.contains("Box"));
1767 }
1768
1769 #[test]
1770 fn test_nested_code_fences_reference_extraction() {
1771 let rule = MD052ReferenceLinkImages::new();
1776
1777 let content = "````\n```\n[ref-inside]: https://example.com\n```\n````\n\n[Use this link][ref-inside]";
1778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1779 let result = rule.check(&ctx).unwrap();
1780
1781 assert_eq!(
1785 result.len(),
1786 1,
1787 "Reference defined inside nested code fence should not count as a definition"
1788 );
1789 assert!(result[0].message.contains("ref-inside"));
1790 }
1791
1792 #[test]
1793 fn test_pandoc_flavor_skips_citations() {
1794 let rule = MD052ReferenceLinkImages::new();
1797 let content = "See [@smith2020] for details.\n";
1798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1799 let result = rule.check(&ctx).unwrap();
1800 assert!(
1801 result.is_empty(),
1802 "MD052 should skip Pandoc citations under Pandoc flavor: {result:?}"
1803 );
1804 }
1805
1806 #[test]
1807 fn md052_pandoc_skips_implicit_header_refs_with_shortcut_syntax() {
1808 use crate::config::MarkdownFlavor;
1815 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1816 shortcut_syntax: true,
1817 ..Default::default()
1818 });
1819 let content = "# My Section\n\nSee [My Section] for details.\n";
1820
1821 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1824 let std_result = rule.check(&ctx_std).unwrap();
1825 assert_eq!(
1826 std_result.len(),
1827 1,
1828 "Standard flavor with shortcut_syntax should flag [My Section]: {std_result:?}"
1829 );
1830
1831 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1833 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1834 assert!(
1835 pandoc_result.is_empty(),
1836 "Pandoc flavor should accept [My Section] as an implicit header ref: {pandoc_result:?}"
1837 );
1838 }
1839}