1use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::mkdocs_patterns::is_mkdocs_auto_reference;
3use crate::utils::range_utils::calculate_match_range;
4use crate::utils::regex_cache::SHORTCUT_REF_REGEX;
5use crate::utils::skip_context::{is_in_math_context, is_in_table_cell};
6use 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_defs {
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 is_in_table_cell(ctx, link.line, link.start_col) {
395 continue;
396 }
397
398 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
400 continue;
401 }
402
403 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
406 continue;
407 }
408
409 if ctx.is_in_shortcode(link.byte_offset) {
412 continue;
413 }
414
415 if let Some(ref_id) = &link.reference_id {
416 let reference_lower = ref_id.to_lowercase();
417
418 if ctx.flavor.is_pandoc_compatible() && ctx.matches_implicit_header_reference(ref_id) {
420 continue;
421 }
422
423 if self.is_known_non_reference_pattern(ref_id) {
425 continue;
426 }
427
428 let stripped_ref = Self::strip_backticks(ref_id);
432 let stripped_text = Self::strip_backticks(&link.text);
433 if mkdocs_mode
434 && (is_mkdocs_auto_reference(stripped_ref)
435 || is_mkdocs_auto_reference(stripped_text)
436 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
437 || (link.text.as_ref() != stripped_text && Self::is_valid_python_identifier(stripped_text)))
438 {
439 continue;
440 }
441
442 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
444 if example_sections.contains(&link.line) {
445 continue;
446 }
447
448 if let Some(line_info) = ctx.line_info(link.line) {
449 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
451 continue;
452 }
453
454 let trimmed = line_info.content(ctx.content).trim_start();
456 if trimmed.starts_with('<') {
457 continue;
458 }
459 }
460
461 let match_len = link.byte_end - link.byte_offset;
462 let line_start = ctx.line_index.get_line_start_byte(link.line).unwrap_or(0);
465 undefined.push((
466 link.line - 1,
467 link.byte_offset - line_start,
468 match_len,
469 original_case_label(&link.text, &reference_lower),
470 ));
471 reported_refs.insert(reference_lower, true);
472 }
473 }
474 }
475
476 for image in &ctx.images {
478 if !image.is_reference {
479 continue; }
481
482 if ctx.is_in_jinja_range(image.byte_offset) {
484 continue;
485 }
486
487 if Self::is_in_code_span(image.byte_offset, &code_spans) {
489 continue;
490 }
491
492 if ctx.is_in_html_comment(image.byte_offset) || ctx.is_in_mdx_comment(image.byte_offset) {
494 continue;
495 }
496
497 if Self::is_in_html_tag(&html_tags, image.byte_offset) {
499 continue;
500 }
501
502 if is_in_math_context(ctx, image.byte_offset) {
504 continue;
505 }
506
507 if is_in_table_cell(ctx, image.line, image.start_col) {
509 continue;
510 }
511
512 if ctx.line_info(image.line).is_some_and(|info| info.in_front_matter) {
514 continue;
515 }
516
517 if let Some(ref_id) = &image.reference_id {
518 let reference_lower = ref_id.to_lowercase();
519
520 if self.is_known_non_reference_pattern(ref_id) {
522 continue;
523 }
524
525 let stripped_ref = Self::strip_backticks(ref_id);
529 let stripped_alt = Self::strip_backticks(&image.alt_text);
530 if mkdocs_mode
531 && (is_mkdocs_auto_reference(stripped_ref)
532 || is_mkdocs_auto_reference(stripped_alt)
533 || (ref_id != stripped_ref && Self::is_valid_python_identifier(stripped_ref))
534 || (image.alt_text.as_ref() != stripped_alt && Self::is_valid_python_identifier(stripped_alt)))
535 {
536 continue;
537 }
538
539 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
541 if example_sections.contains(&image.line) {
542 continue;
543 }
544
545 if let Some(line_info) = ctx.line_info(image.line) {
546 if LIST_ITEM_REGEX.is_match(line_info.content(ctx.content)) {
548 continue;
549 }
550
551 let trimmed = line_info.content(ctx.content).trim_start();
553 if trimmed.starts_with('<') {
554 continue;
555 }
556 }
557
558 let match_len = image.byte_end - image.byte_offset;
559 let line_start = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
562 undefined.push((
563 image.line - 1,
564 image.byte_offset - line_start,
565 match_len,
566 original_case_label(&image.alt_text, &reference_lower),
567 ));
568 reported_refs.insert(reference_lower, true);
569 }
570 }
571 }
572
573 let mut covered_ranges: Vec<(usize, usize)> = Vec::new();
575
576 for link in &ctx.links {
578 covered_ranges.push((link.byte_offset, link.byte_end));
579 }
580
581 for image in &ctx.images {
583 covered_ranges.push((image.byte_offset, image.byte_end));
584 }
585
586 covered_ranges.sort_by_key(|&(start, _)| start);
588
589 if !self.config.shortcut_syntax {
594 return undefined;
595 }
596
597 let lines = ctx.raw_lines();
599 for (line_num, line) in lines.iter().enumerate() {
600 if let Some(line_info) = ctx.line_info(line_num + 1)
602 && (line_info.in_front_matter || line_info.in_code_block)
603 {
604 continue;
605 }
606
607 if example_sections.contains(&(line_num + 1)) {
608 continue;
609 }
610
611 if LIST_ITEM_REGEX.is_match(line) {
613 continue;
614 }
615
616 let trimmed_line = line.trim_start();
618 if trimmed_line.starts_with('<') {
619 continue;
620 }
621
622 if GITHUB_ALERT_REGEX.is_match(line) {
624 continue;
625 }
626
627 if trimmed_line.starts_with("*[") {
630 continue;
631 }
632
633 let mut url_bracket_ranges: Vec<(usize, usize)> = Vec::new();
636 for mat in URL_WITH_BRACKETS.find_iter(line) {
637 let url_str = mat.as_str();
639 let url_start = mat.start();
640
641 let mut idx = 0;
643 while idx < url_str.len() {
644 if let Some(bracket_start) = url_str[idx..].find('[') {
645 let bracket_start_abs = url_start + idx + bracket_start;
646 if let Some(bracket_end) = url_str[idx + bracket_start + 1..].find(']') {
647 let bracket_end_abs = url_start + idx + bracket_start + 1 + bracket_end + 1;
648 url_bracket_ranges.push((bracket_start_abs, bracket_end_abs));
649 idx += bracket_start + bracket_end + 2;
650 } else {
651 break;
652 }
653 } else {
654 break;
655 }
656 }
657 }
658
659 if let Ok(captures) = SHORTCUT_REF_REGEX.captures_iter(line).collect::<Result<Vec<_>, _>>() {
661 for cap in captures {
662 if let Some(ref_match) = cap.get(1) {
663 let bracket_start = cap.get(0).unwrap().start();
665 let bracket_end = cap.get(0).unwrap().end();
666
667 let is_in_url = url_bracket_ranges
669 .iter()
670 .any(|&(url_start, url_end)| bracket_start >= url_start && bracket_end <= url_end);
671
672 if is_in_url {
673 continue;
674 }
675
676 if bracket_start > 0 {
679 if let Some(byte) = line.as_bytes().get(bracket_start.saturating_sub(1))
681 && *byte == b'^'
682 {
683 continue; }
685 }
686
687 let reference = ref_match.as_str();
688 let reference_lower = reference.to_lowercase();
689
690 if self.is_known_non_reference_pattern(reference) {
692 continue;
693 }
694
695 if let Some(alert_type) = reference.strip_prefix('!')
697 && matches!(
698 alert_type,
699 "NOTE"
700 | "TIP"
701 | "WARNING"
702 | "IMPORTANT"
703 | "CAUTION"
704 | "INFO"
705 | "SUCCESS"
706 | "FAILURE"
707 | "DANGER"
708 | "BUG"
709 | "EXAMPLE"
710 | "QUOTE"
711 )
712 {
713 continue;
714 }
715
716 if mkdocs_mode
719 && (reference.starts_with("start:") || reference.starts_with("end:"))
720 && (crate::utils::mkdocs_snippets::is_snippet_section_start(line)
721 || crate::utils::mkdocs_snippets::is_snippet_section_end(line))
722 {
723 continue;
724 }
725
726 let stripped_ref = Self::strip_backticks(reference);
729 if mkdocs_mode
730 && (is_mkdocs_auto_reference(stripped_ref)
731 || (reference != stripped_ref && Self::is_valid_python_identifier(stripped_ref)))
732 {
733 continue;
734 }
735
736 if ctx.flavor.is_pandoc_compatible() && ctx.matches_implicit_header_reference(reference) {
740 continue;
741 }
742
743 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
744 let full_match = cap.get(0).unwrap();
745 let col = full_match.start();
746 let line_start_byte = ctx.line_offsets[line_num];
747 let byte_pos = line_start_byte + col;
748
749 let code_spans = ctx.code_spans();
751 if Self::is_in_code_span(byte_pos, &code_spans) {
752 continue;
753 }
754
755 if ctx.is_in_jinja_range(byte_pos) {
757 continue;
758 }
759
760 if crate::utils::code_block_utils::CodeBlockUtils::is_in_code_block(
762 &ctx.code_blocks,
763 byte_pos,
764 ) {
765 continue;
766 }
767
768 if ctx.is_in_html_comment(byte_pos) || ctx.is_in_mdx_comment(byte_pos) {
770 continue;
771 }
772
773 if Self::is_in_html_tag(&html_tags, byte_pos) {
775 continue;
776 }
777
778 if is_in_math_context(ctx, byte_pos) {
780 continue;
781 }
782
783 if is_in_table_cell(ctx, line_num + 1, col) {
785 continue;
786 }
787
788 let byte_end = byte_pos + (full_match.end() - full_match.start());
789
790 let mut is_covered = false;
792 for &(range_start, range_end) in &covered_ranges {
793 if range_start <= byte_pos && byte_end <= range_end {
794 is_covered = true;
796 break;
797 }
798 if range_start > byte_end {
799 break;
801 }
802 }
803
804 if is_covered {
805 continue;
806 }
807
808 let line_chars: Vec<char> = line.chars().collect();
813 if col > 0 && col <= line_chars.len() && line_chars.get(col - 1) == Some(&']') {
814 let mut bracket_count = 1; let mut check_pos = col.saturating_sub(2);
817 let mut found_opening = false;
818
819 while check_pos > 0 && check_pos < line_chars.len() {
820 match line_chars.get(check_pos) {
821 Some(&']') => bracket_count += 1,
822 Some(&'[') => {
823 bracket_count -= 1;
824 if bracket_count == 0 {
825 if check_pos == 0 || line_chars.get(check_pos - 1) != Some(&'\\') {
827 found_opening = true;
828 }
829 break;
830 }
831 }
832 _ => {}
833 }
834 if check_pos == 0 {
835 break;
836 }
837 check_pos = check_pos.saturating_sub(1);
838 }
839
840 if found_opening {
841 continue;
843 }
844 }
845
846 let before_text = &line[..col];
849 if before_text.contains("\\]") {
850 if let Some(escaped_close_pos) = before_text.rfind("\\]") {
852 let search_text = &before_text[..escaped_close_pos];
853 if search_text.contains("\\[") {
854 continue;
856 }
857 }
858 }
859
860 let match_len = full_match.end() - full_match.start();
861 undefined.push((line_num, col, match_len, reference.to_string()));
862 reported_refs.insert(reference_lower, true);
863 }
864 }
865 }
866 }
867 }
868
869 undefined
870 }
871}
872
873fn original_case_label(text: &str, reference_lower: &str) -> String {
879 if !text.is_empty() && text.to_lowercase() == reference_lower {
880 text.to_string()
881 } else {
882 reference_lower.to_string()
883 }
884}
885
886impl Rule for MD052ReferenceLinkImages {
887 fn name(&self) -> &'static str {
888 "MD052"
889 }
890
891 fn description(&self) -> &'static str {
892 "Reference links and images should use a reference that exists"
893 }
894
895 fn category(&self) -> RuleCategory {
896 RuleCategory::Link
897 }
898
899 fn fix_capability(&self) -> FixCapability {
900 FixCapability::Unfixable
901 }
902
903 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
904 let content = ctx.content;
905 let mut warnings = Vec::new();
906
907 if !content.contains('[') {
909 return Ok(warnings);
910 }
911
912 let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
914
915 let references = self.extract_references(ctx);
916
917 let lines = ctx.raw_lines();
919 for (line_num, col, match_len, reference) in self.find_undefined_references(&references, ctx, mkdocs_mode) {
920 let line_content = lines.get(line_num).unwrap_or(&"");
921
922 let (start_line, start_col, end_line, end_col) =
924 calculate_match_range(line_num + 1, line_content, col, match_len);
925
926 warnings.push(LintWarning {
927 rule_name: Some(self.name().to_string()),
928 line: start_line,
929 column: start_col,
930 end_line,
931 end_column: end_col,
932 message: format!("Reference '{reference}' not found"),
933 severity: Severity::Warning,
934 fix: None,
935 });
936 }
937
938 Ok(warnings)
939 }
940
941 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
943 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
945 }
946
947 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
948 let content = ctx.content;
949 Ok(content.to_string())
951 }
952
953 fn as_any(&self) -> &dyn std::any::Any {
954 self
955 }
956
957 crate::impl_rule_config_methods!(MD052Config);
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963 use crate::lint_context::LintContext;
964
965 #[test]
966 fn test_valid_reference_link() {
967 let rule = MD052ReferenceLinkImages::new();
968 let content = "[text][ref]\n\n[ref]: https://example.com";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 let result = rule.check(&ctx).unwrap();
971
972 assert_eq!(result.len(), 0);
973 }
974
975 #[test]
976 fn test_undefined_reference_link() {
977 let rule = MD052ReferenceLinkImages::new();
978 let content = "[text][undefined]";
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
980 let result = rule.check(&ctx).unwrap();
981
982 assert_eq!(result.len(), 1);
983 assert!(result[0].message.contains("Reference 'undefined' not found"));
984 }
985
986 #[test]
987 fn test_undefined_reference_column_non_ascii_prefix() {
988 let rule = MD052ReferenceLinkImages::new();
992 let content = "你好[text][undefined]";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996
997 assert_eq!(result.len(), 1);
998 assert_eq!(
999 result[0].column, 3,
1000 "Column must be a character offset, not a byte offset"
1001 );
1002 }
1003
1004 #[test]
1005 fn test_valid_reference_image() {
1006 let rule = MD052ReferenceLinkImages::new();
1007 let content = "![alt][img]\n\n[img]: image.jpg";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009 let result = rule.check(&ctx).unwrap();
1010
1011 assert_eq!(result.len(), 0);
1012 }
1013
1014 #[test]
1015 fn test_undefined_reference_image() {
1016 let rule = MD052ReferenceLinkImages::new();
1017 let content = "![alt][missing]";
1018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1019 let result = rule.check(&ctx).unwrap();
1020
1021 assert_eq!(result.len(), 1);
1022 assert!(result[0].message.contains("Reference 'missing' not found"));
1023 }
1024
1025 #[test]
1026 fn test_case_insensitive_references() {
1027 let rule = MD052ReferenceLinkImages::new();
1028 let content = "[Text][REF]\n\n[ref]: https://example.com";
1029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030 let result = rule.check(&ctx).unwrap();
1031
1032 assert_eq!(result.len(), 0);
1033 }
1034
1035 #[test]
1036 fn test_shortcut_reference_valid() {
1037 let rule = MD052ReferenceLinkImages::new();
1038 let content = "[ref]\n\n[ref]: https://example.com";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040 let result = rule.check(&ctx).unwrap();
1041
1042 assert_eq!(result.len(), 0);
1043 }
1044
1045 #[test]
1046 fn test_shortcut_reference_undefined_with_shortcut_syntax_enabled() {
1047 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1050 shortcut_syntax: true,
1051 ..Default::default()
1052 });
1053 let content = "[undefined]";
1054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055 let result = rule.check(&ctx).unwrap();
1056
1057 assert_eq!(result.len(), 1);
1058 assert!(result[0].message.contains("Reference 'undefined' not found"));
1059 }
1060
1061 #[test]
1062 fn test_shortcut_reference_not_checked_by_default() {
1063 let rule = MD052ReferenceLinkImages::new();
1065 let content = "[undefined]";
1066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067 let result = rule.check(&ctx).unwrap();
1068
1069 assert_eq!(result.len(), 0);
1071 }
1072
1073 #[test]
1074 fn test_inline_links_ignored() {
1075 let rule = MD052ReferenceLinkImages::new();
1076 let content = "[text](https://example.com)";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078 let result = rule.check(&ctx).unwrap();
1079
1080 assert_eq!(result.len(), 0);
1081 }
1082
1083 #[test]
1084 fn test_inline_images_ignored() {
1085 let rule = MD052ReferenceLinkImages::new();
1086 let content = "";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088 let result = rule.check(&ctx).unwrap();
1089
1090 assert_eq!(result.len(), 0);
1091 }
1092
1093 #[test]
1094 fn test_references_in_code_blocks_ignored() {
1095 let rule = MD052ReferenceLinkImages::new();
1096 let content = "```\n[undefined]\n```\n\n[ref]: https://example.com";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1098 let result = rule.check(&ctx).unwrap();
1099
1100 assert_eq!(result.len(), 0);
1101 }
1102
1103 #[test]
1104 fn test_references_in_inline_code_ignored() {
1105 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_comprehensive_inline_code_detection() {
1116 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1118 shortcut_syntax: true,
1119 ..Default::default()
1120 });
1121 let content = r#"# Test
1122
1123This `[inside]` should be ignored.
1124This [outside] should be flagged.
1125Reference links `[text][ref]` in code are ignored.
1126Regular reference [text][missing] should be flagged.
1127Images `![alt][img]` in code are ignored.
1128Regular image ![alt][badimg] should be flagged.
1129
1130Multiple `[one]` and `[two]` in code ignored, but [three] is not.
1131
1132```
1133[code block content] should be ignored
1134```
1135
1136`Multiple [refs] in [same] code span` ignored."#;
1137
1138 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1139 let result = rule.check(&ctx).unwrap();
1140
1141 assert_eq!(result.len(), 4);
1143
1144 let messages: Vec<&str> = result.iter().map(|w| &*w.message).collect();
1145 assert!(messages.iter().any(|m| m.contains("outside")));
1146 assert!(messages.iter().any(|m| m.contains("missing")));
1147 assert!(messages.iter().any(|m| m.contains("badimg")));
1148 assert!(messages.iter().any(|m| m.contains("three")));
1149
1150 assert!(!messages.iter().any(|m| m.contains("inside")));
1152 assert!(!messages.iter().any(|m| m.contains("one")));
1153 assert!(!messages.iter().any(|m| m.contains("two")));
1154 assert!(!messages.iter().any(|m| m.contains("refs")));
1155 assert!(!messages.iter().any(|m| m.contains("same")));
1156 }
1157
1158 #[test]
1159 fn test_multiple_undefined_references() {
1160 let rule = MD052ReferenceLinkImages::new();
1161 let content = "[link1][ref1] [link2][ref2] [link3][ref3]";
1162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163 let result = rule.check(&ctx).unwrap();
1164
1165 assert_eq!(result.len(), 3);
1166 assert!(result[0].message.contains("ref1"));
1167 assert!(result[1].message.contains("ref2"));
1168 assert!(result[2].message.contains("ref3"));
1169 }
1170
1171 #[test]
1172 fn test_mixed_valid_and_undefined() {
1173 let rule = MD052ReferenceLinkImages::new();
1174 let content = "[valid][ref] [invalid][missing]\n\n[ref]: https://example.com";
1175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1176 let result = rule.check(&ctx).unwrap();
1177
1178 assert_eq!(result.len(), 1);
1179 assert!(result[0].message.contains("missing"));
1180 }
1181
1182 #[test]
1183 fn test_empty_reference() {
1184 let rule = MD052ReferenceLinkImages::new();
1185 let content = "[text][]\n\n[ref]: https://example.com";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 let result = rule.check(&ctx).unwrap();
1188
1189 assert_eq!(result.len(), 1);
1191 }
1192
1193 #[test]
1194 fn test_escaped_brackets_ignored() {
1195 let rule = MD052ReferenceLinkImages::new();
1196 let content = "\\[not a link\\]";
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198 let result = rule.check(&ctx).unwrap();
1199
1200 assert_eq!(result.len(), 0);
1201 }
1202
1203 #[test]
1204 fn test_list_items_ignored() {
1205 let rule = MD052ReferenceLinkImages::new();
1206 let content = "- [undefined]\n* [another]\n+ [third]";
1207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1208 let result = rule.check(&ctx).unwrap();
1209
1210 assert_eq!(result.len(), 0);
1212 }
1213
1214 #[test]
1215 fn test_output_example_section_ignored() {
1216 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1218 shortcut_syntax: true,
1219 ..Default::default()
1220 });
1221 let content = "## Output\n\n[undefined]\n\n## Normal Section\n\n[missing]";
1222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223 let result = rule.check(&ctx).unwrap();
1224
1225 assert_eq!(result.len(), 1);
1227 assert!(result[0].message.contains("missing"));
1228 }
1229
1230 #[test]
1231 fn test_reference_definitions_in_code_blocks_ignored() {
1232 let rule = MD052ReferenceLinkImages::new();
1233 let content = "[link][ref]\n\n```\n[ref]: https://example.com\n```";
1234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1235 let result = rule.check(&ctx).unwrap();
1236
1237 assert_eq!(result.len(), 1);
1239 assert!(result[0].message.contains("ref"));
1240 }
1241
1242 #[test]
1243 fn test_multiple_references_to_same_undefined() {
1244 let rule = MD052ReferenceLinkImages::new();
1245 let content = "[first][missing] [second][missing] [third][missing]";
1246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1247 let result = rule.check(&ctx).unwrap();
1248
1249 assert_eq!(result.len(), 1);
1251 assert!(result[0].message.contains("missing"));
1252 }
1253
1254 #[test]
1255 fn test_reference_with_special_characters() {
1256 let rule = MD052ReferenceLinkImages::new();
1257 let content = "[text][ref-with-hyphens]\n\n[ref-with-hyphens]: https://example.com";
1258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1259 let result = rule.check(&ctx).unwrap();
1260
1261 assert_eq!(result.len(), 0);
1262 }
1263
1264 #[test]
1265 fn test_issue_51_html_attribute_not_reference() {
1266 let rule = MD052ReferenceLinkImages::new();
1268 let content = r#"# Example
1269
1270## Test
1271
1272Want to fill out this form?
1273
1274<form method="post">
1275 <input type="email" name="fields[email]" id="drip-email" placeholder="email@domain.com">
1276</form>"#;
1277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278 let result = rule.check(&ctx).unwrap();
1279
1280 assert_eq!(
1281 result.len(),
1282 0,
1283 "HTML attributes with square brackets should not be flagged as undefined references"
1284 );
1285 }
1286
1287 #[test]
1288 fn test_extract_references() {
1289 let rule = MD052ReferenceLinkImages::new();
1290 let content = "[ref1]: url1\n[Ref2]: url2\n[REF3]: url3";
1291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292 let refs = rule.extract_references(&ctx);
1293
1294 assert_eq!(refs.len(), 3);
1295 assert!(refs.contains("ref1"));
1296 assert!(refs.contains("ref2"));
1297 assert!(refs.contains("ref3"));
1298 }
1299
1300 #[test]
1301 fn test_inline_code_not_flagged() {
1302 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1304 shortcut_syntax: true,
1305 ..Default::default()
1306 });
1307
1308 let content = r#"# Test
1310
1311Configure with `["JavaScript", "GitHub", "Node.js"]` in your settings.
1312
1313Also, `[todo]` is not a reference link.
1314
1315But this [reference] should be flagged.
1316
1317And this `[inline code]` should not be flagged.
1318"#;
1319
1320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321 let warnings = rule.check(&ctx).unwrap();
1322
1323 assert_eq!(warnings.len(), 1, "Should only flag one undefined reference");
1325 assert!(warnings[0].message.contains("'reference'"));
1326 }
1327
1328 #[test]
1329 fn test_code_block_references_ignored() {
1330 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1332 shortcut_syntax: true,
1333 ..Default::default()
1334 });
1335
1336 let content = r#"# Test
1337
1338```markdown
1339[undefined] reference in code block
1340![undefined] image in code block
1341```
1342
1343[real-undefined] reference outside
1344"#;
1345
1346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347 let warnings = rule.check(&ctx).unwrap();
1348
1349 assert_eq!(warnings.len(), 1);
1351 assert!(warnings[0].message.contains("'real-undefined'"));
1352 }
1353
1354 #[test]
1355 fn test_html_comments_ignored() {
1356 let rule = MD052ReferenceLinkImages::new();
1358
1359 let content = r#"<!--- write fake_editor.py 'import sys\nopen(*sys.argv[1:], mode="wt").write("2 3 4 4 2 3 2")' -->
1361<!--- set_env EDITOR 'python3 fake_editor.py' -->
1362
1363```bash
1364$ python3 vote.py
13653 votes for: 2
13662 votes for: 3, 4
1367```"#;
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369 let result = rule.check(&ctx).unwrap();
1370 assert_eq!(result.len(), 0, "Should not flag [1:] inside HTML comments");
1371
1372 let content = r#"<!-- This is [ref1] and [ref2][ref3] -->
1374Normal [text][undefined]
1375<!-- Another [comment][with] references -->"#;
1376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1377 let result = rule.check(&ctx).unwrap();
1378 assert_eq!(
1379 result.len(),
1380 1,
1381 "Should only flag the undefined reference outside comments"
1382 );
1383 assert!(result[0].message.contains("undefined"));
1384
1385 let content = r#"<!--
1387[ref1]
1388[ref2][ref3]
1389-->
1390[actual][undefined]"#;
1391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1392 let result = rule.check(&ctx).unwrap();
1393 assert_eq!(
1394 result.len(),
1395 1,
1396 "Should not flag references in multi-line HTML comments"
1397 );
1398 assert!(result[0].message.contains("undefined"));
1399
1400 let content = r#"<!-- Comment with [1:] pattern -->
1402Valid [link][ref]
1403<!-- More [refs][in][comments] -->
1404![image][missing]
1405
1406[ref]: https://example.com"#;
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let result = rule.check(&ctx).unwrap();
1409 assert_eq!(result.len(), 1, "Should only flag missing image reference");
1410 assert!(result[0].message.contains("missing"));
1411 }
1412
1413 #[test]
1414 fn test_frontmatter_ignored() {
1415 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1418 shortcut_syntax: true,
1419 ..Default::default()
1420 });
1421
1422 let content = r#"---
1424layout: post
1425title: "My Jekyll Post"
1426date: 2023-01-01
1427categories: blog
1428tags: ["test", "example"]
1429author: John Doe
1430---
1431
1432# My Blog Post
1433
1434This is the actual markdown content that should be linted.
1435
1436[undefined] reference should be flagged.
1437
1438## Section 1
1439
1440Some content here."#;
1441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1442 let result = rule.check(&ctx).unwrap();
1443
1444 assert_eq!(
1446 result.len(),
1447 1,
1448 "Should only flag the undefined reference outside frontmatter"
1449 );
1450 assert!(result[0].message.contains("undefined"));
1451
1452 let content = r#"+++
1454title = "My Post"
1455tags = ["example", "test"]
1456+++
1457
1458# Content
1459
1460[missing] reference should be flagged."#;
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 let result = rule.check(&ctx).unwrap();
1463 assert_eq!(
1464 result.len(),
1465 1,
1466 "Should only flag the undefined reference outside TOML frontmatter"
1467 );
1468 assert!(result[0].message.contains("missing"));
1469 }
1470
1471 #[test]
1472 fn test_mkdocs_snippet_markers_not_flagged() {
1473 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1476 shortcut_syntax: true,
1477 ..Default::default()
1478 });
1479
1480 let content = r#"# Document with MkDocs Snippets
1482
1483Some content here.
1484
1485# -8<- [start:remote-content]
1486
1487This is the remote content section.
1488
1489# -8<- [end:remote-content]
1490
1491More content here.
1492
1493<!-- --8<-- [start:another-section] -->
1494Content in another section
1495<!-- --8<-- [end:another-section] -->"#;
1496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1497 let result = rule.check(&ctx).unwrap();
1498
1499 assert_eq!(
1501 result.len(),
1502 0,
1503 "Should not flag MkDocs snippet markers as undefined references"
1504 );
1505
1506 let content = r#"# Document
1509
1510# -8<- [start:section]
1511Content with [reference] inside snippet section
1512# -8<- [end:section]
1513
1514Regular [undefined] reference outside snippet markers."#;
1515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1516 let result = rule.check(&ctx).unwrap();
1517
1518 assert_eq!(
1519 result.len(),
1520 2,
1521 "Should flag undefined references but skip snippet marker lines"
1522 );
1523 assert!(result[0].message.contains("reference"));
1525 assert!(result[1].message.contains("undefined"));
1526
1527 let content = r#"# Document
1529
1530# -8<- [start:section]
1531# -8<- [end:section]"#;
1532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1533 let result = rule.check(&ctx).unwrap();
1534
1535 assert_eq!(
1536 result.len(),
1537 2,
1538 "In standard mode, snippet markers should be flagged as undefined references"
1539 );
1540 }
1541
1542 #[test]
1543 fn test_pandoc_citations_not_flagged() {
1544 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1547 shortcut_syntax: true,
1548 ..Default::default()
1549 });
1550
1551 let content = r#"# Research Paper
1552
1553We are using the **bookdown** package [@R-bookdown] in this sample book.
1554This was built on top of R Markdown and **knitr** [@xie2015].
1555
1556Multiple citations [@citation1; @citation2; @citation3] are also supported.
1557
1558Regular [undefined] reference should still be flagged.
1559"#;
1560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1561 let result = rule.check(&ctx).unwrap();
1562
1563 assert_eq!(
1565 result.len(),
1566 1,
1567 "Should only flag the undefined reference, not Pandoc citations"
1568 );
1569 assert!(result[0].message.contains("undefined"));
1570 }
1571
1572 #[test]
1573 fn test_pandoc_inline_footnotes_not_flagged() {
1574 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1577 shortcut_syntax: true,
1578 ..Default::default()
1579 });
1580
1581 let content = r#"# Math Document
1582
1583You can use math in footnotes like this^[where we mention $p = \frac{a}{b}$].
1584
1585Another footnote^[with some text and a [link](https://example.com)].
1586
1587But this [reference] without ^ should be flagged.
1588"#;
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1590 let result = rule.check(&ctx).unwrap();
1591
1592 assert_eq!(
1594 result.len(),
1595 1,
1596 "Should only flag the regular reference, not inline footnotes"
1597 );
1598 assert!(result[0].message.contains("reference"));
1599 }
1600
1601 #[test]
1602 fn test_github_alerts_not_flagged() {
1603 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1606 shortcut_syntax: true,
1607 ..Default::default()
1608 });
1609
1610 let content = r#"# Document with GitHub Alerts
1612
1613> [!NOTE]
1614> This is a note alert.
1615
1616> [!TIP]
1617> This is a tip alert.
1618
1619> [!IMPORTANT]
1620> This is an important alert.
1621
1622> [!WARNING]
1623> This is a warning alert.
1624
1625> [!CAUTION]
1626> This is a caution alert.
1627
1628Regular content with [undefined] reference."#;
1629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1630 let result = rule.check(&ctx).unwrap();
1631
1632 assert_eq!(
1634 result.len(),
1635 1,
1636 "Should only flag the undefined reference, not GitHub alerts"
1637 );
1638 assert!(result[0].message.contains("undefined"));
1639 assert_eq!(result[0].line, 18); let content = r#"> [!TIP]
1643> Here's a useful tip about [something].
1644> Multiple lines are allowed.
1645
1646[something] is mentioned but not defined."#;
1647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1648 let result = rule.check(&ctx).unwrap();
1649
1650 assert_eq!(result.len(), 1, "Should flag undefined reference");
1654 assert!(result[0].message.contains("something"));
1655
1656 let content = r#"> [!NOTE]
1658> See [reference] for more details.
1659
1660[reference]: https://example.com"#;
1661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1662 let result = rule.check(&ctx).unwrap();
1663
1664 assert_eq!(result.len(), 0, "Should not flag GitHub alerts or defined references");
1666 }
1667
1668 #[test]
1669 fn test_ignore_config() {
1670 let config = MD052Config {
1672 shortcut_syntax: true,
1673 ignore: vec!["Vec".to_string(), "HashMap".to_string(), "Option".to_string()],
1674 };
1675 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1676
1677 let content = r#"# Document with Custom Types
1678
1679Use [Vec] for dynamic arrays.
1680Use [HashMap] for key-value storage.
1681Use [Option] for nullable values.
1682Use [Result] for error handling.
1683"#;
1684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1685 let result = rule.check(&ctx).unwrap();
1686
1687 assert_eq!(result.len(), 1, "Should only flag names not in ignore");
1689 assert!(result[0].message.contains("Result"));
1691 }
1692
1693 #[test]
1694 fn test_ignore_case_insensitive() {
1695 let config = MD052Config {
1697 shortcut_syntax: true,
1698 ignore: vec!["Vec".to_string()],
1699 };
1700 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1701
1702 let content = r#"# Case Insensitivity Test
1703
1704[Vec] should be ignored.
1705[vec] should also be ignored (different case, same match).
1706[VEC] should also be ignored (different case, same match).
1707[undefined] should be flagged (not in ignore list).
1708"#;
1709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1710 let result = rule.check(&ctx).unwrap();
1711
1712 assert_eq!(result.len(), 1, "Should only flag non-ignored reference");
1714 assert!(result[0].message.contains("undefined"));
1715 }
1716
1717 #[test]
1718 fn test_ignore_empty_by_default() {
1719 let rule = MD052ReferenceLinkImages::new();
1721
1722 let content = "[text][undefined]";
1723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1724 let result = rule.check(&ctx).unwrap();
1725
1726 assert_eq!(result.len(), 1);
1728 assert!(result[0].message.contains("undefined"));
1729 }
1730
1731 #[test]
1732 fn test_ignore_with_reference_links() {
1733 let config = MD052Config {
1735 shortcut_syntax: false,
1736 ignore: vec!["CustomType".to_string()],
1737 };
1738 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1739
1740 let content = r#"# Test
1741
1742See [documentation][CustomType] for details.
1743See [other docs][MissingRef] for more.
1744"#;
1745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746 let result = rule.check(&ctx).unwrap();
1747
1748 for (i, w) in result.iter().enumerate() {
1750 eprintln!("Warning {}: {}", i, w.message);
1751 }
1752
1753 assert_eq!(result.len(), 1, "Expected 1 warning, got {}", result.len());
1756 assert!(
1757 result[0].message.contains("missingref"),
1758 "Expected 'missingref' in message: {}",
1759 result[0].message
1760 );
1761 }
1762
1763 #[test]
1764 fn test_ignore_multiple() {
1765 let config = MD052Config {
1767 shortcut_syntax: true,
1768 ignore: vec![
1769 "i32".to_string(),
1770 "u64".to_string(),
1771 "String".to_string(),
1772 "Arc".to_string(),
1773 "Mutex".to_string(),
1774 ],
1775 };
1776 let rule = MD052ReferenceLinkImages::from_config_struct(config);
1777
1778 let content = r#"# Types
1779
1780[i32] [u64] [String] [Arc] [Mutex] [Box]
1781"#;
1782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1783 let result = rule.check(&ctx).unwrap();
1784
1785 assert_eq!(result.len(), 1);
1789 assert!(result[0].message.contains("Box"));
1791 }
1792
1793 #[test]
1794 fn test_nested_code_fences_reference_extraction() {
1795 let rule = MD052ReferenceLinkImages::new();
1800
1801 let content = "````\n```\n[ref-inside]: https://example.com\n```\n````\n\n[Use this link][ref-inside]";
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803 let result = rule.check(&ctx).unwrap();
1804
1805 assert_eq!(
1809 result.len(),
1810 1,
1811 "Reference defined inside nested code fence should not count as a definition"
1812 );
1813 assert!(result[0].message.contains("ref-inside"));
1814 }
1815
1816 #[test]
1817 fn test_pandoc_flavor_skips_citations() {
1818 let rule = MD052ReferenceLinkImages::new();
1821 let content = "See [@smith2020] for details.\n";
1822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1823 let result = rule.check(&ctx).unwrap();
1824 assert!(
1825 result.is_empty(),
1826 "MD052 should skip Pandoc citations under Pandoc flavor: {result:?}"
1827 );
1828 }
1829
1830 #[test]
1831 fn md052_pandoc_skips_implicit_header_refs_with_shortcut_syntax() {
1832 use crate::config::MarkdownFlavor;
1839 let rule = MD052ReferenceLinkImages::from_config_struct(MD052Config {
1840 shortcut_syntax: true,
1841 ..Default::default()
1842 });
1843 let content = "# My Section\n\nSee [My Section] for details.\n";
1844
1845 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1848 let std_result = rule.check(&ctx_std).unwrap();
1849 assert_eq!(
1850 std_result.len(),
1851 1,
1852 "Standard flavor with shortcut_syntax should flag [My Section]: {std_result:?}"
1853 );
1854
1855 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1857 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1858 assert!(
1859 pandoc_result.is_empty(),
1860 "Pandoc flavor should accept [My Section] as an implicit header ref: {pandoc_result:?}"
1861 );
1862 }
1863
1864 #[test]
1865 fn test_md052_complex_undefined_reference() {
1866 let rule = MD052ReferenceLinkImages::from_config(&crate::config::Config::default());
1867 let content = "Check [link `code [with brackets]` text][undefined_ref] for details.\n";
1869 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1870 let result = rule.check(&ctx).unwrap();
1871 assert_eq!(
1872 result.len(),
1873 1,
1874 "Undefined reference in complex link must be flagged: {result:?}"
1875 );
1876 assert_eq!(result[0].message, "Reference 'undefined_ref' not found");
1877 }
1878}