1use crate::lint_context::HeadingContainer;
2use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::mkdocs_patterns::is_mkdocs_auto_reference;
4use crate::utils::range_utils::calculate_match_range;
5use crate::utils::regex_cache::SHORTCUT_REF_REGEX;
6use crate::utils::skip_context::is_in_math_context;
7use pulldown_cmark::LinkType;
8use regex::Regex;
9use std::collections::{HashMap, HashSet};
10use std::sync::LazyLock;
11
12mod md052_config;
13use md052_config::MD052Config;
14
15static REF_REGEX: LazyLock<Regex> =
19 LazyLock::new(|| Regex::new(r"^\s*\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]:\s*.*").unwrap());
20
21static LIST_ITEM_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*[-*+]\s+(?:\[[xX\s]\]\s+)?").unwrap());
23
24const OUTPUT_EXAMPLE_TITLES: [&str; 4] = ["Output", "Example", "Output Style", "Output Format"];
26
27static GITHUB_ALERT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
30 Regex::new(r"^\s*>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|INFO|SUCCESS|FAILURE|DANGER|BUG|EXAMPLE|QUOTE)\]")
31 .unwrap()
32});
33
34static URL_WITH_BRACKETS: LazyLock<Regex> =
42 LazyLock::new(|| Regex::new(r"https?://(?:\[[0-9a-fA-F:.%]+\]|[^\s\[\]]+/[^\s]*\[\d+\])").unwrap());
43
44#[derive(Clone, Default)]
57pub struct MD052ReferenceLinkImages {
58 config: MD052Config,
59}
60
61impl MD052ReferenceLinkImages {
62 pub fn new() -> Self {
63 Self {
64 config: MD052Config::default(),
65 }
66 }
67
68 pub fn from_config_struct(config: MD052Config) -> Self {
69 Self { config }
70 }
71
72 fn strip_backticks(s: &str) -> &str {
75 s.trim_start_matches('`').trim_end_matches('`')
76 }
77
78 fn is_valid_python_identifier(s: &str) -> bool {
82 if s.is_empty() {
83 return false;
84 }
85 let first_char = s.chars().next().unwrap();
86 if !first_char.is_ascii_alphabetic() && first_char != '_' {
87 return false;
88 }
89 s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
90 }
91
92 fn is_known_non_reference_pattern(&self, text: &str) -> bool {
101 if self.config.ignore.iter().any(|p| p.eq_ignore_ascii_case(text)) {
105 return true;
106 }
107 if text.chars().all(|c| c.is_ascii_digit()) {
109 return true;
110 }
111
112 if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
114 return true;
115 }
116
117 if text.contains('.')
121 && !text.contains(' ')
122 && !text.contains('-')
123 && !text.contains('_')
124 && !text.contains('`')
125 {
126 return true;
128 }
129
130 if text == "*" || text == "..." || text == "**" {
132 return true;
133 }
134
135 if text.contains('/') && !text.contains(' ') && !text.starts_with("http") {
137 return true;
138 }
139
140 if text.contains(',') || text.contains('[') || text.contains(']') {
143 return true;
145 }
146
147 if !text.contains('`')
154 && text.contains('.')
155 && !text.contains(' ')
156 && !text.contains('-')
157 && !text.contains('_')
158 {
159 return true;
160 }
161
162 if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
169 return true;
170 }
171
172 if text.len() <= 2 && !text.chars().all(char::is_alphabetic) {
174 return true;
175 }
176
177 if (text.starts_with('"') && text.ends_with('"'))
179 || (text.starts_with('\'') && text.ends_with('\''))
180 || text.contains('"')
181 || text.contains('\'')
182 {
183 return true;
184 }
185
186 if text.contains(':') && text.contains(' ') {
189 return true;
190 }
191
192 if text.starts_with('!') {
194 return true;
195 }
196
197 if text.starts_with('^') {
200 return true;
201 }
202
203 if text.starts_with('@') {
206 return true;
207 }
208
209 if text == "TOC" {
212 return true;
213 }
214
215 if text.len() == 1 && text.chars().all(|c| c.is_ascii_uppercase()) {
217 return true;
218 }
219
220 let common_non_refs = [
223 "object",
225 "Object",
226 "any",
227 "Any",
228 "inv",
229 "void",
230 "bool",
231 "int",
232 "float",
233 "str",
234 "char",
235 "i8",
236 "i16",
237 "i32",
238 "i64",
239 "i128",
240 "isize",
241 "u8",
242 "u16",
243 "u32",
244 "u64",
245 "u128",
246 "usize",
247 "f32",
248 "f64",
249 "null",
251 "true",
252 "false",
253 "NaN",
254 "Infinity",
255 "object Object",
257 ];
258
259 if common_non_refs.contains(&text) {
260 return true;
261 }
262
263 false
264 }
265
266 fn is_in_html_tag(html_tags: &[crate::lint_context::HtmlTag], byte_pos: usize) -> bool {
268 let idx = html_tags.partition_point(|tag| tag.byte_offset <= byte_pos);
269 idx > 0 && byte_pos < html_tags[idx - 1].byte_end
270 }
271
272 fn extract_references(&self, ctx: &crate::lint_context::LintContext) -> HashSet<String> {
273 use crate::utils::skip_context::is_mkdocs_snippet_line;
274
275 let mut references = HashSet::new();
276
277 for (line_num, line) in ctx.content.lines().enumerate() {
278 if let Some(line_info) = ctx.line_info(line_num + 1)
280 && line_info.in_code_block
281 {
282 continue;
283 }
284
285 if is_mkdocs_snippet_line(line, ctx.flavor) {
287 continue;
288 }
289
290 if line.trim_start().starts_with("*[") {
293 continue;
294 }
295
296 if let Some(cap) = REF_REGEX.captures(line) {
297 if let Some(reference) = cap.get(1) {
299 references.insert(reference.as_str().to_lowercase());
300 }
301 }
302 }
303
304 for def in ctx.reference_definitions() {
308 references.insert(def.id.clone());
309 }
310
311 references
312 }
313
314 fn compute_example_sections(ctx: &crate::lint_context::LintContext) -> HashSet<usize> {
323 let mut sections = HashSet::new();
324 let mut open_from: Option<usize> = None;
326 for parsed in ctx.headings() {
327 if ctx.heading_container(parsed.line_num) != HeadingContainer::Outside {
328 continue;
329 }
330 let first_line = parsed.first_line_num();
331 if let Some(from) = open_from.take() {
332 sections.extend(from..first_line);
333 }
334 if OUTPUT_EXAMPLE_TITLES.contains(&parsed.heading.text.as_str()) {
335 open_from = Some(first_line);
336 }
337 }
338 if let Some(from) = open_from {
339 sections.extend(from..=ctx.lines.len());
340 }
341 sections
342 }
343
344 fn find_undefined_references(
345 &self,
346 references: &HashSet<String>,
347 ctx: &crate::lint_context::LintContext,
348 mkdocs_mode: bool,
349 ) -> Vec<(usize, usize, usize, String)> {
350 let mut undefined = Vec::new();
351 let mut reported_refs = HashMap::new();
352
353 let example_sections = Self::compute_example_sections(ctx);
354
355 let html_tags = ctx.html_tags();
357
358 let mut all_links: Vec<&crate::lint_context::ParsedLink> =
361 ctx.links().iter().chain(ctx.unresolved_links()).collect();
362 all_links.sort_by_key(|link| link.byte_offset);
363 let mut all_images: Vec<&crate::lint_context::ParsedImage> =
364 ctx.images().iter().chain(ctx.unresolved_images()).collect();
365 all_images.sort_by_key(|image| image.byte_offset);
366
367 for link in all_links {
369 if !link.is_reference {
370 continue; }
372
373 if !self.config.shortcut_syntax && matches!(link.link_type, LinkType::Shortcut | LinkType::ShortcutUnknown)
375 {
376 continue;
377 }
378
379 if link.byte_offset > 0 && ctx.content.as_bytes().get(link.byte_offset - 1) == Some(&b'^') {
381 continue;
382 }
383
384 if ctx.is_in_jinja_range(link.byte_offset) {
386 continue;
387 }
388
389 if ctx.is_byte_offset_in_code_span(link.byte_offset) {
391 continue;
392 }
393
394 if ctx.is_in_html_comment(link.byte_offset) || ctx.is_in_mdx_comment(link.byte_offset) {
396 continue;
397 }
398
399 if Self::is_in_html_tag(&html_tags, link.byte_offset) {
401 continue;
402 }
403
404 if is_in_math_context(ctx, link.byte_offset) {
406 continue;
407 }
408
409 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
411 continue;
412 }
413
414 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
417 continue;
418 }
419
420 if ctx.is_in_shortcode(link.byte_offset) {
423 continue;
424 }
425
426 if let Some(ref_id) = &link.reference_id {
427 let reference_lower = ref_id.to_lowercase();
428
429 if ctx.flavor.is_pandoc_compatible() && ctx.matches_implicit_header_reference(ref_id) {
431 continue;
432 }
433
434 if self.is_known_non_reference_pattern(ref_id) {
436 continue;
437 }
438
439 let stripped_ref = Self::strip_backticks(ref_id);
443 let stripped_text = Self::strip_backticks(&link.text);
444 if mkdocs_mode
445 && (is_mkdocs_auto_reference(stripped_ref)
446 || is_mkdocs_auto_reference(stripped_text)
447 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
448 || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text)))
449 {
450 continue;
451 }
452
453 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
455 if example_sections.contains(&link.line) {
456 continue;
457 }
458
459 if let Some(line_info) = ctx.line_info(link.line) {
460 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
462 continue;
463 }
464
465 let trimmed = line_info.content(ctx.content).trim_start();
467 if trimmed.starts_with('<') {
468 continue;
469 }
470 }
471
472 let match_len = link.byte_end - link.byte_offset;
473 let line_start = ctx.line_start_byte(link.line).unwrap_or(0);
476 undefined.push((
477 link.line - 1,
478 link.byte_offset - line_start,
479 match_len,
480 original_case_label(&link.text, &reference_lower),
481 ));
482 reported_refs.insert(reference_lower, true);
483 }
484 }
485 }
486
487 for image in all_images {
489 if !image.is_reference {
490 continue; }
492
493 if !self.config.shortcut_syntax && matches!(image.link_type, LinkType::Shortcut | LinkType::ShortcutUnknown)
495 {
496 continue;
497 }
498
499 if ctx.is_in_jinja_range(image.byte_offset) {
501 continue;
502 }
503
504 if ctx.is_byte_offset_in_code_span(image.byte_offset) {
506 continue;
507 }
508
509 if ctx.is_in_html_comment(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
511 continue;
512 }
513
514 if Self::is_in_html_tag(&html_tags, image.byte_offset) {
516 continue;
517 }
518
519 if is_in_math_context(ctx, image.byte_offset) {
521 continue;
522 }
523
524 if ctx.line_info(image.line).is_some_and(|info| info.in_front_matter) {
526 continue;
527 }
528
529 if let Some(ref_id) = &image.reference_id {
530 let reference_lower = ref_id.to_lowercase();
531
532 if self.is_known_non_reference_pattern(ref_id) {
534 continue;
535 }
536
537 let stripped_ref = Self::strip_backticks(ref_id);
541 let stripped_alt = Self::strip_backticks(&image.alt_text);
542 if mkdocs_mode
543 && (is_mkdocs_auto_reference(stripped_ref)
544 || is_mkdocs_auto_reference(stripped_alt)
545 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
546 || (image.alt_text.as_ref() != stripped_alt && Self::is_valid_python_identifier(stripped_alt)))
547 {
548 continue;
549 }
550
551 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
553 if example_sections.contains(&image.line) {
554 continue;
555 }
556
557 if let Some(line_info) = ctx.line_info(image.line) {
558 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
560 continue;
561 }
562
563 let trimmed = line_info.content(ctx.content).trim_start();
565 if trimmed.starts_with('<') {
566 continue;
567 }
568 }
569
570 let match_len = image.byte_end - image.byte_offset;
571 let line_start = ctx.line_start_byte(image.line).unwrap_or(0);
574 undefined.push((
575 image.line - 1,
576 image.byte_offset - line_start,
577 match_len,
578 original_case_label(&image.alt_text, &reference_lower),
579 ));
580 reported_refs.insert(reference_lower, true);
581 }
582 }
583 }
584
585 let mut covered_ranges: Vec<(usize, usize)> = Vec::new();
587
588 for link in ctx.links().iter().chain(ctx.unresolved_links()) {
590 covered_ranges.push((link.byte_offset, link.byte_end));
591 }
592
593 for image in ctx.images().iter().chain(ctx.unresolved_images()) {
595 covered_ranges.push((image.byte_offset, image.byte_end));
596 }
597
598 covered_ranges.sort_by_key(|&(start, _)| start);
600
601 if !self.config.shortcut_syntax {
606 undefined.sort_by_key(|&(line, col, _, _)| (line, col));
607 return undefined;
608 }
609
610 let lines = ctx.raw_lines();
612 for (line_num, line) in lines.iter().enumerate() {
613 if let Some(line_info) = ctx.line_info(line_num + 1)
615 && (line_info.in_front_matter || line_info.in_code_block)
616 {
617 continue;
618 }
619
620 if example_sections.contains(&(line_num + 1)) {
621 continue;
622 }
623
624 if LIST_ITEM_REGEX.is_match(line) {
626 continue;
627 }
628
629 let trimmed_line = line.trim_start();
631 if trimmed_line.starts_with('<') {
632 continue;
633 }
634
635 if GITHUB_ALERT_REGEX.is_match(line) {
637 continue;
638 }
639
640 if trimmed_line.starts_with("*[") {
643 continue;
644 }
645
646 let mut url_bracket_ranges: Vec<(usize, usize)> = Vec::new();
649 for mat in URL_WITH_BRACKETS.find_iter(line) {
650 let url_str = mat.as_str();
652 let url_start = mat.start();
653
654 let mut idx = 0;
656 while idx < url_str.len() {
657 if let Some(bracket_start) = url_str[idx..].find('[') {
658 let bracket_start_abs = url_start + idx + bracket_start;
659 if let Some(bracket_end) = url_str[idx + bracket_start + 1..].find(']') {
660 let bracket_end_abs = url_start + idx + bracket_start + 1 + bracket_end + 1;
661 url_bracket_ranges.push((bracket_start_abs, bracket_end_abs));
662 idx += bracket_start + bracket_end + 2;
663 } else {
664 break;
665 }
666 } else {
667 break;
668 }
669 }
670 }
671
672 if let Ok(captures) = SHORTCUT_REF_REGEX.captures_iter(line).collect::<Result<Vec<_>, _>>() {
674 for cap in captures {
675 if let Some(ref_match) = cap.get(1) {
676 let bracket_start = cap.get(0).unwrap().start();
678 let bracket_end = cap.get(0).unwrap().end();
679
680 let is_in_url = url_bracket_ranges
682 .iter()
683 .any(|&(url_start, url_end)| bracket_start >= url_start && bracket_end <= url_end);
684
685 if is_in_url {
686 continue;
687 }
688
689 if bracket_start > 0 {
692 if let Some(byte) = line.as_bytes().get(bracket_start.saturating_sub(1))
694 && *byte == b'^'
695 {
696 continue; }
698 }
699
700 let reference = ref_match.as_str();
701 let reference_lower = reference.to_lowercase();
702
703 if self.is_known_non_reference_pattern(reference) {
705 continue;
706 }
707
708 if let Some(alert_type) = reference.strip_prefix('!')
710 && matches!(
711 alert_type,
712 "NOTE"
713 | "TIP"
714 | "WARNING"
715 | "IMPORTANT"
716 | "CAUTION"
717 | "INFO"
718 | "SUCCESS"
719 | "FAILURE"
720 | "DANGER"
721 | "BUG"
722 | "EXAMPLE"
723 | "QUOTE"
724 )
725 {
726 continue;
727 }
728
729 if mkdocs_mode
732 && (reference.starts_with("start:") || reference.starts_with("end:"))
733 && (crate::utils::mkdocs_snippets::is_snippet_section_start(line)
734 || crate::utils::mkdocs_snippets::is_snippet_section_end(line))
735 {
736 continue;
737 }
738
739 let stripped_ref = Self::strip_backticks(reference);
742 if mkdocs_mode
743 && (is_mkdocs_auto_reference(stripped_ref)
744 || (reference != stripped_ref && Self::is_valid_python_identifier(stripped_ref)))
745 {
746 continue;
747 }
748
749 if ctx.flavor.is_pandoc_compatible() && ctx.matches_implicit_header_reference(reference) {
753 continue;
754 }
755
756 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
757 let full_match = cap.get(0).unwrap();
758 let col = full_match.start();
759 let line_start_byte = ctx.line_offsets[line_num];
760 let byte_pos = line_start_byte + col;
761
762 if ctx.is_byte_offset_in_code_span(byte_pos) {
764 continue;
765 }
766
767 if ctx.is_in_jinja_range(byte_pos) {
769 continue;
770 }
771
772 if crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block(
774 &ctx.code_blocks,
775 byte_pos,
776 ) {
777 continue;
778 }
779
780 if ctx.is_in_html_comment(byte_pos) || ctx.is_in_mdx_comment(byte_pos) {
782 continue;
783 }
784
785 if Self::is_in_html_tag(&html_tags, byte_pos) {
787 continue;
788 }
789
790 if is_in_math_context(ctx, byte_pos) {
792 continue;
793 }
794
795 let byte_end = byte_pos + (full_match.end() - full_match.start());
796
797 let mut is_covered = false;
799 for &(range_start, range_end) in &covered_ranges {
800 if range_start <= byte_pos && byte_end <= range_end {
801 is_covered = true;
803 break;
804 }
805 if range_start > byte_end {
806 break;
808 }
809 }
810
811 if is_covered {
812 continue;
813 }
814
815 let line_chars: Vec<char> = line.chars().collect();
820 if col > 0 && col <= line_chars.len() && line_chars.get(col - 1) == Some(&']') {
821 let mut bracket_count = 1; let mut check_pos = col.saturating_sub(2);
824 let mut found_opening = false;
825
826 while check_pos > 0 && check_pos < line_chars.len() {
827 match line_chars.get(check_pos) {
828 Some(&']') => bracket_count += 1,
829 Some(&'[') => {
830 bracket_count -= 1;
831 if bracket_count == 0 {
832 if check_pos == 0 || line_chars.get(check_pos - 1) != Some(&'\\') {
834 found_opening = true;
835 }
836 break;
837 }
838 }
839 _ => {}
840 }
841 if check_pos == 0 {
842 break;
843 }
844 check_pos = check_pos.saturating_sub(1);
845 }
846
847 if found_opening {
848 continue;
850 }
851 }
852
853 let before_text = &line[..col];
856 if before_text.contains("\\]") {
857 if let Some(escaped_close_pos) = before_text.rfind("\\]") {
859 let search_text = &before_text[..escaped_close_pos];
860 if search_text.contains("\\[") {
861 continue;
863 }
864 }
865 }
866
867 let match_len = full_match.end() - full_match.start();
868 undefined.push((line_num, col, match_len, reference.to_string()));
869 reported_refs.insert(reference_lower, true);
870 }
871 }
872 }
873 }
874 }
875
876 undefined.sort_by_key(|&(line, col, _, _)| (line, col));
879 undefined
880 }
881}
882
883fn original_case_label(text: &str, reference_lower: &str) -> String {
889 if !text.is_empty() && text.to_lowercase() == reference_lower {
890 text.to_string()
891 } else {
892 reference_lower.to_string()
893 }
894}
895
896impl Rule for MD052ReferenceLinkImages {
897 fn name(&self) -> &'static str {
898 "MD052"
899 }
900
901 fn description(&self) -> &'static str {
902 "Reference links and images should use a reference that exists"
903 }
904
905 fn category(&self) -> RuleCategory {
906 RuleCategory::Link
907 }
908
909 fn fix_capability(&self) -> FixCapability {
910 FixCapability::Unfixable
911 }
912
913 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
914 let mut warnings = Vec::new();
915
916 if !ctx.has_char('[') {
918 return Ok(warnings);
919 }
920
921 let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
923
924 let references = self.extract_references(ctx);
925
926 let lines = ctx.raw_lines();
928 for (line_num, col, match_len, reference) in self.find_undefined_references(&references, ctx, mkdocs_mode) {
929 let line_content = lines.get(line_num).unwrap_or(&"");
930
931 let (start_line, start_col, end_line, end_col) =
933 calculate_match_range(line_num + 1, line_content, col, match_len);
934
935 warnings.push(LintWarning {
936 rule_name: Some(self.name().to_string()),
937 line: start_line,
938 column: start_col,
939 end_line,
940 end_column: end_col,
941 message: format!("Reference '{reference}' not found"),
942 severity: Severity::Warning,
943 fix: None,
944 });
945 }
946
947 Ok(warnings)
948 }
949
950 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
952 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
954 }
955
956 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
957 let content = ctx.content;
958 Ok(content.to_string())
960 }
961
962 fn as_any(&self) -> &dyn std::any::Any {
963 self
964 }
965
966 crate::impl_rule_config_methods!(MD052Config);
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972 use crate::lint_context::LintContext;
973
974 #[test]
975 fn test_valid_reference_link() {
976 let rule = MD052ReferenceLinkImages::new();
977 let content = "[text][ref]\n\n[ref]: https://example.com";
978 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979 let result = rule.check(&ctx).unwrap();
980
981 assert_eq!(result.len(), 0);
982 }
983
984 #[test]
985 fn test_undefined_reference_link() {
986 let rule = MD052ReferenceLinkImages::new();
987 let content = "[text][undefined]";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989 let result = rule.check(&ctx).unwrap();
990
991 assert_eq!(result.len(), 1);
992 assert!(result[0].message.contains("Reference 'undefined' not found"));
993 }
994
995 #[test]
996 fn test_undefined_reference_column_non_ascii_prefix() {
997 let rule = MD052ReferenceLinkImages::new();
1001 let content = "你好[text][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(), 1);
1007 assert_eq!(
1008 result[0].column, 3,
1009 "Column must be a character offset, not a byte offset"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_valid_reference_image() {
1015 let rule = MD052ReferenceLinkImages::new();
1016 let content = "![alt][img]\n\n[img]: image.jpg";
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_undefined_reference_image() {
1025 let rule = MD052ReferenceLinkImages::new();
1026 let content = "![alt][missing]";
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028 let result = rule.check(&ctx).unwrap();
1029
1030 assert_eq!(result.len(), 1);
1031 assert!(result[0].message.contains("Reference 'missing' not found"));
1032 }
1033
1034 #[test]
1035 fn test_case_insensitive_references() {
1036 let rule = MD052ReferenceLinkImages::new();
1037 let content = "[Text][REF]\n\n[ref]: https://example.com";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 let result = rule.check(&ctx).unwrap();
1040
1041 assert_eq!(result.len(), 0);
1042 }
1043
1044 #[test]
1045 fn test_shortcut_reference_valid() {
1046 let rule = MD052ReferenceLinkImages::new();
1047 let content = "[ref]\n\n[ref]: https://example.com";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049 let result = rule.check(&ctx).unwrap();
1050
1051 assert_eq!(result.len(), 0);
1052 }
1053
1054 #[test]
1055 fn test_shortcut_reference_undefined_with_shortcut_syntax_enabled() {
1056 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1059 shortcut_syntax: true,
1060 ..Default::default()
1061 });
1062 let content = "[undefined]";
1063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1064 let result = rule.check(&ctx).unwrap();
1065
1066 assert_eq!(result.len(), 1);
1067 assert!(result[0].message.contains("Reference 'undefined' not found"));
1068 }
1069
1070 #[test]
1071 fn test_shortcut_image_reference_checked_with_shortcut_syntax_enabled() {
1072 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1073 shortcut_syntax: true,
1074 ..Default::default()
1075 });
1076 let content = "![alt]\n\n";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078 let result = rule.check(&ctx).unwrap();
1079
1080 assert_eq!(result.len(), 2, "got {result:?}");
1081 assert!(result[0].message.contains("Reference 'alt' not found"));
1082 assert!(result[1].message.contains("Reference 'alt2' not found"));
1083 }
1084
1085 #[test]
1086 fn test_shortcut_reference_in_table_cell_with_shortcut_syntax_enabled() {
1087 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1090 shortcut_syntax: true,
1091 ..Default::default()
1092 });
1093 let content = "| A | B |\n| --- | --- |\n| [undefined] | [defined] |\n\n[defined]: https://example.com\n";
1094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1095 let result = rule.check(&ctx).unwrap();
1096
1097 assert_eq!(result.len(), 1, "{result:?}");
1098 assert_eq!(result[0].line, 3);
1099 assert!(result[0].message.contains("Reference 'undefined' not found"));
1100 }
1101
1102 #[test]
1103 fn test_shortcut_reference_not_checked_by_default() {
1104 let rule = MD052ReferenceLinkImages::new();
1106 let content = "[undefined]";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108 let result = rule.check(&ctx).unwrap();
1109
1110 assert_eq!(result.len(), 0);
1112 }
1113
1114 #[test]
1115 fn test_inline_links_ignored() {
1116 let rule = MD052ReferenceLinkImages::new();
1117 let content = "[text](https://example.com)";
1118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119 let result = rule.check(&ctx).unwrap();
1120
1121 assert_eq!(result.len(), 0);
1122 }
1123
1124 #[test]
1125 fn test_inline_images_ignored() {
1126 let rule = MD052ReferenceLinkImages::new();
1127 let content = "";
1128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1129 let result = rule.check(&ctx).unwrap();
1130
1131 assert_eq!(result.len(), 0);
1132 }
1133
1134 #[test]
1135 fn test_references_in_code_blocks_ignored() {
1136 let rule = MD052ReferenceLinkImages::new();
1137 let content = "```\n[undefined]\n```\n\n[ref]: https://example.com";
1138 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1139 let result = rule.check(&ctx).unwrap();
1140
1141 assert_eq!(result.len(), 0);
1142 }
1143
1144 #[test]
1145 fn test_references_in_inline_code_ignored() {
1146 let rule = MD052ReferenceLinkImages::new();
1147 let content = "`[undefined]`";
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149 let result = rule.check(&ctx).unwrap();
1150
1151 assert_eq!(result.len(), 0);
1153 }
1154
1155 #[test]
1156 fn test_comprehensive_inline_code_detection() {
1157 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1159 shortcut_syntax: true,
1160 ..Default::default()
1161 });
1162 let content = r#"# Test
1163
1164This `[inside]` should be ignored.
1165This [outside] should be flagged.
1166Reference links `[text][ref]` in code are ignored.
1167Regular reference [text][missing] should be flagged.
1168Images `![alt][img]` in code are ignored.
1169Regular image ![alt][badimg] should be flagged.
1170
1171Multiple `[one]` and `[two]` in code ignored, but [three] is not.
1172
1173```
1174[code block content] should be ignored
1175```
1176
1177`Multiple [refs] in [same] code span` ignored."#;
1178
1179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180 let result = rule.check(&ctx).unwrap();
1181
1182 assert_eq!(result.len(), 4);
1184
1185 let messages: Vec<&str> = result.iter().map(|w| &*w.message).collect();
1186 assert!(messages.iter().any(|m| m.contains("outside")));
1187 assert!(messages.iter().any(|m| m.contains("missing")));
1188 assert!(messages.iter().any(|m| m.contains("badimg")));
1189 assert!(messages.iter().any(|m| m.contains("three")));
1190
1191 assert!(!messages.iter().any(|m| m.contains("inside")));
1193 assert!(!messages.iter().any(|m| m.contains("one")));
1194 assert!(!messages.iter().any(|m| m.contains("two")));
1195 assert!(!messages.iter().any(|m| m.contains("refs")));
1196 assert!(!messages.iter().any(|m| m.contains("same")));
1197 }
1198
1199 #[test]
1200 fn test_multiple_undefined_references() {
1201 let rule = MD052ReferenceLinkImages::new();
1202 let content = "[link1][ref1] [link2][ref2] [link3][ref3]";
1203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1204 let result = rule.check(&ctx).unwrap();
1205
1206 assert_eq!(result.len(), 3);
1207 assert!(result[0].message.contains("ref1"));
1208 assert!(result[1].message.contains("ref2"));
1209 assert!(result[2].message.contains("ref3"));
1210 }
1211
1212 #[test]
1213 fn test_mixed_valid_and_undefined() {
1214 let rule = MD052ReferenceLinkImages::new();
1215 let content = "[valid][ref] [invalid][missing]\n\n[ref]: https://example.com";
1216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1217 let result = rule.check(&ctx).unwrap();
1218
1219 assert_eq!(result.len(), 1);
1220 assert!(result[0].message.contains("missing"));
1221 }
1222
1223 #[test]
1224 fn test_empty_reference() {
1225 let rule = MD052ReferenceLinkImages::new();
1226 let content = "[text][]\n\n[ref]: https://example.com";
1227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228 let result = rule.check(&ctx).unwrap();
1229
1230 assert_eq!(result.len(), 1);
1232 }
1233
1234 #[test]
1235 fn test_escaped_brackets_ignored() {
1236 let rule = MD052ReferenceLinkImages::new();
1237 let content = "\\[not a link\\]";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239 let result = rule.check(&ctx).unwrap();
1240
1241 assert_eq!(result.len(), 0);
1242 }
1243
1244 #[test]
1245 fn test_list_items_ignored() {
1246 let rule = MD052ReferenceLinkImages::new();
1247 let content = "- [undefined]\n* [another]\n+ [third]";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let result = rule.check(&ctx).unwrap();
1250
1251 assert_eq!(result.len(), 0);
1253 }
1254
1255 #[test]
1256 fn test_output_example_section_ignored() {
1257 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1259 shortcut_syntax: true,
1260 ..Default::default()
1261 });
1262 let content = "## Output\n\n[undefined]\n\n## Normal Section\n\n[missing]";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264 let result = rule.check(&ctx).unwrap();
1265
1266 assert_eq!(result.len(), 1);
1268 assert!(result[0].message.contains("missing"));
1269 }
1270
1271 fn example_section_lines(content: &str) -> Vec<usize> {
1272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1273 let mut lines: Vec<usize> = MD052ReferenceLinkImages::compute_example_sections(&ctx)
1274 .into_iter()
1275 .collect();
1276 lines.sort_unstable();
1277 lines
1278 }
1279
1280 #[test]
1281 fn test_example_section_spans_to_next_heading() {
1282 assert_eq!(
1283 example_section_lines("# T\n\n## Example\n\ntext\n\n## Next\n\nmore\n"),
1284 [3, 4, 5, 6]
1285 );
1286 assert_eq!(example_section_lines("# T\n\n## Output\n\ntext\n"), [3, 4, 5]);
1288 }
1289
1290 #[test]
1291 fn test_example_section_ignores_hash_lines_that_are_not_headings() {
1292 assert_eq!(
1294 example_section_lines("## Example\n\n```sh\n# a comment\n```\n\ntail\n"),
1295 [1, 2, 3, 4, 5, 6, 7]
1296 );
1297 assert!(example_section_lines("# T\n\n```\n## Output\n```\n").is_empty());
1299 assert_eq!(example_section_lines("## Example\n\n#tag\n\ntail\n"), [1, 2, 3, 4, 5]);
1301 }
1302
1303 #[test]
1304 fn test_example_section_setext_and_closing_hashes() {
1305 assert_eq!(example_section_lines("Example\n-------\n\ntail\n"), [1, 2, 3, 4]);
1306 assert_eq!(example_section_lines("## Example ##\n\ntail\n"), [1, 2, 3]);
1307 assert_eq!(
1308 example_section_lines("## Example\n\ntail\n\nOther\n-----\n\nafter\n"),
1309 [1, 2, 3, 4]
1310 );
1311 }
1312
1313 #[test]
1314 fn test_example_section_ignores_headings_in_containers() {
1315 assert!(example_section_lines("- ## Example\n\n text\n").is_empty());
1316 assert!(example_section_lines("> ## Example\n\ntext\n").is_empty());
1317 assert_eq!(
1318 example_section_lines("## Example\n\n- # x\n\n> # y\n\ntail\n"),
1319 [1, 2, 3, 4, 5, 6, 7]
1320 );
1321 }
1322
1323 #[test]
1324 fn test_reference_definitions_in_code_blocks_ignored() {
1325 let rule = MD052ReferenceLinkImages::new();
1326 let content = "[link][ref]\n\n```\n[ref]: https://example.com\n```";
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328 let result = rule.check(&ctx).unwrap();
1329
1330 assert_eq!(result.len(), 1);
1332 assert!(result[0].message.contains("ref"));
1333 }
1334
1335 #[test]
1336 fn test_multiple_references_to_same_undefined() {
1337 let rule = MD052ReferenceLinkImages::new();
1338 let content = "[first][missing] [second][missing] [third][missing]";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let result = rule.check(&ctx).unwrap();
1341
1342 assert_eq!(result.len(), 1);
1344 assert!(result[0].message.contains("missing"));
1345 }
1346
1347 #[test]
1348 fn test_reference_with_special_characters() {
1349 let rule = MD052ReferenceLinkImages::new();
1350 let content = "[text][ref-with-hyphens]\n\n[ref-with-hyphens]: https://example.com";
1351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1352 let result = rule.check(&ctx).unwrap();
1353
1354 assert_eq!(result.len(), 0);
1355 }
1356
1357 #[test]
1358 fn test_issue_51_html_attribute_not_reference() {
1359 let rule = MD052ReferenceLinkImages::new();
1361 let content = r#"# Example
1362
1363## Test
1364
1365Want to fill out this form?
1366
1367<form method="post">
1368 <input type="email" name="fields[email]" id="drip-email" placeholder="email@domain.com">
1369</form>"#;
1370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1371 let result = rule.check(&ctx).unwrap();
1372
1373 assert_eq!(
1374 result.len(),
1375 0,
1376 "HTML attributes with square brackets should not be flagged as undefined references"
1377 );
1378 }
1379
1380 #[test]
1381 fn test_extract_references() {
1382 let rule = MD052ReferenceLinkImages::new();
1383 let content = "[ref1]: url1\n[Ref2]: url2\n[REF3]: url3";
1384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1385 let refs = rule.extract_references(&ctx);
1386
1387 assert_eq!(refs.len(), 3);
1388 assert!(refs.contains("ref1"));
1389 assert!(refs.contains("ref2"));
1390 assert!(refs.contains("ref3"));
1391 }
1392
1393 #[test]
1394 fn test_inline_code_not_flagged() {
1395 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1397 shortcut_syntax: true,
1398 ..Default::default()
1399 });
1400
1401 let content = r#"# Test
1403
1404Configure with `["JavaScript", "GitHub", "Node.js"]` in your settings.
1405
1406Also, `[todo]` is not a reference link.
1407
1408But this [reference] should be flagged.
1409
1410And this `[inline code]` should not be flagged.
1411"#;
1412
1413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1414 let warnings = rule.check(&ctx).unwrap();
1415
1416 assert_eq!(warnings.len(), 1, "Should only flag one undefined reference");
1418 assert!(warnings[0].message.contains("'reference'"));
1419 }
1420
1421 #[test]
1422 fn test_code_block_references_ignored() {
1423 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1425 shortcut_syntax: true,
1426 ..Default::default()
1427 });
1428
1429 let content = r#"# Test
1430
1431```markdown
1432[undefined] reference in code block
1433![undefined] image in code block
1434```
1435
1436[real-undefined] reference outside
1437"#;
1438
1439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440 let warnings = rule.check(&ctx).unwrap();
1441
1442 assert_eq!(warnings.len(), 1);
1444 assert!(warnings[0].message.contains("'real-undefined'"));
1445 }
1446
1447 #[test]
1448 fn test_html_comments_ignored() {
1449 let rule = MD052ReferenceLinkImages::new();
1451
1452 let content = r#"<!--- write fake_editor.py 'import sys\nopen(*sys.argv[1:], mode="wt").write("2 3 4 4 2 3 2")' -->
1454<!--- set_env EDITOR 'python3 fake_editor.py' -->
1455
1456```bash
1457$ python3 vote.py
14583 votes for: 2
14592 votes for: 3, 4
1460```"#;
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 let result = rule.check(&ctx).unwrap();
1463 assert_eq!(result.len(), 0, "Should not flag [1:] inside HTML comments");
1464
1465 let content = r#"<!-- This is [ref1] and [ref2][ref3] -->
1467Normal [text][undefined]
1468<!-- Another [comment][with] references -->"#;
1469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1470 let result = rule.check(&ctx).unwrap();
1471 assert_eq!(
1472 result.len(),
1473 1,
1474 "Should only flag the undefined reference outside comments"
1475 );
1476 assert!(result[0].message.contains("undefined"));
1477
1478 let content = r#"<!--
1480[ref1]
1481[ref2][ref3]
1482-->
1483[actual][undefined]"#;
1484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1485 let result = rule.check(&ctx).unwrap();
1486 assert_eq!(
1487 result.len(),
1488 1,
1489 "Should not flag references in multi-line HTML comments"
1490 );
1491 assert!(result[0].message.contains("undefined"));
1492
1493 let content = r#"<!-- Comment with [1:] pattern -->
1495Valid [link][ref]
1496<!-- More [refs][in][comments] -->
1497![image][missing]
1498
1499[ref]: https://example.com"#;
1500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1501 let result = rule.check(&ctx).unwrap();
1502 assert_eq!(result.len(), 1, "Should only flag missing image reference");
1503 assert!(result[0].message.contains("missing"));
1504 }
1505
1506 #[test]
1507 fn test_frontmatter_ignored() {
1508 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1511 shortcut_syntax: true,
1512 ..Default::default()
1513 });
1514
1515 let content = r#"---
1517layout: post
1518title: "My Jekyll Post"
1519date: 2023-01-01
1520categories: blog
1521tags: ["test", "example"]
1522author: John Doe
1523---
1524
1525# My Blog Post
1526
1527This is the actual markdown content that should be linted.
1528
1529[undefined] reference should be flagged.
1530
1531## Section 1
1532
1533Some content here."#;
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let result = rule.check(&ctx).unwrap();
1536
1537 assert_eq!(
1539 result.len(),
1540 1,
1541 "Should only flag the undefined reference outside frontmatter"
1542 );
1543 assert!(result[0].message.contains("undefined"));
1544
1545 let content = r#"+++
1547title = "My Post"
1548tags = ["example", "test"]
1549+++
1550
1551# Content
1552
1553[missing] reference should be flagged."#;
1554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1555 let result = rule.check(&ctx).unwrap();
1556 assert_eq!(
1557 result.len(),
1558 1,
1559 "Should only flag the undefined reference outside TOML frontmatter"
1560 );
1561 assert!(result[0].message.contains("missing"));
1562 }
1563
1564 #[test]
1565 fn test_mkdocs_snippet_markers_not_flagged() {
1566 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1569 shortcut_syntax: true,
1570 ..Default::default()
1571 });
1572
1573 let content = r#"# Document with MkDocs Snippets
1575
1576Some content here.
1577
1578# -8<- [start:remote-content]
1579
1580This is the remote content section.
1581
1582# -8<- [end:remote-content]
1583
1584More content here.
1585
1586<!-- --8<-- [start:another-section] -->
1587Content in another section
1588<!-- --8<-- [end:another-section] -->"#;
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1590 let result = rule.check(&ctx).unwrap();
1591
1592 assert_eq!(
1594 result.len(),
1595 0,
1596 "Should not flag MkDocs snippet markers as undefined references"
1597 );
1598
1599 let content = r#"# Document
1602
1603# -8<- [start:section]
1604Content with [reference] inside snippet section
1605# -8<- [end:section]
1606
1607Regular [undefined] reference outside snippet markers."#;
1608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1609 let result = rule.check(&ctx).unwrap();
1610
1611 assert_eq!(
1612 result.len(),
1613 2,
1614 "Should flag undefined references but skip snippet marker lines"
1615 );
1616 assert!(result[0].message.contains("reference"));
1618 assert!(result[1].message.contains("undefined"));
1619
1620 let content = r#"# Document
1622
1623# -8<- [start:section]
1624# -8<- [end:section]"#;
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626 let result = rule.check(&ctx).unwrap();
1627
1628 assert_eq!(
1629 result.len(),
1630 2,
1631 "In standard mode, snippet markers should be flagged as undefined references"
1632 );
1633 }
1634
1635 #[test]
1636 fn test_pandoc_citations_not_flagged() {
1637 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1640 shortcut_syntax: true,
1641 ..Default::default()
1642 });
1643
1644 let content = r#"# Research Paper
1645
1646We are using the **bookdown** package [@R-bookdown] in this sample book.
1647This was built on top of R Markdown and **knitr** [@xie2015].
1648
1649Multiple citations [@citation1; @citation2; @citation3] are also supported.
1650
1651Regular [undefined] reference should still be flagged.
1652"#;
1653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654 let result = rule.check(&ctx).unwrap();
1655
1656 assert_eq!(
1658 result.len(),
1659 1,
1660 "Should only flag the undefined reference, not Pandoc citations"
1661 );
1662 assert!(result[0].message.contains("undefined"));
1663 }
1664
1665 #[test]
1666 fn test_pandoc_inline_footnotes_not_flagged() {
1667 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1670 shortcut_syntax: true,
1671 ..Default::default()
1672 });
1673
1674 let content = r#"# Math Document
1675
1676You can use math in footnotes like this^[where we mention $p = \frac{a}{b}$].
1677
1678Another footnote^[with some text and a [link](https://example.com)].
1679
1680But this [reference] without ^ should be flagged.
1681"#;
1682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683 let result = rule.check(&ctx).unwrap();
1684
1685 assert_eq!(
1687 result.len(),
1688 1,
1689 "Should only flag the regular reference, not inline footnotes"
1690 );
1691 assert!(result[0].message.contains("reference"));
1692 }
1693
1694 #[test]
1695 fn test_github_alerts_not_flagged() {
1696 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1699 shortcut_syntax: true,
1700 ..Default::default()
1701 });
1702
1703 let content = r#"# Document with GitHub Alerts
1705
1706> [!NOTE]
1707> This is a note alert.
1708
1709> [!TIP]
1710> This is a tip alert.
1711
1712> [!IMPORTANT]
1713> This is an important alert.
1714
1715> [!WARNING]
1716> This is a warning alert.
1717
1718> [!CAUTION]
1719> This is a caution alert.
1720
1721Regular content with [undefined] reference."#;
1722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1723 let result = rule.check(&ctx).unwrap();
1724
1725 assert_eq!(
1727 result.len(),
1728 1,
1729 "Should only flag the undefined reference, not GitHub alerts"
1730 );
1731 assert!(result[0].message.contains("undefined"));
1732 assert_eq!(result[0].line, 18); let content = r#"> [!TIP]
1736> Here's a useful tip about [something].
1737> Multiple lines are allowed.
1738
1739[something] is mentioned but not defined."#;
1740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1741 let result = rule.check(&ctx).unwrap();
1742
1743 assert_eq!(result.len(), 1, "Should flag undefined reference");
1747 assert!(result[0].message.contains("something"));
1748
1749 let content = r#"> [!NOTE]
1751> See [reference] for more details.
1752
1753[reference]: https://example.com"#;
1754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1755 let result = rule.check(&ctx).unwrap();
1756
1757 assert_eq!(result.len(), 0, "Should not flag GitHub alerts or defined references");
1759 }
1760
1761 #[test]
1762 fn test_ignore_config() {
1763 let config = MD052Config {
1765 shortcut_syntax: true,
1766 ignore: vec!["Vec".to_string(), "HashMap".to_string(), "Option".to_string()],
1767 };
1768 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1769
1770 let content = r#"# Document with Custom Types
1771
1772Use [Vec] for dynamic arrays.
1773Use [HashMap] for key-value storage.
1774Use [Option] for nullable values.
1775Use [Result] for error handling.
1776"#;
1777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1778 let result = rule.check(&ctx).unwrap();
1779
1780 assert_eq!(result.len(), 1, "Should only flag names not in ignore");
1782 assert!(result[0].message.contains("Result"));
1784 }
1785
1786 #[test]
1787 fn test_ignore_case_insensitive() {
1788 let config = MD052Config {
1790 shortcut_syntax: true,
1791 ignore: vec!["Vec".to_string()],
1792 };
1793 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1794
1795 let content = r#"# Case Insensitivity Test
1796
1797[Vec] should be ignored.
1798[vec] should also be ignored (different case, same match).
1799[VEC] should also be ignored (different case, same match).
1800[undefined] should be flagged (not in ignore list).
1801"#;
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803 let result = rule.check(&ctx).unwrap();
1804
1805 assert_eq!(result.len(), 1, "Should only flag non-ignored reference");
1807 assert!(result[0].message.contains("undefined"));
1808 }
1809
1810 #[test]
1811 fn test_ignore_empty_by_default() {
1812 let rule = MD052ReferenceLinkImages::new();
1814
1815 let content = "[text][undefined]";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817 let result = rule.check(&ctx).unwrap();
1818
1819 assert_eq!(result.len(), 1);
1821 assert!(result[0].message.contains("undefined"));
1822 }
1823
1824 #[test]
1825 fn test_ignore_with_reference_links() {
1826 let config = MD052Config {
1828 shortcut_syntax: false,
1829 ignore: vec!["CustomType".to_string()],
1830 };
1831 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1832
1833 let content = r#"# Test
1834
1835See [documentation][CustomType] for details.
1836See [other docs][MissingRef] for more.
1837"#;
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839 let result = rule.check(&ctx).unwrap();
1840
1841 for (i, w) in result.iter().enumerate() {
1843 eprintln!("Warning {}: {}", i, w.message);
1844 }
1845
1846 assert_eq!(result.len(), 1, "Expected 1 warning, got {}", result.len());
1849 assert!(
1850 result[0].message.contains("missingref"),
1851 "Expected 'missingref' in message: {}",
1852 result[0].message
1853 );
1854 }
1855
1856 #[test]
1857 fn test_ignore_multiple() {
1858 let config = MD052Config {
1860 shortcut_syntax: true,
1861 ignore: vec![
1862 "i32".to_string(),
1863 "u64".to_string(),
1864 "String".to_string(),
1865 "Arc".to_string(),
1866 "Mutex".to_string(),
1867 ],
1868 };
1869 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1870
1871 let content = r#"# Types
1872
1873[i32] [u64] [String] [Arc] [Mutex] [Box]
1874"#;
1875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1876 let result = rule.check(&ctx).unwrap();
1877
1878 assert_eq!(result.len(), 1);
1882 assert!(result[0].message.contains("Box"));
1884 }
1885
1886 #[test]
1887 fn test_nested_code_fences_reference_extraction() {
1888 let rule = MD052ReferenceLinkImages::new();
1893
1894 let content = "````\n```\n[ref-inside]: https://example.com\n```\n````\n\n[Use this link][ref-inside]";
1895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896 let result = rule.check(&ctx).unwrap();
1897
1898 assert_eq!(
1902 result.len(),
1903 1,
1904 "Reference defined inside nested code fence should not count as a definition"
1905 );
1906 assert!(result[0].message.contains("ref-inside"));
1907 }
1908
1909 #[test]
1910 fn test_pandoc_flavor_skips_citations() {
1911 let rule = MD052ReferenceLinkImages::new();
1914 let content = "See [@smith2020] for details.\n";
1915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1916 let result = rule.check(&ctx).unwrap();
1917 assert!(
1918 result.is_empty(),
1919 "MD052 should skip Pandoc citations under Pandoc flavor: {result:?}"
1920 );
1921 }
1922
1923 #[test]
1924 fn md052_pandoc_skips_implicit_header_refs_with_shortcut_syntax() {
1925 use crate::config::MarkdownFlavor;
1932 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1933 shortcut_syntax: true,
1934 ..Default::default()
1935 });
1936 let content = "# My Section\n\nSee [My Section] for details.\n";
1937
1938 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1941 let std_result = rule.check(&ctx_std).unwrap();
1942 assert_eq!(
1943 std_result.len(),
1944 1,
1945 "Standard flavor with shortcut_syntax should flag [My Section]: {std_result:?}"
1946 );
1947
1948 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1950 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1951 assert!(
1952 pandoc_result.is_empty(),
1953 "Pandoc flavor should accept [My Section] as an implicit header ref: {pandoc_result:?}"
1954 );
1955 }
1956
1957 #[test]
1958 fn test_md052_complex_undefined_reference() {
1959 let rule = MD052ReferenceLinkImages::from_config(&crate::config::Config::default());
1960 let content = "Check [link `code [with brackets]` text][undefined_ref] for details.\n";
1962 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1963 let result = rule.check(&ctx).unwrap();
1964 assert_eq!(
1965 result.len(),
1966 1,
1967 "Undefined reference in complex link must be flagged: {result:?}"
1968 );
1969 assert_eq!(result[0].message, "Reference 'undefined_ref' not found");
1970 }
1971}