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 references
310 }
311
312 fn find_undefined_references(
313 &self,
314 references: &HashSet<String>,
315 ctx: &crate::lint_context::LintContext,
316 mkdocs_mode: bool,
317 ) -> Vec<(usize, usize, usize, String)> {
318 let mut undefined = Vec::new();
319 let mut reported_refs = HashMap::new();
320 let mut in_example_section = false;
321
322 let code_spans = ctx.code_spans();
324 let html_tags = ctx.html_tags();
325
326 for link in &ctx.links {
328 if !link.is_reference {
329 continue; }
331
332 if ctx.is_in_jinja_range(link.byte_offset) {
334 continue;
335 }
336
337 if Self::is_in_code_span(link.byte_offset, &code_spans) {
339 continue;
340 }
341
342 if ctx.is_in_html_comment(link.byte_offset) || ctx.is_in_mdx_comment(link.byte_offset) {
344 continue;
345 }
346
347 if Self::is_in_html_tag(&html_tags, link.byte_offset) {
349 continue;
350 }
351
352 if is_in_math_context(ctx, link.byte_offset) {
354 continue;
355 }
356
357 if is_in_table_cell(ctx, link.line, link.start_col) {
359 continue;
360 }
361
362 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
364 continue;
365 }
366
367 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
370 continue;
371 }
372
373 if ctx.is_in_shortcode(link.byte_offset) {
376 continue;
377 }
378
379 if let Some(ref_id) = &link.reference_id {
380 let reference_lower = ref_id.to_lowercase();
381
382 if self.is_known_non_reference_pattern(ref_id) {
384 continue;
385 }
386
387 let stripped_ref = Self::strip_backticks(ref_id);
391 let stripped_text = Self::strip_backticks(&link.text);
392 if mkdocs_mode
393 && (is_mkdocs_auto_reference(stripped_ref)
394 || is_mkdocs_auto_reference(stripped_text)
395 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
396 || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text)))
397 {
398 continue;
399 }
400
401 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
403 if let Some(line_info) = ctx.line_info(link.line) {
405 if OUTPUT_EXAMPLE_START.is_match(line_info.content(ctx.content)) {
406 in_example_section = true;
407 continue;
408 }
409
410 if in_example_section {
411 continue;
412 }
413
414 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
416 continue;
417 }
418
419 let trimmed = line_info.content(ctx.content).trim_start();
421 if trimmed.starts_with('<') {
422 continue;
423 }
424 }
425
426 let match_len = link.byte_end - link.byte_offset;
427 undefined.push((link.line - 1, link.start_col, match_len, ref_id.to_string()));
428 reported_refs.insert(reference_lower, true);
429 }
430 }
431 }
432
433 for image in &ctx.images {
435 if !image.is_reference {
436 continue; }
438
439 if ctx.is_in_jinja_range(image.byte_offset) {
441 continue;
442 }
443
444 if Self::is_in_code_span(image.byte_offset, &code_spans) {
446 continue;
447 }
448
449 if ctx.is_in_html_comment(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
451 continue;
452 }
453
454 if Self::is_in_html_tag(&html_tags, image.byte_offset) {
456 continue;
457 }
458
459 if is_in_math_context(ctx, image.byte_offset) {
461 continue;
462 }
463
464 if is_in_table_cell(ctx, image.line, image.start_col) {
466 continue;
467 }
468
469 if ctx.line_info(image.line).is_some_and(|info| info.in_front_matter) {
471 continue;
472 }
473
474 if let Some(ref_id) = &image.reference_id {
475 let reference_lower = ref_id.to_lowercase();
476
477 if self.is_known_non_reference_pattern(ref_id) {
479 continue;
480 }
481
482 let stripped_ref = Self::strip_backticks(ref_id);
486 let stripped_alt = Self::strip_backticks(&image.alt_text);
487 if mkdocs_mode
488 && (is_mkdocs_auto_reference(stripped_ref)
489 || is_mkdocs_auto_reference(stripped_alt)
490 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
491 || (image.alt_text.as_ref() != stripped_alt && Self::is_valid_python_identifier(stripped_alt)))
492 {
493 continue;
494 }
495
496 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
498 if let Some(line_info) = ctx.line_info(image.line) {
500 if OUTPUT_EXAMPLE_START.is_match(line_info.content(ctx.content)) {
501 in_example_section = true;
502 continue;
503 }
504
505 if in_example_section {
506 continue;
507 }
508
509 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
511 continue;
512 }
513
514 let trimmed = line_info.content(ctx.content).trim_start();
516 if trimmed.starts_with('<') {
517 continue;
518 }
519 }
520
521 let match_len = image.byte_end - image.byte_offset;
522 undefined.push((image.line - 1, image.start_col, match_len, ref_id.to_string()));
523 reported_refs.insert(reference_lower, true);
524 }
525 }
526 }
527
528 let mut covered_ranges: Vec<(usize, usize)> = Vec::new();
530
531 for link in &ctx.links {
533 covered_ranges.push((link.byte_offset, link.byte_end));
534 }
535
536 for image in &ctx.images {
538 covered_ranges.push((image.byte_offset, image.byte_end));
539 }
540
541 covered_ranges.sort_by_key(|&(start, _)| start);
543
544 if !self.config.shortcut_syntax {
549 return undefined;
550 }
551
552 let lines = ctx.raw_lines();
554 in_example_section = false; for (line_num, line) in lines.iter().enumerate() {
557 if let Some(line_info) = ctx.line_info(line_num + 1)
559 && (line_info.in_front_matter || line_info.in_code_block)
560 {
561 continue;
562 }
563
564 if OUTPUT_EXAMPLE_START.is_match(line) {
566 in_example_section = true;
567 continue;
568 }
569
570 if in_example_section {
571 if line.starts_with('#') && !OUTPUT_EXAMPLE_START.is_match(line) {
573 in_example_section = false;
574 } else {
575 continue;
576 }
577 }
578
579 if LIST_ITEM_REGEX.is_match(line) {
581 continue;
582 }
583
584 let trimmed_line = line.trim_start();
586 if trimmed_line.starts_with('<') {
587 continue;
588 }
589
590 if GITHUB_ALERT_REGEX.is_match(line) {
592 continue;
593 }
594
595 if trimmed_line.starts_with("*[") {
598 continue;
599 }
600
601 let mut url_bracket_ranges: Vec<(usize, usize)> = Vec::new();
604 for mat in URL_WITH_BRACKETS.find_iter(line) {
605 let url_str = mat.as_str();
607 let url_start = mat.start();
608
609 let mut idx = 0;
611 while idx < url_str.len() {
612 if let Some(bracket_start) = url_str[idx..].find('[') {
613 let bracket_start_abs = url_start + idx + bracket_start;
614 if let Some(bracket_end) = url_str[idx + bracket_start + 1..].find(']') {
615 let bracket_end_abs = url_start + idx + bracket_start + 1 + bracket_end + 1;
616 url_bracket_ranges.push((bracket_start_abs, bracket_end_abs));
617 idx += bracket_start + bracket_end + 2;
618 } else {
619 break;
620 }
621 } else {
622 break;
623 }
624 }
625 }
626
627 if let Ok(captures) = SHORTCUT_REF_REGEX.captures_iter(line).collect::<Result<Vec<_>, _>>() {
629 for cap in captures {
630 if let Some(ref_match) = cap.get(1) {
631 let bracket_start = cap.get(0).unwrap().start();
633 let bracket_end = cap.get(0).unwrap().end();
634
635 let is_in_url = url_bracket_ranges
637 .iter()
638 .any(|&(url_start, url_end)| bracket_start >= url_start && bracket_end <= url_end);
639
640 if is_in_url {
641 continue;
642 }
643
644 if bracket_start > 0 {
647 if let Some(byte) = line.as_bytes().get(bracket_start.saturating_sub(1))
649 && *byte == b'^'
650 {
651 continue; }
653 }
654
655 let reference = ref_match.as_str();
656 let reference_lower = reference.to_lowercase();
657
658 if self.is_known_non_reference_pattern(reference) {
660 continue;
661 }
662
663 if let Some(alert_type) = reference.strip_prefix('!')
665 && matches!(
666 alert_type,
667 "NOTE"
668 | "TIP"
669 | "WARNING"
670 | "IMPORTANT"
671 | "CAUTION"
672 | "INFO"
673 | "SUCCESS"
674 | "FAILURE"
675 | "DANGER"
676 | "BUG"
677 | "EXAMPLE"
678 | "QUOTE"
679 )
680 {
681 continue;
682 }
683
684 if mkdocs_mode
687 && (reference.starts_with("start:") || reference.starts_with("end:"))
688 && (crate::utils::mkdocs_snippets::is_snippet_section_start(line)
689 || crate::utils::mkdocs_snippets::is_snippet_section_end(line))
690 {
691 continue;
692 }
693
694 let stripped_ref = Self::strip_backticks(reference);
697 if mkdocs_mode
698 && (is_mkdocs_auto_reference(stripped_ref)
699 || (reference != stripped_ref && Self::is_valid_python_identifier(stripped_ref)))
700 {
701 continue;
702 }
703
704 if ctx.flavor.is_pandoc_compatible() && ctx.matches_implicit_header_reference(reference) {
708 continue;
709 }
710
711 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
712 let full_match = cap.get(0).unwrap();
713 let col = full_match.start();
714 let line_start_byte = ctx.line_offsets[line_num];
715 let byte_pos = line_start_byte + col;
716
717 let code_spans = ctx.code_spans();
719 if Self::is_in_code_span(byte_pos, &code_spans) {
720 continue;
721 }
722
723 if ctx.is_in_jinja_range(byte_pos) {
725 continue;
726 }
727
728 if crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block(
730 &ctx.code_blocks,
731 byte_pos,
732 ) {
733 continue;
734 }
735
736 if ctx.is_in_html_comment(byte_pos) || ctx.is_in_mdx_comment(byte_pos) {
738 continue;
739 }
740
741 if Self::is_in_html_tag(&html_tags, byte_pos) {
743 continue;
744 }
745
746 if is_in_math_context(ctx, byte_pos) {
748 continue;
749 }
750
751 if is_in_table_cell(ctx, line_num + 1, col) {
753 continue;
754 }
755
756 let byte_end = byte_pos + (full_match.end() - full_match.start());
757
758 let mut is_covered = false;
760 for &(range_start, range_end) in &covered_ranges {
761 if range_start <= byte_pos && byte_end <= range_end {
762 is_covered = true;
764 break;
765 }
766 if range_start > byte_end {
767 break;
769 }
770 }
771
772 if is_covered {
773 continue;
774 }
775
776 let line_chars: Vec<char> = line.chars().collect();
781 if col > 0 && col <= line_chars.len() && line_chars.get(col - 1) == Some(&']') {
782 let mut bracket_count = 1; let mut check_pos = col.saturating_sub(2);
785 let mut found_opening = false;
786
787 while check_pos > 0 && check_pos < line_chars.len() {
788 match line_chars.get(check_pos) {
789 Some(&']') => bracket_count += 1,
790 Some(&'[') => {
791 bracket_count -= 1;
792 if bracket_count == 0 {
793 if check_pos == 0 || line_chars.get(check_pos - 1) != Some(&'\\') {
795 found_opening = true;
796 }
797 break;
798 }
799 }
800 _ => {}
801 }
802 if check_pos == 0 {
803 break;
804 }
805 check_pos = check_pos.saturating_sub(1);
806 }
807
808 if found_opening {
809 continue;
811 }
812 }
813
814 let before_text = &line[..col];
817 if before_text.contains("\\]") {
818 if let Some(escaped_close_pos) = before_text.rfind("\\]") {
820 let search_text = &before_text[..escaped_close_pos];
821 if search_text.contains("\\[") {
822 continue;
824 }
825 }
826 }
827
828 let match_len = full_match.end() - full_match.start();
829 undefined.push((line_num, col, match_len, reference.to_string()));
830 reported_refs.insert(reference_lower, true);
831 }
832 }
833 }
834 }
835 }
836
837 undefined
838 }
839}
840
841impl Rule for MD052ReferenceLinkImages {
842 fn name(&self) -> &'static str {
843 "MD052"
844 }
845
846 fn description(&self) -> &'static str {
847 "Reference links and images should use a reference that exists"
848 }
849
850 fn category(&self) -> RuleCategory {
851 RuleCategory::Link
852 }
853
854 fn fix_capability(&self) -> FixCapability {
855 FixCapability::Unfixable
856 }
857
858 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
859 let content = ctx.content;
860 let mut warnings = Vec::new();
861
862 if !content.contains('[') {
864 return Ok(warnings);
865 }
866
867 let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
869
870 let references = self.extract_references(ctx);
871
872 let lines = ctx.raw_lines();
874 for (line_num, col, match_len, reference) in self.find_undefined_references(&references, ctx, mkdocs_mode) {
875 let line_content = lines.get(line_num).unwrap_or(&"");
876
877 let (start_line, start_col, end_line, end_col) =
879 calculate_match_range(line_num + 1, line_content, col, match_len);
880
881 warnings.push(LintWarning {
882 rule_name: Some(self.name().to_string()),
883 line: start_line,
884 column: start_col,
885 end_line,
886 end_column: end_col,
887 message: format!("Reference '{reference}' not found"),
888 severity: Severity::Warning,
889 fix: None,
890 });
891 }
892
893 Ok(warnings)
894 }
895
896 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
898 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
900 }
901
902 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
903 let content = ctx.content;
904 Ok(content.to_string())
906 }
907
908 fn as_any(&self) -> &dyn std::any::Any {
909 self
910 }
911
912 crate::impl_rule_config_methods!(MD052Config);
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918 use crate::lint_context::LintContext;
919
920 #[test]
921 fn test_valid_reference_link() {
922 let rule = MD052ReferenceLinkImages::new();
923 let content = "[text][ref]\n\n[ref]: https://example.com";
924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925 let result = rule.check(&ctx).unwrap();
926
927 assert_eq!(result.len(), 0);
928 }
929
930 #[test]
931 fn test_undefined_reference_link() {
932 let rule = MD052ReferenceLinkImages::new();
933 let content = "[text][undefined]";
934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
935 let result = rule.check(&ctx).unwrap();
936
937 assert_eq!(result.len(), 1);
938 assert!(result[0].message.contains("Reference 'undefined' not found"));
939 }
940
941 #[test]
942 fn test_valid_reference_image() {
943 let rule = MD052ReferenceLinkImages::new();
944 let content = "![alt][img]\n\n[img]: image.jpg";
945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let result = rule.check(&ctx).unwrap();
947
948 assert_eq!(result.len(), 0);
949 }
950
951 #[test]
952 fn test_undefined_reference_image() {
953 let rule = MD052ReferenceLinkImages::new();
954 let content = "![alt][missing]";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let result = rule.check(&ctx).unwrap();
957
958 assert_eq!(result.len(), 1);
959 assert!(result[0].message.contains("Reference 'missing' not found"));
960 }
961
962 #[test]
963 fn test_case_insensitive_references() {
964 let rule = MD052ReferenceLinkImages::new();
965 let content = "[Text][REF]\n\n[ref]: https://example.com";
966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
967 let result = rule.check(&ctx).unwrap();
968
969 assert_eq!(result.len(), 0);
970 }
971
972 #[test]
973 fn test_shortcut_reference_valid() {
974 let rule = MD052ReferenceLinkImages::new();
975 let content = "[ref]\n\n[ref]: https://example.com";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977 let result = rule.check(&ctx).unwrap();
978
979 assert_eq!(result.len(), 0);
980 }
981
982 #[test]
983 fn test_shortcut_reference_undefined_with_shortcut_syntax_enabled() {
984 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
987 shortcut_syntax: true,
988 ..Default::default()
989 });
990 let content = "[undefined]";
991 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
992 let result = rule.check(&ctx).unwrap();
993
994 assert_eq!(result.len(), 1);
995 assert!(result[0].message.contains("Reference 'undefined' not found"));
996 }
997
998 #[test]
999 fn test_shortcut_reference_not_checked_by_default() {
1000 let rule = MD052ReferenceLinkImages::new();
1002 let content = "[undefined]";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let result = rule.check(&ctx).unwrap();
1005
1006 assert_eq!(result.len(), 0);
1008 }
1009
1010 #[test]
1011 fn test_inline_links_ignored() {
1012 let rule = MD052ReferenceLinkImages::new();
1013 let content = "[text](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_inline_images_ignored() {
1022 let rule = MD052ReferenceLinkImages::new();
1023 let content = "";
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_references_in_code_blocks_ignored() {
1032 let rule = MD052ReferenceLinkImages::new();
1033 let content = "```\n[undefined]\n```\n\n[ref]: https://example.com";
1034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1035 let result = rule.check(&ctx).unwrap();
1036
1037 assert_eq!(result.len(), 0);
1038 }
1039
1040 #[test]
1041 fn test_references_in_inline_code_ignored() {
1042 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_comprehensive_inline_code_detection() {
1053 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1055 shortcut_syntax: true,
1056 ..Default::default()
1057 });
1058 let content = r#"# Test
1059
1060This `[inside]` should be ignored.
1061This [outside] should be flagged.
1062Reference links `[text][ref]` in code are ignored.
1063Regular reference [text][missing] should be flagged.
1064Images `![alt][img]` in code are ignored.
1065Regular image ![alt][badimg] should be flagged.
1066
1067Multiple `[one]` and `[two]` in code ignored, but [three] is not.
1068
1069```
1070[code block content] should be ignored
1071```
1072
1073`Multiple [refs] in [same] code span` ignored."#;
1074
1075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1076 let result = rule.check(&ctx).unwrap();
1077
1078 assert_eq!(result.len(), 4);
1080
1081 let messages: Vec<&str> = result.iter().map(|w| &*w.message).collect();
1082 assert!(messages.iter().any(|m| m.contains("outside")));
1083 assert!(messages.iter().any(|m| m.contains("missing")));
1084 assert!(messages.iter().any(|m| m.contains("badimg")));
1085 assert!(messages.iter().any(|m| m.contains("three")));
1086
1087 assert!(!messages.iter().any(|m| m.contains("inside")));
1089 assert!(!messages.iter().any(|m| m.contains("one")));
1090 assert!(!messages.iter().any(|m| m.contains("two")));
1091 assert!(!messages.iter().any(|m| m.contains("refs")));
1092 assert!(!messages.iter().any(|m| m.contains("same")));
1093 }
1094
1095 #[test]
1096 fn test_multiple_undefined_references() {
1097 let rule = MD052ReferenceLinkImages::new();
1098 let content = "[link1][ref1] [link2][ref2] [link3][ref3]";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101
1102 assert_eq!(result.len(), 3);
1103 assert!(result[0].message.contains("ref1"));
1104 assert!(result[1].message.contains("ref2"));
1105 assert!(result[2].message.contains("ref3"));
1106 }
1107
1108 #[test]
1109 fn test_mixed_valid_and_undefined() {
1110 let rule = MD052ReferenceLinkImages::new();
1111 let content = "[valid][ref] [invalid][missing]\n\n[ref]: https://example.com";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1113 let result = rule.check(&ctx).unwrap();
1114
1115 assert_eq!(result.len(), 1);
1116 assert!(result[0].message.contains("missing"));
1117 }
1118
1119 #[test]
1120 fn test_empty_reference() {
1121 let rule = MD052ReferenceLinkImages::new();
1122 let content = "[text][]\n\n[ref]: https://example.com";
1123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1124 let result = rule.check(&ctx).unwrap();
1125
1126 assert_eq!(result.len(), 1);
1128 }
1129
1130 #[test]
1131 fn test_escaped_brackets_ignored() {
1132 let rule = MD052ReferenceLinkImages::new();
1133 let content = "\\[not a link\\]";
1134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1135 let result = rule.check(&ctx).unwrap();
1136
1137 assert_eq!(result.len(), 0);
1138 }
1139
1140 #[test]
1141 fn test_list_items_ignored() {
1142 let rule = MD052ReferenceLinkImages::new();
1143 let content = "- [undefined]\n* [another]\n+ [third]";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145 let result = rule.check(&ctx).unwrap();
1146
1147 assert_eq!(result.len(), 0);
1149 }
1150
1151 #[test]
1152 fn test_output_example_section_ignored() {
1153 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1155 shortcut_syntax: true,
1156 ..Default::default()
1157 });
1158 let content = "## Output\n\n[undefined]\n\n## Normal Section\n\n[missing]";
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160 let result = rule.check(&ctx).unwrap();
1161
1162 assert_eq!(result.len(), 1);
1164 assert!(result[0].message.contains("missing"));
1165 }
1166
1167 #[test]
1168 fn test_reference_definitions_in_code_blocks_ignored() {
1169 let rule = MD052ReferenceLinkImages::new();
1170 let content = "[link][ref]\n\n```\n[ref]: https://example.com\n```";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let result = rule.check(&ctx).unwrap();
1173
1174 assert_eq!(result.len(), 1);
1176 assert!(result[0].message.contains("ref"));
1177 }
1178
1179 #[test]
1180 fn test_multiple_references_to_same_undefined() {
1181 let rule = MD052ReferenceLinkImages::new();
1182 let content = "[first][missing] [second][missing] [third][missing]";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184 let result = rule.check(&ctx).unwrap();
1185
1186 assert_eq!(result.len(), 1);
1188 assert!(result[0].message.contains("missing"));
1189 }
1190
1191 #[test]
1192 fn test_reference_with_special_characters() {
1193 let rule = MD052ReferenceLinkImages::new();
1194 let content = "[text][ref-with-hyphens]\n\n[ref-with-hyphens]: https://example.com";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 let result = rule.check(&ctx).unwrap();
1197
1198 assert_eq!(result.len(), 0);
1199 }
1200
1201 #[test]
1202 fn test_issue_51_html_attribute_not_reference() {
1203 let rule = MD052ReferenceLinkImages::new();
1205 let content = r#"# Example
1206
1207## Test
1208
1209Want to fill out this form?
1210
1211<form method="post">
1212 <input type="email" name="fields[email]" id="drip-email" placeholder="email@domain.com">
1213</form>"#;
1214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1215 let result = rule.check(&ctx).unwrap();
1216
1217 assert_eq!(
1218 result.len(),
1219 0,
1220 "HTML attributes with square brackets should not be flagged as undefined references"
1221 );
1222 }
1223
1224 #[test]
1225 fn test_extract_references() {
1226 let rule = MD052ReferenceLinkImages::new();
1227 let content = "[ref1]: url1\n[Ref2]: url2\n[REF3]: url3";
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229 let refs = rule.extract_references(&ctx);
1230
1231 assert_eq!(refs.len(), 3);
1232 assert!(refs.contains("ref1"));
1233 assert!(refs.contains("ref2"));
1234 assert!(refs.contains("ref3"));
1235 }
1236
1237 #[test]
1238 fn test_inline_code_not_flagged() {
1239 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1241 shortcut_syntax: true,
1242 ..Default::default()
1243 });
1244
1245 let content = r#"# Test
1247
1248Configure with `["JavaScript", "GitHub", "Node.js"]` in your settings.
1249
1250Also, `[todo]` is not a reference link.
1251
1252But this [reference] should be flagged.
1253
1254And this `[inline code]` should not be flagged.
1255"#;
1256
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let warnings = rule.check(&ctx).unwrap();
1259
1260 assert_eq!(warnings.len(), 1, "Should only flag one undefined reference");
1262 assert!(warnings[0].message.contains("'reference'"));
1263 }
1264
1265 #[test]
1266 fn test_code_block_references_ignored() {
1267 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1269 shortcut_syntax: true,
1270 ..Default::default()
1271 });
1272
1273 let content = r#"# Test
1274
1275```markdown
1276[undefined] reference in code block
1277![undefined] image in code block
1278```
1279
1280[real-undefined] reference outside
1281"#;
1282
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284 let warnings = rule.check(&ctx).unwrap();
1285
1286 assert_eq!(warnings.len(), 1);
1288 assert!(warnings[0].message.contains("'real-undefined'"));
1289 }
1290
1291 #[test]
1292 fn test_html_comments_ignored() {
1293 let rule = MD052ReferenceLinkImages::new();
1295
1296 let content = r#"<!--- write fake_editor.py 'import sys\nopen(*sys.argv[1:], mode="wt").write("2 3 4 4 2 3 2")' -->
1298<!--- set_env EDITOR 'python3 fake_editor.py' -->
1299
1300```bash
1301$ python3 vote.py
13023 votes for: 2
13032 votes for: 3, 4
1304```"#;
1305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306 let result = rule.check(&ctx).unwrap();
1307 assert_eq!(result.len(), 0, "Should not flag [1:] inside HTML comments");
1308
1309 let content = r#"<!-- This is [ref1] and [ref2][ref3] -->
1311Normal [text][undefined]
1312<!-- Another [comment][with] references -->"#;
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314 let result = rule.check(&ctx).unwrap();
1315 assert_eq!(
1316 result.len(),
1317 1,
1318 "Should only flag the undefined reference outside comments"
1319 );
1320 assert!(result[0].message.contains("undefined"));
1321
1322 let content = r#"<!--
1324[ref1]
1325[ref2][ref3]
1326-->
1327[actual][undefined]"#;
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329 let result = rule.check(&ctx).unwrap();
1330 assert_eq!(
1331 result.len(),
1332 1,
1333 "Should not flag references in multi-line HTML comments"
1334 );
1335 assert!(result[0].message.contains("undefined"));
1336
1337 let content = r#"<!-- Comment with [1:] pattern -->
1339Valid [link][ref]
1340<!-- More [refs][in][comments] -->
1341![image][missing]
1342
1343[ref]: https://example.com"#;
1344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1345 let result = rule.check(&ctx).unwrap();
1346 assert_eq!(result.len(), 1, "Should only flag missing image reference");
1347 assert!(result[0].message.contains("missing"));
1348 }
1349
1350 #[test]
1351 fn test_frontmatter_ignored() {
1352 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1355 shortcut_syntax: true,
1356 ..Default::default()
1357 });
1358
1359 let content = r#"---
1361layout: post
1362title: "My Jekyll Post"
1363date: 2023-01-01
1364categories: blog
1365tags: ["test", "example"]
1366author: John Doe
1367---
1368
1369# My Blog Post
1370
1371This is the actual markdown content that should be linted.
1372
1373[undefined] reference should be flagged.
1374
1375## Section 1
1376
1377Some content here."#;
1378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1379 let result = rule.check(&ctx).unwrap();
1380
1381 assert_eq!(
1383 result.len(),
1384 1,
1385 "Should only flag the undefined reference outside frontmatter"
1386 );
1387 assert!(result[0].message.contains("undefined"));
1388
1389 let content = r#"+++
1391title = "My Post"
1392tags = ["example", "test"]
1393+++
1394
1395# Content
1396
1397[missing] reference should be flagged."#;
1398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1399 let result = rule.check(&ctx).unwrap();
1400 assert_eq!(
1401 result.len(),
1402 1,
1403 "Should only flag the undefined reference outside TOML frontmatter"
1404 );
1405 assert!(result[0].message.contains("missing"));
1406 }
1407
1408 #[test]
1409 fn test_mkdocs_snippet_markers_not_flagged() {
1410 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1413 shortcut_syntax: true,
1414 ..Default::default()
1415 });
1416
1417 let content = r#"# Document with MkDocs Snippets
1419
1420Some content here.
1421
1422# -8<- [start:remote-content]
1423
1424This is the remote content section.
1425
1426# -8<- [end:remote-content]
1427
1428More content here.
1429
1430<!-- --8<-- [start:another-section] -->
1431Content in another section
1432<!-- --8<-- [end:another-section] -->"#;
1433 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1434 let result = rule.check(&ctx).unwrap();
1435
1436 assert_eq!(
1438 result.len(),
1439 0,
1440 "Should not flag MkDocs snippet markers as undefined references"
1441 );
1442
1443 let content = r#"# Document
1446
1447# -8<- [start:section]
1448Content with [reference] inside snippet section
1449# -8<- [end:section]
1450
1451Regular [undefined] reference outside snippet markers."#;
1452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1453 let result = rule.check(&ctx).unwrap();
1454
1455 assert_eq!(
1456 result.len(),
1457 2,
1458 "Should flag undefined references but skip snippet marker lines"
1459 );
1460 assert!(result[0].message.contains("reference"));
1462 assert!(result[1].message.contains("undefined"));
1463
1464 let content = r#"# Document
1466
1467# -8<- [start:section]
1468# -8<- [end:section]"#;
1469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1470 let result = rule.check(&ctx).unwrap();
1471
1472 assert_eq!(
1473 result.len(),
1474 2,
1475 "In standard mode, snippet markers should be flagged as undefined references"
1476 );
1477 }
1478
1479 #[test]
1480 fn test_pandoc_citations_not_flagged() {
1481 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1484 shortcut_syntax: true,
1485 ..Default::default()
1486 });
1487
1488 let content = r#"# Research Paper
1489
1490We are using the **bookdown** package [@R-bookdown] in this sample book.
1491This was built on top of R Markdown and **knitr** [@xie2015].
1492
1493Multiple citations [@citation1; @citation2; @citation3] are also supported.
1494
1495Regular [undefined] reference should still be flagged.
1496"#;
1497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498 let result = rule.check(&ctx).unwrap();
1499
1500 assert_eq!(
1502 result.len(),
1503 1,
1504 "Should only flag the undefined reference, not Pandoc citations"
1505 );
1506 assert!(result[0].message.contains("undefined"));
1507 }
1508
1509 #[test]
1510 fn test_pandoc_inline_footnotes_not_flagged() {
1511 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1514 shortcut_syntax: true,
1515 ..Default::default()
1516 });
1517
1518 let content = r#"# Math Document
1519
1520You can use math in footnotes like this^[where we mention $p = \frac{a}{b}$].
1521
1522Another footnote^[with some text and a [link](https://example.com)].
1523
1524But this [reference] without ^ should be flagged.
1525"#;
1526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1527 let result = rule.check(&ctx).unwrap();
1528
1529 assert_eq!(
1531 result.len(),
1532 1,
1533 "Should only flag the regular reference, not inline footnotes"
1534 );
1535 assert!(result[0].message.contains("reference"));
1536 }
1537
1538 #[test]
1539 fn test_github_alerts_not_flagged() {
1540 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1543 shortcut_syntax: true,
1544 ..Default::default()
1545 });
1546
1547 let content = r#"# Document with GitHub Alerts
1549
1550> [!NOTE]
1551> This is a note alert.
1552
1553> [!TIP]
1554> This is a tip alert.
1555
1556> [!IMPORTANT]
1557> This is an important alert.
1558
1559> [!WARNING]
1560> This is a warning alert.
1561
1562> [!CAUTION]
1563> This is a caution alert.
1564
1565Regular content with [undefined] reference."#;
1566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1567 let result = rule.check(&ctx).unwrap();
1568
1569 assert_eq!(
1571 result.len(),
1572 1,
1573 "Should only flag the undefined reference, not GitHub alerts"
1574 );
1575 assert!(result[0].message.contains("undefined"));
1576 assert_eq!(result[0].line, 18); let content = r#"> [!TIP]
1580> Here's a useful tip about [something].
1581> Multiple lines are allowed.
1582
1583[something] is mentioned but not defined."#;
1584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585 let result = rule.check(&ctx).unwrap();
1586
1587 assert_eq!(result.len(), 1, "Should flag undefined reference");
1591 assert!(result[0].message.contains("something"));
1592
1593 let content = r#"> [!NOTE]
1595> See [reference] for more details.
1596
1597[reference]: https://example.com"#;
1598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1599 let result = rule.check(&ctx).unwrap();
1600
1601 assert_eq!(result.len(), 0, "Should not flag GitHub alerts or defined references");
1603 }
1604
1605 #[test]
1606 fn test_ignore_config() {
1607 let config = MD052Config {
1609 shortcut_syntax: true,
1610 ignore: vec!["Vec".to_string(), "HashMap".to_string(), "Option".to_string()],
1611 };
1612 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1613
1614 let content = r#"# Document with Custom Types
1615
1616Use [Vec] for dynamic arrays.
1617Use [HashMap] for key-value storage.
1618Use [Option] for nullable values.
1619Use [Result] for error handling.
1620"#;
1621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622 let result = rule.check(&ctx).unwrap();
1623
1624 assert_eq!(result.len(), 1, "Should only flag names not in ignore");
1626 assert!(result[0].message.contains("Result"));
1627 }
1628
1629 #[test]
1630 fn test_ignore_case_insensitive() {
1631 let config = MD052Config {
1633 shortcut_syntax: true,
1634 ignore: vec!["Vec".to_string()],
1635 };
1636 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1637
1638 let content = r#"# Case Insensitivity Test
1639
1640[Vec] should be ignored.
1641[vec] should also be ignored (different case, same match).
1642[VEC] should also be ignored (different case, same match).
1643[undefined] should be flagged (not in ignore list).
1644"#;
1645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1646 let result = rule.check(&ctx).unwrap();
1647
1648 assert_eq!(result.len(), 1, "Should only flag non-ignored reference");
1650 assert!(result[0].message.contains("undefined"));
1651 }
1652
1653 #[test]
1654 fn test_ignore_empty_by_default() {
1655 let rule = MD052ReferenceLinkImages::new();
1657
1658 let content = "[text][undefined]";
1659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1660 let result = rule.check(&ctx).unwrap();
1661
1662 assert_eq!(result.len(), 1);
1664 assert!(result[0].message.contains("undefined"));
1665 }
1666
1667 #[test]
1668 fn test_ignore_with_reference_links() {
1669 let config = MD052Config {
1671 shortcut_syntax: false,
1672 ignore: vec!["CustomType".to_string()],
1673 };
1674 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1675
1676 let content = r#"# Test
1677
1678See [documentation][CustomType] for details.
1679See [other docs][MissingRef] for more.
1680"#;
1681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1682 let result = rule.check(&ctx).unwrap();
1683
1684 for (i, w) in result.iter().enumerate() {
1686 eprintln!("Warning {}: {}", i, w.message);
1687 }
1688
1689 assert_eq!(result.len(), 1, "Expected 1 warning, got {}", result.len());
1692 assert!(
1693 result[0].message.contains("missingref"),
1694 "Expected 'missingref' in message: {}",
1695 result[0].message
1696 );
1697 }
1698
1699 #[test]
1700 fn test_ignore_multiple() {
1701 let config = MD052Config {
1703 shortcut_syntax: true,
1704 ignore: vec![
1705 "i32".to_string(),
1706 "u64".to_string(),
1707 "String".to_string(),
1708 "Arc".to_string(),
1709 "Mutex".to_string(),
1710 ],
1711 };
1712 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1713
1714 let content = r#"# Types
1715
1716[i32] [u64] [String] [Arc] [Mutex] [Box]
1717"#;
1718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1719 let result = rule.check(&ctx).unwrap();
1720
1721 assert_eq!(result.len(), 1);
1725 assert!(result[0].message.contains("Box"));
1726 }
1727
1728 #[test]
1729 fn test_nested_code_fences_reference_extraction() {
1730 let rule = MD052ReferenceLinkImages::new();
1735
1736 let content = "````\n```\n[ref-inside]: https://example.com\n```\n````\n\n[Use this link][ref-inside]";
1737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1738 let result = rule.check(&ctx).unwrap();
1739
1740 assert_eq!(
1744 result.len(),
1745 1,
1746 "Reference defined inside nested code fence should not count as a definition"
1747 );
1748 assert!(result[0].message.contains("ref-inside"));
1749 }
1750
1751 #[test]
1752 fn test_pandoc_flavor_skips_citations() {
1753 let rule = MD052ReferenceLinkImages::new();
1756 let content = "See [@smith2020] for details.\n";
1757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1758 let result = rule.check(&ctx).unwrap();
1759 assert!(
1760 result.is_empty(),
1761 "MD052 should skip Pandoc citations under Pandoc flavor: {result:?}"
1762 );
1763 }
1764
1765 #[test]
1766 fn md052_pandoc_skips_implicit_header_refs_with_shortcut_syntax() {
1767 use crate::config::MarkdownFlavor;
1774 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1775 shortcut_syntax: true,
1776 ..Default::default()
1777 });
1778 let content = "# My Section\n\nSee [My Section] for details.\n";
1779
1780 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1783 let std_result = rule.check(&ctx_std).unwrap();
1784 assert_eq!(
1785 std_result.len(),
1786 1,
1787 "Standard flavor with shortcut_syntax should flag [My Section]: {std_result:?}"
1788 );
1789
1790 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1792 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1793 assert!(
1794 pandoc_result.is_empty(),
1795 "Pandoc flavor should accept [My Section] as an implicit header ref: {pandoc_result:?}"
1796 );
1797 }
1798}