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