1use crate::rule::{CrossFileScope, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::anchor_styles::AnchorStyle;
3use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex};
4use pulldown_cmark::LinkType;
5use regex::Regex;
6use std::collections::{HashMap, HashSet};
7use std::path::{Component, Path, PathBuf};
8use std::sync::LazyLock;
9static HTML_ANCHOR_PATTERN: LazyLock<Regex> =
12 LazyLock::new(|| Regex::new(r#"\b(?:id|name)\s*=\s*["']([^"']+)["']"#).unwrap());
13
14static ATTR_ANCHOR_PATTERN: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r#"\{\s*#([a-zA-Z][a-zA-Z0-9_-]*)[^}]*\}"#).unwrap());
19
20fn normalize_path(path: &Path) -> PathBuf {
22 let mut result = PathBuf::new();
23 for component in path.components() {
24 match component {
25 Component::CurDir => {} Component::ParentDir => {
27 result.pop(); }
29 c => result.push(c.as_os_str()),
30 }
31 }
32 result
33}
34
35#[derive(Clone)]
42pub struct MD051LinkFragments {
43 anchor_style: AnchorStyle,
45}
46
47impl Default for MD051LinkFragments {
48 fn default() -> Self {
49 Self::new()
50 }
51}
52
53impl MD051LinkFragments {
54 pub fn new() -> Self {
55 Self {
56 anchor_style: AnchorStyle::GitHub,
57 }
58 }
59
60 pub fn with_anchor_style(style: AnchorStyle) -> Self {
62 Self { anchor_style: style }
63 }
64
65 fn extract_headings_from_context(
69 &self,
70 ctx: &crate::lint_context::LintContext,
71 ) -> (HashSet<String>, HashSet<String>) {
72 let mut markdown_headings = HashSet::with_capacity(32);
73 let mut html_anchors = HashSet::with_capacity(16);
74 let mut fragment_counts = std::collections::HashMap::new();
75
76 for line_info in &ctx.lines {
77 if line_info.in_front_matter {
78 continue;
79 }
80
81 if line_info.in_code_block {
83 continue;
84 }
85
86 let content = line_info.content(ctx.content);
87 let bytes = content.as_bytes();
88
89 if bytes.contains(&b'<') && (content.contains("id=") || content.contains("name=")) {
91 let mut pos = 0;
94 while pos < content.len() {
95 if let Some(start) = content[pos..].find('<') {
96 let tag_start = pos + start;
97 if let Some(end) = content[tag_start..].find('>') {
98 let tag_end = tag_start + end + 1;
99 let tag = &content[tag_start..tag_end];
100
101 if let Some(caps) = HTML_ANCHOR_PATTERN.find(tag) {
103 let matched_text = caps.as_str();
104 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(matched_text)
105 && let Some(id_match) = caps.get(1)
106 {
107 let id = id_match.as_str();
108 if !id.is_empty() {
109 html_anchors.insert(id.to_string());
110 }
111 }
112 }
113 pos = tag_end;
114 } else {
115 break;
116 }
117 } else {
118 break;
119 }
120 }
121 }
122
123 if line_info.heading.is_none() && content.contains('{') && content.contains('#') {
126 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
127 if let Some(id_match) = caps.get(1) {
128 markdown_headings.insert(id_match.as_str().to_lowercase());
130 }
131 }
132 }
133
134 if let Some(heading) = &line_info.heading {
136 if let Some(custom_id) = &heading.custom_id {
138 markdown_headings.insert(custom_id.to_lowercase());
139 }
140
141 let fragment = self.anchor_style.generate_fragment(&heading.text);
145
146 if !fragment.is_empty() {
147 let final_fragment = if let Some(count) = fragment_counts.get_mut(&fragment) {
149 let suffix = *count;
150 *count += 1;
151 format!("{fragment}-{suffix}")
152 } else {
153 fragment_counts.insert(fragment.clone(), 1);
154 fragment
155 };
156 markdown_headings.insert(final_fragment);
157 }
158 }
159 }
160
161 (markdown_headings, html_anchors)
162 }
163
164 #[inline]
166 fn is_external_url_fast(url: &str) -> bool {
167 url.starts_with("http://")
169 || url.starts_with("https://")
170 || url.starts_with("ftp://")
171 || url.starts_with("mailto:")
172 || url.starts_with("tel:")
173 || url.starts_with("//")
174 }
175
176 #[inline]
184 fn resolve_path_with_extensions(path: &Path, extensions: &[&str]) -> Vec<PathBuf> {
185 if path.extension().is_none() {
186 let mut paths = Vec::with_capacity(extensions.len() + 1);
188 paths.push(path.to_path_buf());
190 for ext in extensions {
192 let path_with_ext = path.with_extension(&ext[1..]); paths.push(path_with_ext);
194 }
195 paths
196 } else {
197 vec![path.to_path_buf()]
199 }
200 }
201
202 #[inline]
216 fn is_extensionless_path(path_part: &str) -> bool {
217 if path_part.is_empty()
219 || path_part.contains('.')
220 || path_part.contains('?')
221 || path_part.contains('&')
222 || path_part.contains('=')
223 {
224 return false;
225 }
226
227 let mut has_alphanumeric = false;
229 for c in path_part.chars() {
230 if c.is_alphanumeric() {
231 has_alphanumeric = true;
232 } else if !matches!(c, '/' | '\\' | '-' | '_') {
233 return false;
235 }
236 }
237
238 has_alphanumeric
240 }
241
242 #[inline]
244 fn is_cross_file_link(url: &str) -> bool {
245 if let Some(fragment_pos) = url.find('#') {
246 let path_part = &url[..fragment_pos];
247
248 if path_part.is_empty() {
250 return false;
251 }
252
253 if let Some(tag_start) = path_part.find("{%")
259 && path_part[tag_start + 2..].contains("%}")
260 {
261 return true;
262 }
263 if let Some(var_start) = path_part.find("{{")
264 && path_part[var_start + 2..].contains("}}")
265 {
266 return true;
267 }
268
269 if path_part.starts_with('/') {
272 return true;
273 }
274
275 let has_extension = path_part.contains('.')
281 && (
282 {
284 let clean_path = path_part.split('?').next().unwrap_or(path_part);
285 if let Some(after_dot) = clean_path.strip_prefix('.') {
287 let dots_count = clean_path.matches('.').count();
288 if dots_count == 1 {
289 !after_dot.is_empty() && after_dot.len() <= 10 &&
292 after_dot.chars().all(|c| c.is_ascii_alphanumeric())
293 } else {
294 clean_path.split('.').next_back().is_some_and(|ext| {
296 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
297 })
298 }
299 } else {
300 clean_path.split('.').next_back().is_some_and(|ext| {
302 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
303 })
304 }
305 } ||
306 path_part.contains('/') || path_part.contains('\\') ||
308 path_part.starts_with("./") || path_part.starts_with("../")
310 );
311
312 let is_extensionless = Self::is_extensionless_path(path_part);
315
316 has_extension || is_extensionless
317 } else {
318 false
319 }
320 }
321}
322
323impl Rule for MD051LinkFragments {
324 fn name(&self) -> &'static str {
325 "MD051"
326 }
327
328 fn description(&self) -> &'static str {
329 "Link fragments should reference valid headings"
330 }
331
332 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
333 if !ctx.likely_has_links_or_images() {
335 return true;
336 }
337 !ctx.has_char('#')
339 }
340
341 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
342 let mut warnings = Vec::new();
343
344 if ctx.content.is_empty() || ctx.links.is_empty() || self.should_skip(ctx) {
345 return Ok(warnings);
346 }
347
348 let (markdown_headings, html_anchors) = self.extract_headings_from_context(ctx);
349
350 for link in &ctx.links {
351 if link.is_reference {
352 continue;
353 }
354
355 if matches!(link.link_type, LinkType::WikiLink { .. }) {
357 continue;
358 }
359
360 if ctx.is_in_jinja_range(link.byte_offset) {
362 continue;
363 }
364
365 if ctx.flavor == crate::config::MarkdownFlavor::Quarto && ctx.is_in_citation(link.byte_offset) {
368 continue;
369 }
370
371 if ctx.is_in_shortcode(link.byte_offset) {
374 continue;
375 }
376
377 let url = &link.url;
378
379 if !url.contains('#') || Self::is_external_url_fast(url) {
381 continue;
382 }
383
384 if url.contains("{{#") && url.contains("}}") {
387 continue;
388 }
389
390 if url.starts_with('@') {
394 continue;
395 }
396
397 if Self::is_cross_file_link(url) {
399 continue;
400 }
401
402 let Some(fragment_pos) = url.find('#') else {
403 continue;
404 };
405
406 let fragment = &url[fragment_pos + 1..];
407
408 if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
410 continue;
411 }
412
413 if fragment.is_empty() {
414 continue;
415 }
416
417 let found = if html_anchors.contains(fragment) {
420 true
421 } else {
422 let fragment_lower = fragment.to_lowercase();
423 markdown_headings.contains(&fragment_lower)
424 };
425
426 if !found {
427 warnings.push(LintWarning {
428 rule_name: Some(self.name().to_string()),
429 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
430 line: link.line,
431 column: link.start_col + 1,
432 end_line: link.line,
433 end_column: link.end_col + 1,
434 severity: Severity::Error,
435 fix: None,
436 });
437 }
438 }
439
440 Ok(warnings)
441 }
442
443 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
444 Ok(ctx.content.to_string())
447 }
448
449 fn as_any(&self) -> &dyn std::any::Any {
450 self
451 }
452
453 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
454 where
455 Self: Sized,
456 {
457 let anchor_style = if let Some(rule_config) = config.rules.get("MD051") {
459 if let Some(style_str) = rule_config.values.get("anchor-style").and_then(|v| v.as_str()) {
460 match style_str.to_lowercase().as_str() {
461 "kramdown" => AnchorStyle::Kramdown,
462 "kramdown-gfm" => AnchorStyle::KramdownGfm,
463 "jekyll" => AnchorStyle::KramdownGfm, _ => AnchorStyle::GitHub,
465 }
466 } else {
467 AnchorStyle::GitHub
468 }
469 } else {
470 AnchorStyle::GitHub
471 };
472
473 Box::new(MD051LinkFragments::with_anchor_style(anchor_style))
474 }
475
476 fn category(&self) -> RuleCategory {
477 RuleCategory::Link
478 }
479
480 fn cross_file_scope(&self) -> CrossFileScope {
481 CrossFileScope::Workspace
482 }
483
484 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
485 let mut fragment_counts = HashMap::new();
486
487 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
489 if line_info.in_front_matter {
490 continue;
491 }
492
493 if line_info.in_code_block {
495 continue;
496 }
497
498 let content = line_info.content(ctx.content);
499
500 if content.contains('<') && (content.contains("id=") || content.contains("name=")) {
502 let mut pos = 0;
503 while pos < content.len() {
504 if let Some(start) = content[pos..].find('<') {
505 let tag_start = pos + start;
506 if let Some(end) = content[tag_start..].find('>') {
507 let tag_end = tag_start + end + 1;
508 let tag = &content[tag_start..tag_end];
509
510 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(tag)
511 && let Some(id_match) = caps.get(1)
512 {
513 file_index.add_html_anchor(id_match.as_str().to_string());
514 }
515 pos = tag_end;
516 } else {
517 break;
518 }
519 } else {
520 break;
521 }
522 }
523 }
524
525 if line_info.heading.is_none() && content.contains("{") && content.contains("#") {
528 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
529 if let Some(id_match) = caps.get(1) {
530 file_index.add_attribute_anchor(id_match.as_str().to_string());
531 }
532 }
533 }
534
535 if let Some(heading) = &line_info.heading {
537 let fragment = self.anchor_style.generate_fragment(&heading.text);
538
539 if !fragment.is_empty() {
540 let final_fragment = if let Some(count) = fragment_counts.get_mut(&fragment) {
542 let suffix = *count;
543 *count += 1;
544 format!("{fragment}-{suffix}")
545 } else {
546 fragment_counts.insert(fragment.clone(), 1);
547 fragment
548 };
549
550 file_index.add_heading(HeadingIndex {
551 text: heading.text.clone(),
552 auto_anchor: final_fragment,
553 custom_anchor: heading.custom_id.clone(),
554 line: line_idx + 1, });
556 }
557 }
558 }
559
560 for link in &ctx.links {
562 if link.is_reference {
563 continue;
564 }
565
566 if matches!(link.link_type, LinkType::WikiLink { .. }) {
569 continue;
570 }
571
572 let url = &link.url;
573
574 if Self::is_external_url_fast(url) {
576 continue;
577 }
578
579 if Self::is_cross_file_link(url)
581 && let Some(fragment_pos) = url.find('#')
582 {
583 let path_part = &url[..fragment_pos];
584 let fragment = &url[fragment_pos + 1..];
585
586 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
588 continue;
589 }
590
591 file_index.add_cross_file_link(CrossFileLinkIndex {
592 target_path: path_part.to_string(),
593 fragment: fragment.to_string(),
594 line: link.line,
595 column: link.start_col + 1,
596 });
597 }
598 }
599 }
600
601 fn cross_file_check(
602 &self,
603 file_path: &Path,
604 file_index: &FileIndex,
605 workspace_index: &crate::workspace_index::WorkspaceIndex,
606 ) -> LintResult {
607 let mut warnings = Vec::new();
608
609 const MARKDOWN_EXTENSIONS: &[&str] = &[
611 ".md",
612 ".markdown",
613 ".mdx",
614 ".mkd",
615 ".mkdn",
616 ".mdown",
617 ".mdwn",
618 ".qmd",
619 ".rmd",
620 ];
621
622 for cross_link in &file_index.cross_file_links {
624 if cross_link.fragment.is_empty() {
626 continue;
627 }
628
629 let base_target_path = if let Some(parent) = file_path.parent() {
631 parent.join(&cross_link.target_path)
632 } else {
633 Path::new(&cross_link.target_path).to_path_buf()
634 };
635
636 let base_target_path = normalize_path(&base_target_path);
638
639 let target_paths_to_try = Self::resolve_path_with_extensions(&base_target_path, MARKDOWN_EXTENSIONS);
642
643 let mut target_file_index = None;
645
646 for target_path in &target_paths_to_try {
647 if let Some(index) = workspace_index.get_file(target_path) {
648 target_file_index = Some(index);
649 break;
650 }
651 }
652
653 if let Some(target_file_index) = target_file_index {
654 if !target_file_index.has_anchor(&cross_link.fragment) {
656 warnings.push(LintWarning {
657 rule_name: Some(self.name().to_string()),
658 line: cross_link.line,
659 column: cross_link.column,
660 end_line: cross_link.line,
661 end_column: cross_link.column + cross_link.target_path.len() + 1 + cross_link.fragment.len(),
662 message: format!(
663 "Link fragment '{}' not found in '{}'",
664 cross_link.fragment, cross_link.target_path
665 ),
666 severity: Severity::Error,
667 fix: None,
668 });
669 }
670 }
671 }
673
674 Ok(warnings)
675 }
676
677 fn default_config_section(&self) -> Option<(String, toml::Value)> {
678 let value: toml::Value = toml::from_str(
679 r#"
680# Anchor generation style to match your target platform
681# Options: "github" (default), "kramdown-gfm", "kramdown"
682# Note: "jekyll" is accepted as an alias for "kramdown-gfm" (backward compatibility)
683anchor-style = "github"
684"#,
685 )
686 .ok()?;
687 Some(("MD051".to_string(), value))
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694 use crate::lint_context::LintContext;
695
696 #[test]
697 fn test_quarto_cross_references() {
698 let rule = MD051LinkFragments::new();
699
700 let content = r#"# Test Document
702
703## Figures
704
705See [@fig-plot] for the visualization.
706
707More details in [@tbl-results] and [@sec-methods].
708
709The equation [@eq-regression] shows the relationship.
710
711Reference to [@lst-code] for implementation."#;
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
713 let result = rule.check(&ctx).unwrap();
714 assert!(
715 result.is_empty(),
716 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
717 result.len()
718 );
719
720 let content_with_anchor = r#"# Test
722
723See [link](#test) for details."#;
724 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
725 let result_anchor = rule.check(&ctx_anchor).unwrap();
726 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
727
728 let content_invalid = r#"# Test
730
731See [link](#nonexistent) for details."#;
732 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
733 let result_invalid = rule.check(&ctx_invalid).unwrap();
734 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
735 }
736
737 #[test]
739 fn test_cross_file_scope() {
740 let rule = MD051LinkFragments::new();
741 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
742 }
743
744 #[test]
745 fn test_contribute_to_index_extracts_headings() {
746 let rule = MD051LinkFragments::new();
747 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749
750 let mut file_index = FileIndex::new();
751 rule.contribute_to_index(&ctx, &mut file_index);
752
753 assert_eq!(file_index.headings.len(), 3);
754 assert_eq!(file_index.headings[0].text, "First Heading");
755 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
756 assert!(file_index.headings[0].custom_anchor.is_none());
757
758 assert_eq!(file_index.headings[1].text, "Second");
759 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
760
761 assert_eq!(file_index.headings[2].text, "Third");
762 }
763
764 #[test]
765 fn test_contribute_to_index_extracts_cross_file_links() {
766 let rule = MD051LinkFragments::new();
767 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769
770 let mut file_index = FileIndex::new();
771 rule.contribute_to_index(&ctx, &mut file_index);
772
773 assert_eq!(file_index.cross_file_links.len(), 2);
774 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
775 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
776 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
777 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
778 }
779
780 #[test]
781 fn test_cross_file_check_valid_fragment() {
782 use crate::workspace_index::WorkspaceIndex;
783
784 let rule = MD051LinkFragments::new();
785
786 let mut workspace_index = WorkspaceIndex::new();
788 let mut target_file_index = FileIndex::new();
789 target_file_index.add_heading(HeadingIndex {
790 text: "Installation Guide".to_string(),
791 auto_anchor: "installation-guide".to_string(),
792 custom_anchor: None,
793 line: 1,
794 });
795 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
796
797 let mut current_file_index = FileIndex::new();
799 current_file_index.add_cross_file_link(CrossFileLinkIndex {
800 target_path: "install.md".to_string(),
801 fragment: "installation-guide".to_string(),
802 line: 3,
803 column: 5,
804 });
805
806 let warnings = rule
807 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
808 .unwrap();
809
810 assert!(warnings.is_empty());
812 }
813
814 #[test]
815 fn test_cross_file_check_invalid_fragment() {
816 use crate::workspace_index::WorkspaceIndex;
817
818 let rule = MD051LinkFragments::new();
819
820 let mut workspace_index = WorkspaceIndex::new();
822 let mut target_file_index = FileIndex::new();
823 target_file_index.add_heading(HeadingIndex {
824 text: "Installation Guide".to_string(),
825 auto_anchor: "installation-guide".to_string(),
826 custom_anchor: None,
827 line: 1,
828 });
829 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
830
831 let mut current_file_index = FileIndex::new();
833 current_file_index.add_cross_file_link(CrossFileLinkIndex {
834 target_path: "install.md".to_string(),
835 fragment: "nonexistent".to_string(),
836 line: 3,
837 column: 5,
838 });
839
840 let warnings = rule
841 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
842 .unwrap();
843
844 assert_eq!(warnings.len(), 1);
846 assert!(warnings[0].message.contains("nonexistent"));
847 assert!(warnings[0].message.contains("install.md"));
848 }
849
850 #[test]
851 fn test_cross_file_check_custom_anchor_match() {
852 use crate::workspace_index::WorkspaceIndex;
853
854 let rule = MD051LinkFragments::new();
855
856 let mut workspace_index = WorkspaceIndex::new();
858 let mut target_file_index = FileIndex::new();
859 target_file_index.add_heading(HeadingIndex {
860 text: "Installation Guide".to_string(),
861 auto_anchor: "installation-guide".to_string(),
862 custom_anchor: Some("install".to_string()),
863 line: 1,
864 });
865 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
866
867 let mut current_file_index = FileIndex::new();
869 current_file_index.add_cross_file_link(CrossFileLinkIndex {
870 target_path: "install.md".to_string(),
871 fragment: "install".to_string(),
872 line: 3,
873 column: 5,
874 });
875
876 let warnings = rule
877 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
878 .unwrap();
879
880 assert!(warnings.is_empty());
882 }
883
884 #[test]
885 fn test_cross_file_check_target_not_in_workspace() {
886 use crate::workspace_index::WorkspaceIndex;
887
888 let rule = MD051LinkFragments::new();
889
890 let workspace_index = WorkspaceIndex::new();
892
893 let mut current_file_index = FileIndex::new();
895 current_file_index.add_cross_file_link(CrossFileLinkIndex {
896 target_path: "external.md".to_string(),
897 fragment: "heading".to_string(),
898 line: 3,
899 column: 5,
900 });
901
902 let warnings = rule
903 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
904 .unwrap();
905
906 assert!(warnings.is_empty());
908 }
909
910 #[test]
911 fn test_wikilinks_skipped_in_check() {
912 let rule = MD051LinkFragments::new();
914
915 let content = r#"# Test Document
916
917## Valid Heading
918
919[[Microsoft#Windows OS]]
920[[SomePage#section]]
921[[page|Display Text]]
922[[path/to/page#section]]
923"#;
924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925 let result = rule.check(&ctx).unwrap();
926
927 assert!(
928 result.is_empty(),
929 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
930 );
931 }
932
933 #[test]
934 fn test_wikilinks_not_added_to_cross_file_index() {
935 let rule = MD051LinkFragments::new();
937
938 let content = r#"# Test Document
939
940[[Microsoft#Windows OS]]
941[[SomePage#section]]
942[Regular Link](other.md#section)
943"#;
944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945
946 let mut file_index = FileIndex::new();
947 rule.contribute_to_index(&ctx, &mut file_index);
948
949 let cross_file_links = &file_index.cross_file_links;
952 assert_eq!(
953 cross_file_links.len(),
954 1,
955 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
956 );
957 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
958 assert_eq!(file_index.cross_file_links[0].fragment, "section");
959 }
960}