1use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
2use crate::utils::mkdocs_patterns::is_mkdocs_auto_reference;
3use crate::utils::range_utils::calculate_match_range;
4use crate::utils::regex_cache::{HTML_COMMENT_PATTERN, SHORTCUT_REF_REGEX};
5use crate::utils::skip_context::{is_in_front_matter, is_in_math_context, is_in_table_cell};
6use lazy_static::lazy_static;
7use regex::Regex;
8use std::collections::{HashMap, HashSet};
9
10lazy_static! {
11 static ref REF_REGEX: Regex = Regex::new(r"^\s*\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]:\s*.*").unwrap();
15
16 static ref LIST_ITEM_REGEX: Regex = Regex::new(r"^\s*[-*+]\s+(?:\[[xX\s]\]\s+)?").unwrap();
18
19 static ref FENCED_CODE_START: Regex = Regex::new(r"^(\s*)(`{3,}|~{3,})").unwrap();
21
22 static ref OUTPUT_EXAMPLE_START: Regex = Regex::new(r"^#+\s*(?:Output|Example|Output Style|Output Format)\s*$").unwrap();
24
25 static ref GITHUB_ALERT_REGEX: Regex = Regex::new(r"^\s*>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|INFO|SUCCESS|FAILURE|DANGER|BUG|EXAMPLE|QUOTE)\]").unwrap();
28
29 static ref URL_WITH_BRACKETS: Regex = Regex::new(
37 r"https?://(?:\[[0-9a-fA-F:.%]+\]|[^\s\[\]]+/[^\s]*\[\d+\])"
38 ).unwrap();
39}
40
41#[derive(Clone, Default)]
47pub struct MD052ReferenceLinkImages {}
48
49impl MD052ReferenceLinkImages {
50 pub fn new() -> Self {
51 Self {}
52 }
53
54 fn is_likely_not_reference(text: &str) -> bool {
57 if text.chars().all(|c| c.is_ascii_digit()) {
59 return true;
60 }
61
62 if text.contains(':') && text.chars().all(|c| c.is_ascii_digit() || c == ':') {
64 return true;
65 }
66
67 if text.contains('.') && !text.contains(' ') && !text.contains('-') && !text.contains('_') {
70 return true;
72 }
73
74 if text == "*" || text == "..." || text == "**" {
76 return true;
77 }
78
79 if text.contains('/') && !text.contains(' ') && !text.starts_with("http") {
81 return true;
82 }
83
84 if text.contains(',') || text.contains('[') || text.contains(']') {
87 return true;
89 }
90
91 if !text.contains('`')
98 && text.contains('.')
99 && !text.contains(' ')
100 && !text.contains('-')
101 && !text.contains('_')
102 {
103 return true;
104 }
105
106 if text.chars().all(|c| !c.is_alphanumeric() && c != ' ') {
113 return true;
114 }
115
116 if text.len() <= 2 && !text.chars().all(|c| c.is_alphabetic()) {
118 return true;
119 }
120
121 if (text.starts_with('"') && text.ends_with('"'))
123 || (text.starts_with('\'') && text.ends_with('\''))
124 || text.contains('"')
125 || text.contains('\'')
126 {
127 return true;
128 }
129
130 if text.contains(':') && text.contains(' ') {
133 return true;
134 }
135
136 if text.starts_with('!') {
138 return true;
139 }
140
141 if text.len() == 1 && text.chars().all(|c| c.is_ascii_uppercase()) {
143 return true;
144 }
145
146 let common_non_refs = [
149 "object", "Object", "any", "Any", "inv", "void", "bool", "int", "float", "str", "char", "i8", "i16", "i32",
150 "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize", "f32", "f64",
151 ];
152
153 if common_non_refs.contains(&text) {
154 return true;
155 }
156
157 false
158 }
159
160 fn is_in_code_span(line: usize, col: usize, code_spans: &[crate::lint_context::CodeSpan]) -> bool {
162 code_spans
163 .iter()
164 .any(|span| span.line == line && col >= span.start_col && col < span.end_col)
165 }
166
167 fn is_in_html_comment(content: &str, byte_pos: usize) -> bool {
169 for m in HTML_COMMENT_PATTERN.find_iter(content) {
170 if m.start() <= byte_pos && byte_pos < m.end() {
171 return true;
172 }
173 }
174 false
175 }
176
177 fn is_in_html_tag(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
179 for html_tag in ctx.html_tags().iter() {
181 if html_tag.byte_offset <= byte_pos && byte_pos < html_tag.byte_end {
182 return true;
183 }
184 }
185 false
186 }
187
188 fn extract_references(&self, content: &str, mkdocs_mode: bool) -> HashSet<String> {
189 use crate::config::MarkdownFlavor;
190 use crate::utils::skip_context::is_mkdocs_snippet_line;
191
192 let mut references = HashSet::new();
193 let mut in_code_block = false;
194 let mut code_fence_marker = String::new();
195
196 for line in content.lines() {
197 if is_mkdocs_snippet_line(
199 line,
200 if mkdocs_mode {
201 MarkdownFlavor::MkDocs
202 } else {
203 MarkdownFlavor::Standard
204 },
205 ) {
206 continue;
207 }
208 if let Some(cap) = FENCED_CODE_START.captures(line) {
210 if let Some(fence) = cap.get(2) {
211 let fence_str = fence.as_str();
213 if !in_code_block {
214 in_code_block = true;
215 code_fence_marker = fence_str.to_string();
216 } else if line.trim_start().starts_with(&code_fence_marker) {
217 let trimmed = line.trim_start();
219 if trimmed.starts_with(&code_fence_marker) {
221 let after_fence = &trimmed[code_fence_marker.len()..];
222 if after_fence.trim().is_empty() {
223 in_code_block = false;
224 code_fence_marker.clear();
225 }
226 }
227 }
228 }
229 continue;
230 }
231
232 if in_code_block {
234 continue;
235 }
236
237 if line.trim_start().starts_with("*[") {
240 continue;
241 }
242
243 if let Some(cap) = REF_REGEX.captures(line) {
244 if let Some(reference) = cap.get(1) {
246 references.insert(reference.as_str().to_lowercase());
247 }
248 }
249 }
250
251 references
252 }
253
254 fn find_undefined_references(
255 &self,
256 content: &str,
257 references: &HashSet<String>,
258 ctx: &crate::lint_context::LintContext,
259 mkdocs_mode: bool,
260 ) -> Vec<(usize, usize, usize, String)> {
261 let mut undefined = Vec::new();
262 let mut reported_refs = HashMap::new();
263 let mut in_code_block = false;
264 let mut code_fence_marker = String::new();
265 let mut in_example_section = false;
266
267 let code_spans = ctx.code_spans();
269
270 for link in &ctx.links {
272 if !link.is_reference {
273 continue; }
275
276 if Self::is_in_code_span(link.line, link.start_col, &code_spans) {
278 continue;
279 }
280
281 if Self::is_in_html_comment(content, link.byte_offset) {
283 continue;
284 }
285
286 if Self::is_in_html_tag(ctx, link.byte_offset) {
288 continue;
289 }
290
291 if is_in_math_context(ctx, link.byte_offset) {
293 continue;
294 }
295
296 if is_in_table_cell(ctx, link.line, link.start_col) {
298 continue;
299 }
300
301 if is_in_front_matter(content, link.line.saturating_sub(1)) {
303 continue;
304 }
305
306 if let Some(ref_id) = &link.reference_id {
307 let reference_lower = ref_id.to_lowercase();
308
309 if mkdocs_mode && (is_mkdocs_auto_reference(ref_id) || is_mkdocs_auto_reference(&link.text)) {
312 continue;
313 }
314
315 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
317 if let Some(line_info) = ctx.line_info(link.line) {
319 if OUTPUT_EXAMPLE_START.is_match(&line_info.content) {
320 in_example_section = true;
321 continue;
322 }
323
324 if in_example_section {
325 continue;
326 }
327
328 if LIST_ITEM_REGEX.is_match(&line_info.content) {
330 continue;
331 }
332
333 let trimmed = line_info.content.trim_start();
335 if trimmed.starts_with('<') {
336 continue;
337 }
338 }
339
340 let match_len = link.byte_end - link.byte_offset;
341 undefined.push((link.line - 1, link.start_col, match_len, ref_id.clone()));
342 reported_refs.insert(reference_lower, true);
343 }
344 }
345 }
346
347 for image in &ctx.images {
349 if !image.is_reference {
350 continue; }
352
353 if Self::is_in_code_span(image.line, image.start_col, &code_spans) {
355 continue;
356 }
357
358 if Self::is_in_html_comment(content, image.byte_offset) {
360 continue;
361 }
362
363 if Self::is_in_html_tag(ctx, image.byte_offset) {
365 continue;
366 }
367
368 if is_in_math_context(ctx, image.byte_offset) {
370 continue;
371 }
372
373 if is_in_table_cell(ctx, image.line, image.start_col) {
375 continue;
376 }
377
378 if is_in_front_matter(content, image.line.saturating_sub(1)) {
380 continue;
381 }
382
383 if let Some(ref_id) = &image.reference_id {
384 let reference_lower = ref_id.to_lowercase();
385
386 if mkdocs_mode && (is_mkdocs_auto_reference(ref_id) || is_mkdocs_auto_reference(&image.alt_text)) {
389 continue;
390 }
391
392 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
394 if let Some(line_info) = ctx.line_info(image.line) {
396 if OUTPUT_EXAMPLE_START.is_match(&line_info.content) {
397 in_example_section = true;
398 continue;
399 }
400
401 if in_example_section {
402 continue;
403 }
404
405 if LIST_ITEM_REGEX.is_match(&line_info.content) {
407 continue;
408 }
409
410 let trimmed = line_info.content.trim_start();
412 if trimmed.starts_with('<') {
413 continue;
414 }
415 }
416
417 let match_len = image.byte_end - image.byte_offset;
418 undefined.push((image.line - 1, image.start_col, match_len, ref_id.clone()));
419 reported_refs.insert(reference_lower, true);
420 }
421 }
422 }
423
424 let mut covered_ranges: Vec<(usize, usize)> = Vec::new();
426
427 for link in &ctx.links {
429 covered_ranges.push((link.byte_offset, link.byte_end));
430 }
431
432 for image in &ctx.images {
434 covered_ranges.push((image.byte_offset, image.byte_end));
435 }
436
437 covered_ranges.sort_by_key(|&(start, _)| start);
439
440 let lines: Vec<&str> = content.lines().collect();
443 in_example_section = false; for (line_num, line) in lines.iter().enumerate() {
446 if is_in_front_matter(content, line_num) {
448 continue;
449 }
450
451 if let Some(cap) = FENCED_CODE_START.captures(line) {
453 if let Some(fence) = cap.get(2) {
454 let fence_str = fence.as_str();
456 if !in_code_block {
457 in_code_block = true;
458 code_fence_marker = fence_str.to_string();
459 } else if line.trim_start().starts_with(&code_fence_marker) {
460 let trimmed = line.trim_start();
462 if trimmed.starts_with(&code_fence_marker) {
464 let after_fence = &trimmed[code_fence_marker.len()..];
465 if after_fence.trim().is_empty() {
466 in_code_block = false;
467 code_fence_marker.clear();
468 }
469 }
470 }
471 }
472 continue;
473 }
474
475 if in_code_block {
476 continue;
477 }
478
479 if OUTPUT_EXAMPLE_START.is_match(line) {
481 in_example_section = true;
482 continue;
483 }
484
485 if in_example_section {
486 if line.starts_with('#') && !OUTPUT_EXAMPLE_START.is_match(line) {
488 in_example_section = false;
489 } else {
490 continue;
491 }
492 }
493
494 if LIST_ITEM_REGEX.is_match(line) {
496 continue;
497 }
498
499 let trimmed_line = line.trim_start();
501 if trimmed_line.starts_with('<') {
502 continue;
503 }
504
505 if GITHUB_ALERT_REGEX.is_match(line) {
507 continue;
508 }
509
510 if trimmed_line.starts_with("*[") {
513 continue;
514 }
515
516 let mut url_bracket_ranges: Vec<(usize, usize)> = Vec::new();
519 for mat in URL_WITH_BRACKETS.find_iter(line) {
520 let url_str = mat.as_str();
522 let url_start = mat.start();
523
524 let mut idx = 0;
526 while idx < url_str.len() {
527 if let Some(bracket_start) = url_str[idx..].find('[') {
528 let bracket_start_abs = url_start + idx + bracket_start;
529 if let Some(bracket_end) = url_str[idx + bracket_start + 1..].find(']') {
530 let bracket_end_abs = url_start + idx + bracket_start + 1 + bracket_end + 1;
531 url_bracket_ranges.push((bracket_start_abs, bracket_end_abs));
532 idx += bracket_start + bracket_end + 2;
533 } else {
534 break;
535 }
536 } else {
537 break;
538 }
539 }
540 }
541
542 if let Ok(captures) = SHORTCUT_REF_REGEX.captures_iter(line).collect::<Result<Vec<_>, _>>() {
544 for cap in captures {
545 if let Some(ref_match) = cap.get(1) {
546 let bracket_start = cap.get(0).unwrap().start();
548 let bracket_end = cap.get(0).unwrap().end();
549
550 let is_in_url = url_bracket_ranges
552 .iter()
553 .any(|&(url_start, url_end)| bracket_start >= url_start && bracket_end <= url_end);
554
555 if is_in_url {
556 continue;
557 }
558
559 let reference = ref_match.as_str();
560 let reference_lower = reference.to_lowercase();
561
562 if Self::is_likely_not_reference(reference) {
564 continue;
565 }
566
567 if let Some(alert_type) = reference.strip_prefix('!')
569 && matches!(
570 alert_type,
571 "NOTE"
572 | "TIP"
573 | "WARNING"
574 | "IMPORTANT"
575 | "CAUTION"
576 | "INFO"
577 | "SUCCESS"
578 | "FAILURE"
579 | "DANGER"
580 | "BUG"
581 | "EXAMPLE"
582 | "QUOTE"
583 )
584 {
585 continue;
586 }
587
588 if mkdocs_mode
591 && (reference.starts_with("start:") || reference.starts_with("end:"))
592 && (crate::utils::mkdocs_snippets::is_snippet_section_start(line)
593 || crate::utils::mkdocs_snippets::is_snippet_section_end(line))
594 {
595 continue;
596 }
597
598 if mkdocs_mode && is_mkdocs_auto_reference(reference) {
600 continue;
601 }
602
603 if !references.contains(&reference_lower) && !reported_refs.contains_key(&reference_lower) {
604 let full_match = cap.get(0).unwrap();
605 let col = full_match.start();
606
607 let code_spans = ctx.code_spans();
609 if Self::is_in_code_span(line_num + 1, col, &code_spans) {
610 continue;
611 }
612
613 let line_start_byte = ctx.line_offsets[line_num];
615 let byte_pos = line_start_byte + col;
616
617 if Self::is_in_html_comment(content, byte_pos) {
619 continue;
620 }
621
622 if Self::is_in_html_tag(ctx, byte_pos) {
624 continue;
625 }
626
627 if is_in_math_context(ctx, byte_pos) {
629 continue;
630 }
631
632 if is_in_table_cell(ctx, line_num + 1, col) {
634 continue;
635 }
636
637 let byte_end = byte_pos + (full_match.end() - full_match.start());
638
639 let mut is_covered = false;
641 for &(range_start, range_end) in &covered_ranges {
642 if range_start <= byte_pos && byte_end <= range_end {
643 is_covered = true;
645 break;
646 }
647 if range_start > byte_end {
648 break;
650 }
651 }
652
653 if is_covered {
654 continue;
655 }
656
657 let line_chars: Vec<char> = line.chars().collect();
662 if col > 0 && col <= line_chars.len() && line_chars.get(col - 1) == Some(&']') {
663 let mut bracket_count = 1; let mut check_pos = col.saturating_sub(2);
666 let mut found_opening = false;
667
668 while check_pos > 0 && check_pos < line_chars.len() {
669 match line_chars.get(check_pos) {
670 Some(&']') => bracket_count += 1,
671 Some(&'[') => {
672 bracket_count -= 1;
673 if bracket_count == 0 {
674 if check_pos == 0 || line_chars.get(check_pos - 1) != Some(&'\\') {
676 found_opening = true;
677 }
678 break;
679 }
680 }
681 _ => {}
682 }
683 if check_pos == 0 {
684 break;
685 }
686 check_pos = check_pos.saturating_sub(1);
687 }
688
689 if found_opening {
690 continue;
692 }
693 }
694
695 let before_text = &line[..col];
698 if before_text.contains("\\]") {
699 if let Some(escaped_close_pos) = before_text.rfind("\\]") {
701 let search_text = &before_text[..escaped_close_pos];
702 if search_text.contains("\\[") {
703 continue;
705 }
706 }
707 }
708
709 let match_len = full_match.end() - full_match.start();
710 undefined.push((line_num, col, match_len, reference.to_string()));
711 reported_refs.insert(reference_lower, true);
712 }
713 }
714 }
715 }
716 }
717
718 undefined
719 }
720}
721
722impl Rule for MD052ReferenceLinkImages {
723 fn name(&self) -> &'static str {
724 "MD052"
725 }
726
727 fn description(&self) -> &'static str {
728 "Reference links and images should use a reference that exists"
729 }
730
731 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
732 let content = ctx.content;
733 let mut warnings = Vec::new();
734
735 let has_reference_links = ctx.links.iter().any(|l| l.is_reference);
738 let has_reference_images = ctx.images.iter().any(|i| i.is_reference);
739
740 if !content.contains('[') {
742 return Ok(warnings);
743 }
744
745 let has_reference_definitions = content.contains("]:");
747
748 if !has_reference_links && !has_reference_images && !has_reference_definitions {
751 let all_brackets_are_inline = ctx.links.iter().all(|l| !l.is_reference)
754 && ctx.images.iter().all(|i| !i.is_reference)
755 && ctx.links.len() + ctx.images.len() > 0;
756
757 if all_brackets_are_inline {
758 return Ok(warnings); }
760 }
761
762 let mkdocs_mode = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
764
765 let references = self.extract_references(content, mkdocs_mode);
766
767 for (line_num, col, match_len, reference) in
769 self.find_undefined_references(content, &references, ctx, mkdocs_mode)
770 {
771 let lines: Vec<&str> = content.lines().collect();
772 let line_content = lines.get(line_num).unwrap_or(&"");
773
774 let (start_line, start_col, end_line, end_col) =
776 calculate_match_range(line_num + 1, line_content, col, match_len);
777
778 warnings.push(LintWarning {
779 rule_name: Some(self.name()),
780 line: start_line,
781 column: start_col,
782 end_line,
783 end_column: end_col,
784 message: format!("Reference '{reference}' not found"),
785 severity: Severity::Warning,
786 fix: None,
787 });
788 }
789
790 Ok(warnings)
791 }
792
793 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
795 ctx.content.is_empty() || (!ctx.content.contains("](") && !ctx.content.contains("]["))
797 }
798
799 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
800 let content = ctx.content;
801 Ok(content.to_string())
803 }
804
805 fn as_any(&self) -> &dyn std::any::Any {
806 self
807 }
808
809 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
810 where
811 Self: Sized,
812 {
813 Box::new(MD052ReferenceLinkImages::new())
815 }
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821 use crate::lint_context::LintContext;
822
823 #[test]
824 fn test_valid_reference_link() {
825 let rule = MD052ReferenceLinkImages::new();
826 let content = "[text][ref]\n\n[ref]: https://example.com";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
828 let result = rule.check(&ctx).unwrap();
829
830 assert_eq!(result.len(), 0);
831 }
832
833 #[test]
834 fn test_undefined_reference_link() {
835 let rule = MD052ReferenceLinkImages::new();
836 let content = "[text][undefined]";
837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
838 let result = rule.check(&ctx).unwrap();
839
840 assert_eq!(result.len(), 1);
841 assert!(result[0].message.contains("Reference 'undefined' not found"));
842 }
843
844 #[test]
845 fn test_valid_reference_image() {
846 let rule = MD052ReferenceLinkImages::new();
847 let content = "![alt][img]\n\n[img]: image.jpg";
848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
849 let result = rule.check(&ctx).unwrap();
850
851 assert_eq!(result.len(), 0);
852 }
853
854 #[test]
855 fn test_undefined_reference_image() {
856 let rule = MD052ReferenceLinkImages::new();
857 let content = "![alt][missing]";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
859 let result = rule.check(&ctx).unwrap();
860
861 assert_eq!(result.len(), 1);
862 assert!(result[0].message.contains("Reference 'missing' not found"));
863 }
864
865 #[test]
866 fn test_case_insensitive_references() {
867 let rule = MD052ReferenceLinkImages::new();
868 let content = "[Text][REF]\n\n[ref]: https://example.com";
869 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
870 let result = rule.check(&ctx).unwrap();
871
872 assert_eq!(result.len(), 0);
873 }
874
875 #[test]
876 fn test_shortcut_reference_valid() {
877 let rule = MD052ReferenceLinkImages::new();
878 let content = "[ref]\n\n[ref]: https://example.com";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
880 let result = rule.check(&ctx).unwrap();
881
882 assert_eq!(result.len(), 0);
883 }
884
885 #[test]
886 fn test_shortcut_reference_undefined() {
887 let rule = MD052ReferenceLinkImages::new();
888 let content = "[undefined]";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
890 let result = rule.check(&ctx).unwrap();
891
892 assert_eq!(result.len(), 1);
893 assert!(result[0].message.contains("Reference 'undefined' not found"));
894 }
895
896 #[test]
897 fn test_inline_links_ignored() {
898 let rule = MD052ReferenceLinkImages::new();
899 let content = "[text](https://example.com)";
900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
901 let result = rule.check(&ctx).unwrap();
902
903 assert_eq!(result.len(), 0);
904 }
905
906 #[test]
907 fn test_inline_images_ignored() {
908 let rule = MD052ReferenceLinkImages::new();
909 let content = "";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
911 let result = rule.check(&ctx).unwrap();
912
913 assert_eq!(result.len(), 0);
914 }
915
916 #[test]
917 fn test_references_in_code_blocks_ignored() {
918 let rule = MD052ReferenceLinkImages::new();
919 let content = "```\n[undefined]\n```\n\n[ref]: https://example.com";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
921 let result = rule.check(&ctx).unwrap();
922
923 assert_eq!(result.len(), 0);
924 }
925
926 #[test]
927 fn test_references_in_inline_code_ignored() {
928 let rule = MD052ReferenceLinkImages::new();
929 let content = "`[undefined]`";
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
931 let result = rule.check(&ctx).unwrap();
932
933 assert_eq!(result.len(), 0);
935 }
936
937 #[test]
938 fn test_comprehensive_inline_code_detection() {
939 let rule = MD052ReferenceLinkImages::new();
940 let content = r#"# Test
941
942This `[inside]` should be ignored.
943This [outside] should be flagged.
944Reference links `[text][ref]` in code are ignored.
945Regular reference [text][missing] should be flagged.
946Images `![alt][img]` in code are ignored.
947Regular image ![alt][badimg] should be flagged.
948
949Multiple `[one]` and `[two]` in code ignored, but [three] is not.
950
951```
952[code block content] should be ignored
953```
954
955`Multiple [refs] in [same] code span` ignored."#;
956
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
958 let result = rule.check(&ctx).unwrap();
959
960 assert_eq!(result.len(), 4);
962
963 let messages: Vec<&str> = result.iter().map(|w| &*w.message).collect();
964 assert!(messages.iter().any(|m| m.contains("outside")));
965 assert!(messages.iter().any(|m| m.contains("missing")));
966 assert!(messages.iter().any(|m| m.contains("badimg")));
967 assert!(messages.iter().any(|m| m.contains("three")));
968
969 assert!(!messages.iter().any(|m| m.contains("inside")));
971 assert!(!messages.iter().any(|m| m.contains("one")));
972 assert!(!messages.iter().any(|m| m.contains("two")));
973 assert!(!messages.iter().any(|m| m.contains("refs")));
974 assert!(!messages.iter().any(|m| m.contains("same")));
975 }
976
977 #[test]
978 fn test_multiple_undefined_references() {
979 let rule = MD052ReferenceLinkImages::new();
980 let content = "[link1][ref1] [link2][ref2] [link3][ref3]";
981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
982 let result = rule.check(&ctx).unwrap();
983
984 assert_eq!(result.len(), 3);
985 assert!(result[0].message.contains("ref1"));
986 assert!(result[1].message.contains("ref2"));
987 assert!(result[2].message.contains("ref3"));
988 }
989
990 #[test]
991 fn test_mixed_valid_and_undefined() {
992 let rule = MD052ReferenceLinkImages::new();
993 let content = "[valid][ref] [invalid][missing]\n\n[ref]: https://example.com";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
995 let result = rule.check(&ctx).unwrap();
996
997 assert_eq!(result.len(), 1);
998 assert!(result[0].message.contains("missing"));
999 }
1000
1001 #[test]
1002 fn test_empty_reference() {
1003 let rule = MD052ReferenceLinkImages::new();
1004 let content = "[text][]\n\n[ref]: https://example.com";
1005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1006 let result = rule.check(&ctx).unwrap();
1007
1008 assert_eq!(result.len(), 1);
1010 }
1011
1012 #[test]
1013 fn test_escaped_brackets_ignored() {
1014 let rule = MD052ReferenceLinkImages::new();
1015 let content = "\\[not a link\\]";
1016 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1017 let result = rule.check(&ctx).unwrap();
1018
1019 assert_eq!(result.len(), 0);
1020 }
1021
1022 #[test]
1023 fn test_list_items_ignored() {
1024 let rule = MD052ReferenceLinkImages::new();
1025 let content = "- [undefined]\n* [another]\n+ [third]";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1027 let result = rule.check(&ctx).unwrap();
1028
1029 assert_eq!(result.len(), 0);
1031 }
1032
1033 #[test]
1034 fn test_output_example_section_ignored() {
1035 let rule = MD052ReferenceLinkImages::new();
1036 let content = "## Output\n\n[undefined]\n\n## Normal Section\n\n[missing]";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1038 let result = rule.check(&ctx).unwrap();
1039
1040 assert_eq!(result.len(), 1);
1042 assert!(result[0].message.contains("missing"));
1043 }
1044
1045 #[test]
1046 fn test_reference_definitions_in_code_blocks_ignored() {
1047 let rule = MD052ReferenceLinkImages::new();
1048 let content = "[link][ref]\n\n```\n[ref]: https://example.com\n```";
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1050 let result = rule.check(&ctx).unwrap();
1051
1052 assert_eq!(result.len(), 1);
1054 assert!(result[0].message.contains("ref"));
1055 }
1056
1057 #[test]
1058 fn test_multiple_references_to_same_undefined() {
1059 let rule = MD052ReferenceLinkImages::new();
1060 let content = "[first][missing] [second][missing] [third][missing]";
1061 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1062 let result = rule.check(&ctx).unwrap();
1063
1064 assert_eq!(result.len(), 1);
1066 assert!(result[0].message.contains("missing"));
1067 }
1068
1069 #[test]
1070 fn test_reference_with_special_characters() {
1071 let rule = MD052ReferenceLinkImages::new();
1072 let content = "[text][ref-with-hyphens]\n\n[ref-with-hyphens]: https://example.com";
1073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1074 let result = rule.check(&ctx).unwrap();
1075
1076 assert_eq!(result.len(), 0);
1077 }
1078
1079 #[test]
1080 fn test_issue_51_html_attribute_not_reference() {
1081 let rule = MD052ReferenceLinkImages::new();
1083 let content = r#"# Example
1084
1085## Test
1086
1087Want to fill out this form?
1088
1089<form method="post">
1090 <input type="email" name="fields[email]" id="drip-email" placeholder="email@domain.com">
1091</form>"#;
1092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1093 let result = rule.check(&ctx).unwrap();
1094
1095 assert_eq!(
1096 result.len(),
1097 0,
1098 "HTML attributes with square brackets should not be flagged as undefined references"
1099 );
1100 }
1101
1102 #[test]
1103 fn test_extract_references() {
1104 let rule = MD052ReferenceLinkImages::new();
1105 let content = "[ref1]: url1\n[Ref2]: url2\n[REF3]: url3";
1106 let refs = rule.extract_references(content, false);
1107
1108 assert_eq!(refs.len(), 3);
1109 assert!(refs.contains("ref1"));
1110 assert!(refs.contains("ref2"));
1111 assert!(refs.contains("ref3"));
1112 }
1113
1114 #[test]
1115 fn test_inline_code_not_flagged() {
1116 let rule = MD052ReferenceLinkImages::new();
1117
1118 let content = r#"# Test
1120
1121Configure with `["JavaScript", "GitHub", "Node.js"]` in your settings.
1122
1123Also, `[todo]` is not a reference link.
1124
1125But this [reference] should be flagged.
1126
1127And this `[inline code]` should not be flagged.
1128"#;
1129
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1131 let warnings = rule.check(&ctx).unwrap();
1132
1133 assert_eq!(warnings.len(), 1, "Should only flag one undefined reference");
1135 assert!(warnings[0].message.contains("'reference'"));
1136 }
1137
1138 #[test]
1139 fn test_code_block_references_ignored() {
1140 let rule = MD052ReferenceLinkImages::new();
1141
1142 let content = r#"# Test
1143
1144```markdown
1145[undefined] reference in code block
1146![undefined] image in code block
1147```
1148
1149[real-undefined] reference outside
1150"#;
1151
1152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1153 let warnings = rule.check(&ctx).unwrap();
1154
1155 assert_eq!(warnings.len(), 1);
1157 assert!(warnings[0].message.contains("'real-undefined'"));
1158 }
1159
1160 #[test]
1161 fn test_html_comments_ignored() {
1162 let rule = MD052ReferenceLinkImages::new();
1164
1165 let content = r#"<!--- write fake_editor.py 'import sys\nopen(*sys.argv[1:], mode="wt").write("2 3 4 4 2 3 2")' -->
1167<!--- set_env EDITOR 'python3 fake_editor.py' -->
1168
1169```bash
1170$ python3 vote.py
11713 votes for: 2
11722 votes for: 3, 4
1173```"#;
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1175 let result = rule.check(&ctx).unwrap();
1176 assert_eq!(result.len(), 0, "Should not flag [1:] inside HTML comments");
1177
1178 let content = r#"<!-- This is [ref1] and [ref2][ref3] -->
1180Normal [text][undefined]
1181<!-- Another [comment][with] references -->"#;
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1183 let result = rule.check(&ctx).unwrap();
1184 assert_eq!(
1185 result.len(),
1186 1,
1187 "Should only flag the undefined reference outside comments"
1188 );
1189 assert!(result[0].message.contains("undefined"));
1190
1191 let content = r#"<!--
1193[ref1]
1194[ref2][ref3]
1195-->
1196[actual][undefined]"#;
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1198 let result = rule.check(&ctx).unwrap();
1199 assert_eq!(
1200 result.len(),
1201 1,
1202 "Should not flag references in multi-line HTML comments"
1203 );
1204 assert!(result[0].message.contains("undefined"));
1205
1206 let content = r#"<!-- Comment with [1:] pattern -->
1208Valid [link][ref]
1209<!-- More [refs][in][comments] -->
1210![image][missing]
1211
1212[ref]: https://example.com"#;
1213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1214 let result = rule.check(&ctx).unwrap();
1215 assert_eq!(result.len(), 1, "Should only flag missing image reference");
1216 assert!(result[0].message.contains("missing"));
1217 }
1218
1219 #[test]
1220 fn test_frontmatter_ignored() {
1221 let rule = MD052ReferenceLinkImages::new();
1223
1224 let content = r#"---
1226layout: post
1227title: "My Jekyll Post"
1228date: 2023-01-01
1229categories: blog
1230tags: ["test", "example"]
1231author: John Doe
1232---
1233
1234# My Blog Post
1235
1236This is the actual markdown content that should be linted.
1237
1238[undefined] reference should be flagged.
1239
1240## Section 1
1241
1242Some content here."#;
1243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1244 let result = rule.check(&ctx).unwrap();
1245
1246 assert_eq!(
1248 result.len(),
1249 1,
1250 "Should only flag the undefined reference outside frontmatter"
1251 );
1252 assert!(result[0].message.contains("undefined"));
1253
1254 let content = r#"+++
1256title = "My Post"
1257tags = ["example", "test"]
1258+++
1259
1260# Content
1261
1262[missing] reference should be flagged."#;
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1264 let result = rule.check(&ctx).unwrap();
1265 assert_eq!(
1266 result.len(),
1267 1,
1268 "Should only flag the undefined reference outside TOML frontmatter"
1269 );
1270 assert!(result[0].message.contains("missing"));
1271 }
1272
1273 #[test]
1274 fn test_mkdocs_snippet_markers_not_flagged() {
1275 let rule = MD052ReferenceLinkImages::new();
1277
1278 let content = r#"# Document with MkDocs Snippets
1280
1281Some content here.
1282
1283# -8<- [start:remote-content]
1284
1285This is the remote content section.
1286
1287# -8<- [end:remote-content]
1288
1289More content here.
1290
1291<!-- --8<-- [start:another-section] -->
1292Content in another section
1293<!-- --8<-- [end:another-section] -->"#;
1294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1295 let result = rule.check(&ctx).unwrap();
1296
1297 assert_eq!(
1299 result.len(),
1300 0,
1301 "Should not flag MkDocs snippet markers as undefined references"
1302 );
1303
1304 let content = r#"# Document
1307
1308# -8<- [start:section]
1309Content with [reference] inside snippet section
1310# -8<- [end:section]
1311
1312Regular [undefined] reference outside snippet markers."#;
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs);
1314 let result = rule.check(&ctx).unwrap();
1315
1316 assert_eq!(
1317 result.len(),
1318 2,
1319 "Should flag undefined references but skip snippet marker lines"
1320 );
1321 assert!(result[0].message.contains("reference"));
1323 assert!(result[1].message.contains("undefined"));
1324
1325 let content = r#"# Document
1327
1328# -8<- [start:section]
1329# -8<- [end:section]"#;
1330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1331 let result = rule.check(&ctx).unwrap();
1332
1333 assert_eq!(
1334 result.len(),
1335 2,
1336 "In standard mode, snippet markers should be flagged as undefined references"
1337 );
1338 }
1339
1340 #[test]
1341 fn test_github_alerts_not_flagged() {
1342 let rule = MD052ReferenceLinkImages::new();
1344
1345 let content = r#"# Document with GitHub Alerts
1347
1348> [!NOTE]
1349> This is a note alert.
1350
1351> [!TIP]
1352> This is a tip alert.
1353
1354> [!IMPORTANT]
1355> This is an important alert.
1356
1357> [!WARNING]
1358> This is a warning alert.
1359
1360> [!CAUTION]
1361> This is a caution alert.
1362
1363Regular content with [undefined] reference."#;
1364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1365 let result = rule.check(&ctx).unwrap();
1366
1367 assert_eq!(
1369 result.len(),
1370 1,
1371 "Should only flag the undefined reference, not GitHub alerts"
1372 );
1373 assert!(result[0].message.contains("undefined"));
1374 assert_eq!(result[0].line, 18); let content = r#"> [!TIP]
1378> Here's a useful tip about [something].
1379> Multiple lines are allowed.
1380
1381[something] is mentioned but not defined."#;
1382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1383 let result = rule.check(&ctx).unwrap();
1384
1385 assert_eq!(result.len(), 1, "Should flag undefined reference");
1389 assert!(result[0].message.contains("something"));
1390
1391 let content = r#"> [!NOTE]
1393> See [reference] for more details.
1394
1395[reference]: https://example.com"#;
1396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1397 let result = rule.check(&ctx).unwrap();
1398
1399 assert_eq!(result.len(), 0, "Should not flag GitHub alerts or defined references");
1401 }
1402}