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
20static MD_SETTING_PATTERN: LazyLock<Regex> =
23 LazyLock::new(|| Regex::new(r"<!--\s*md:setting\s+([^\s]+)\s*-->").unwrap());
24
25fn normalize_path(path: &Path) -> PathBuf {
27 let mut result = PathBuf::new();
28 for component in path.components() {
29 match component {
30 Component::CurDir => {} Component::ParentDir => {
32 result.pop(); }
34 c => result.push(c.as_os_str()),
35 }
36 }
37 result
38}
39
40#[derive(Clone)]
47pub struct MD051LinkFragments {
48 anchor_style: AnchorStyle,
50}
51
52impl Default for MD051LinkFragments {
53 fn default() -> Self {
54 Self::new()
55 }
56}
57
58impl MD051LinkFragments {
59 pub fn new() -> Self {
60 Self {
61 anchor_style: AnchorStyle::GitHub,
62 }
63 }
64
65 pub fn with_anchor_style(style: AnchorStyle) -> Self {
67 Self { anchor_style: style }
68 }
69
70 fn parse_blockquote_heading(bq_content: &str) -> Option<(String, Option<String>)> {
74 static BQ_ATX_HEADING_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(#{1,6})\s+(.*)$").unwrap());
75
76 let trimmed = bq_content.trim();
77 let caps = BQ_ATX_HEADING_RE.captures(trimmed)?;
78 let mut rest = caps.get(2).map_or("", |m| m.as_str()).to_string();
79
80 let rest_trimmed = rest.trim_end();
82 if let Some(last_hash_pos) = rest_trimmed.rfind('#') {
83 let after_hashes = &rest_trimmed[last_hash_pos..];
84 if after_hashes.chars().all(|c| c == '#') {
85 let mut hash_start = last_hash_pos;
87 while hash_start > 0 && rest_trimmed.as_bytes()[hash_start - 1] == b'#' {
88 hash_start -= 1;
89 }
90 if hash_start == 0
92 || rest_trimmed
93 .as_bytes()
94 .get(hash_start - 1)
95 .is_some_and(|b| b.is_ascii_whitespace())
96 {
97 rest = rest_trimmed[..hash_start].trim_end().to_string();
98 }
99 }
100 }
101
102 let (clean_text, custom_id) = crate::utils::header_id_utils::extract_header_id(&rest);
103 Some((clean_text, custom_id))
104 }
105
106 fn insert_deduplicated_fragment(
114 fragment: String,
115 fragment_counts: &mut HashMap<String, usize>,
116 markdown_headings: &mut HashSet<String>,
117 use_underscore_dedup: bool,
118 ) {
119 if fragment.is_empty() {
120 if !use_underscore_dedup {
121 return;
122 }
123 let count = fragment_counts.entry(fragment).or_insert(0);
125 *count += 1;
126 markdown_headings.insert(format!("_{count}"));
127 return;
128 }
129 if let Some(count) = fragment_counts.get_mut(&fragment) {
130 let suffix = *count;
131 *count += 1;
132 if use_underscore_dedup {
133 markdown_headings.insert(format!("{fragment}_{suffix}"));
135 markdown_headings.insert(format!("{fragment}-{suffix}"));
137 } else {
138 markdown_headings.insert(format!("{fragment}-{suffix}"));
140 }
141 } else {
142 fragment_counts.insert(fragment.clone(), 1);
143 markdown_headings.insert(fragment);
144 }
145 }
146
147 fn add_heading_to_index(
153 fragment: &str,
154 text: &str,
155 custom_anchor: Option<String>,
156 line: usize,
157 fragment_counts: &mut HashMap<String, usize>,
158 file_index: &mut FileIndex,
159 use_underscore_dedup: bool,
160 ) {
161 if fragment.is_empty() {
162 if !use_underscore_dedup {
163 return;
164 }
165 let count = fragment_counts.entry(fragment.to_string()).or_insert(0);
167 *count += 1;
168 file_index.add_heading(HeadingIndex {
169 text: text.to_string(),
170 auto_anchor: format!("_{count}"),
171 custom_anchor,
172 line,
173 });
174 return;
175 }
176 if let Some(count) = fragment_counts.get_mut(fragment) {
177 let suffix = *count;
178 *count += 1;
179 let (primary, alias) = if use_underscore_dedup {
180 (format!("{fragment}_{suffix}"), Some(format!("{fragment}-{suffix}")))
182 } else {
183 (format!("{fragment}-{suffix}"), None)
185 };
186 file_index.add_heading(HeadingIndex {
187 text: text.to_string(),
188 auto_anchor: primary,
189 custom_anchor,
190 line,
191 });
192 if let Some(alias_anchor) = alias {
193 let heading_idx = file_index.headings.len() - 1;
194 file_index.add_anchor_alias(alias_anchor, heading_idx);
195 }
196 } else {
197 fragment_counts.insert(fragment.to_string(), 1);
198 file_index.add_heading(HeadingIndex {
199 text: text.to_string(),
200 auto_anchor: fragment.to_string(),
201 custom_anchor,
202 line,
203 });
204 }
205 }
206
207 fn extract_headings_from_context(
211 &self,
212 ctx: &crate::lint_context::LintContext,
213 ) -> (HashSet<String>, HashSet<String>) {
214 let mut markdown_headings = HashSet::with_capacity(32);
215 let mut html_anchors = HashSet::with_capacity(16);
216 let mut fragment_counts = std::collections::HashMap::new();
217 let use_underscore_dedup = self.anchor_style == AnchorStyle::PythonMarkdown;
218
219 for line_info in &ctx.lines {
220 if line_info.in_front_matter {
221 continue;
222 }
223
224 if line_info.in_code_block {
226 continue;
227 }
228
229 let content = line_info.content(ctx.content);
230 let bytes = content.as_bytes();
231
232 if bytes.contains(&b'<') && (content.contains("id=") || content.contains("name=")) {
234 let mut pos = 0;
237 while pos < content.len() {
238 if let Some(start) = content[pos..].find('<') {
239 let tag_start = pos + start;
240 if let Some(end) = content[tag_start..].find('>') {
241 let tag_end = tag_start + end + 1;
242 let tag = &content[tag_start..tag_end];
243
244 if let Some(caps) = HTML_ANCHOR_PATTERN.find(tag) {
246 let matched_text = caps.as_str();
247 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(matched_text)
248 && let Some(id_match) = caps.get(1)
249 {
250 let id = id_match.as_str();
251 if !id.is_empty() {
252 html_anchors.insert(id.to_string());
253 }
254 }
255 }
256 pos = tag_end;
257 } else {
258 break;
259 }
260 } else {
261 break;
262 }
263 }
264 }
265
266 if line_info.heading.is_none() && content.contains('{') && content.contains('#') {
269 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
270 if let Some(id_match) = caps.get(1) {
271 markdown_headings.insert(id_match.as_str().to_lowercase());
273 }
274 }
275 }
276
277 if line_info.heading.is_none()
281 && let Some(bq) = &line_info.blockquote
282 && let Some((clean_text, custom_id)) = Self::parse_blockquote_heading(&bq.content)
283 {
284 if let Some(id) = custom_id {
285 markdown_headings.insert(id.to_lowercase());
286 }
287 let fragment = self.anchor_style.generate_fragment(&clean_text);
288 Self::insert_deduplicated_fragment(
289 fragment,
290 &mut fragment_counts,
291 &mut markdown_headings,
292 use_underscore_dedup,
293 );
294 }
295
296 if let Some(heading) = &line_info.heading {
298 if let Some(custom_id) = &heading.custom_id {
300 markdown_headings.insert(custom_id.to_lowercase());
301 }
302
303 let fragment = self.anchor_style.generate_fragment(&heading.text);
307
308 Self::insert_deduplicated_fragment(
309 fragment,
310 &mut fragment_counts,
311 &mut markdown_headings,
312 use_underscore_dedup,
313 );
314 }
315 }
316
317 (markdown_headings, html_anchors)
318 }
319
320 #[inline]
322 fn is_external_url_fast(url: &str) -> bool {
323 url.starts_with("http://")
325 || url.starts_with("https://")
326 || url.starts_with("ftp://")
327 || url.starts_with("mailto:")
328 || url.starts_with("tel:")
329 || url.starts_with("//")
330 }
331
332 #[inline]
340 fn resolve_path_with_extensions(path: &Path, extensions: &[&str]) -> Vec<PathBuf> {
341 if path.extension().is_none() {
342 let mut paths = Vec::with_capacity(extensions.len() + 1);
344 paths.push(path.to_path_buf());
346 for ext in extensions {
348 let path_with_ext = path.with_extension(&ext[1..]); paths.push(path_with_ext);
350 }
351 paths
352 } else {
353 vec![path.to_path_buf()]
355 }
356 }
357
358 #[inline]
372 fn is_extensionless_path(path_part: &str) -> bool {
373 if path_part.is_empty()
375 || path_part.contains('.')
376 || path_part.contains('?')
377 || path_part.contains('&')
378 || path_part.contains('=')
379 {
380 return false;
381 }
382
383 let mut has_alphanumeric = false;
385 for c in path_part.chars() {
386 if c.is_alphanumeric() {
387 has_alphanumeric = true;
388 } else if !matches!(c, '/' | '\\' | '-' | '_') {
389 return false;
391 }
392 }
393
394 has_alphanumeric
396 }
397
398 #[inline]
400 fn is_cross_file_link(url: &str) -> bool {
401 if let Some(fragment_pos) = url.find('#') {
402 let path_part = &url[..fragment_pos];
403
404 if path_part.is_empty() {
406 return false;
407 }
408
409 if let Some(tag_start) = path_part.find("{%")
415 && path_part[tag_start + 2..].contains("%}")
416 {
417 return true;
418 }
419 if let Some(var_start) = path_part.find("{{")
420 && path_part[var_start + 2..].contains("}}")
421 {
422 return true;
423 }
424
425 if path_part.starts_with('/') {
428 return true;
429 }
430
431 let has_extension = path_part.contains('.')
437 && (
438 {
440 let clean_path = path_part.split('?').next().unwrap_or(path_part);
441 if let Some(after_dot) = clean_path.strip_prefix('.') {
443 let dots_count = clean_path.matches('.').count();
444 if dots_count == 1 {
445 !after_dot.is_empty() && after_dot.len() <= 10 &&
448 after_dot.chars().all(|c| c.is_ascii_alphanumeric())
449 } else {
450 clean_path.split('.').next_back().is_some_and(|ext| {
452 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
453 })
454 }
455 } else {
456 clean_path.split('.').next_back().is_some_and(|ext| {
458 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
459 })
460 }
461 } ||
462 path_part.contains('/') || path_part.contains('\\') ||
464 path_part.starts_with("./") || path_part.starts_with("../")
466 );
467
468 let is_extensionless = Self::is_extensionless_path(path_part);
471
472 has_extension || is_extensionless
473 } else {
474 false
475 }
476 }
477}
478
479impl Rule for MD051LinkFragments {
480 fn name(&self) -> &'static str {
481 "MD051"
482 }
483
484 fn description(&self) -> &'static str {
485 "Link fragments should reference valid headings"
486 }
487
488 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
489 if !ctx.likely_has_links_or_images() {
491 return true;
492 }
493 !ctx.has_char('#')
495 }
496
497 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
498 let mut warnings = Vec::new();
499
500 if ctx.content.is_empty() || ctx.links.is_empty() || self.should_skip(ctx) {
501 return Ok(warnings);
502 }
503
504 let (markdown_headings, html_anchors) = self.extract_headings_from_context(ctx);
505
506 for link in &ctx.links {
507 if link.is_reference {
508 continue;
509 }
510
511 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
513 continue;
514 }
515
516 if matches!(link.link_type, LinkType::WikiLink { .. }) {
518 continue;
519 }
520
521 if ctx.is_in_jinja_range(link.byte_offset) {
523 continue;
524 }
525
526 if ctx.flavor == crate::config::MarkdownFlavor::Quarto && ctx.is_in_citation(link.byte_offset) {
529 continue;
530 }
531
532 if ctx.is_in_shortcode(link.byte_offset) {
535 continue;
536 }
537
538 let url = &link.url;
539
540 if !url.contains('#') || Self::is_external_url_fast(url) {
542 continue;
543 }
544
545 if url.contains("{{#") && url.contains("}}") {
548 continue;
549 }
550
551 if url.starts_with('@') {
555 continue;
556 }
557
558 if Self::is_cross_file_link(url) {
560 continue;
561 }
562
563 let Some(fragment_pos) = url.find('#') else {
564 continue;
565 };
566
567 let fragment = &url[fragment_pos + 1..];
568
569 if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
571 continue;
572 }
573
574 if fragment.is_empty() {
575 continue;
576 }
577
578 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
583 && (fragment.starts_with("fn:")
584 || fragment.starts_with("fnref:")
585 || (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
586 {
587 continue;
588 }
589
590 let found = if html_anchors.contains(fragment) {
593 true
594 } else {
595 let fragment_lower = fragment.to_lowercase();
596 markdown_headings.contains(&fragment_lower)
597 };
598
599 if !found {
600 warnings.push(LintWarning {
601 rule_name: Some(self.name().to_string()),
602 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
603 line: link.line,
604 column: link.start_col + 1,
605 end_line: link.line,
606 end_column: link.end_col + 1,
607 severity: Severity::Error,
608 fix: None,
609 });
610 }
611 }
612
613 Ok(warnings)
614 }
615
616 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
617 Ok(ctx.content.to_string())
620 }
621
622 fn as_any(&self) -> &dyn std::any::Any {
623 self
624 }
625
626 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
627 where
628 Self: Sized,
629 {
630 let explicit_style = config
632 .rules
633 .get("MD051")
634 .and_then(|rc| rc.values.get("anchor-style"))
635 .and_then(|v| v.as_str())
636 .map(|style_str| match style_str.to_lowercase().as_str() {
637 "kramdown" => AnchorStyle::Kramdown,
638 "kramdown-gfm" | "jekyll" => AnchorStyle::KramdownGfm,
639 "python-markdown" | "python_markdown" | "mkdocs" => AnchorStyle::PythonMarkdown,
640 _ => AnchorStyle::GitHub,
641 });
642
643 let anchor_style = explicit_style.unwrap_or(match config.global.flavor {
646 crate::config::MarkdownFlavor::MkDocs => AnchorStyle::PythonMarkdown,
647 crate::config::MarkdownFlavor::Kramdown => AnchorStyle::KramdownGfm,
648 _ => AnchorStyle::GitHub,
649 });
650
651 Box::new(MD051LinkFragments::with_anchor_style(anchor_style))
652 }
653
654 fn category(&self) -> RuleCategory {
655 RuleCategory::Link
656 }
657
658 fn cross_file_scope(&self) -> CrossFileScope {
659 CrossFileScope::Workspace
660 }
661
662 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
663 let mut fragment_counts = HashMap::new();
664 let use_underscore_dedup = self.anchor_style == AnchorStyle::PythonMarkdown;
665
666 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
668 if line_info.in_front_matter {
669 continue;
670 }
671
672 if line_info.in_code_block {
674 continue;
675 }
676
677 let content = line_info.content(ctx.content);
678
679 if content.contains('<') && (content.contains("id=") || content.contains("name=")) {
681 let mut pos = 0;
682 while pos < content.len() {
683 if let Some(start) = content[pos..].find('<') {
684 let tag_start = pos + start;
685 if let Some(end) = content[tag_start..].find('>') {
686 let tag_end = tag_start + end + 1;
687 let tag = &content[tag_start..tag_end];
688
689 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(tag)
690 && let Some(id_match) = caps.get(1)
691 {
692 file_index.add_html_anchor(id_match.as_str().to_string());
693 }
694 pos = tag_end;
695 } else {
696 break;
697 }
698 } else {
699 break;
700 }
701 }
702 }
703
704 if line_info.heading.is_none() && content.contains("{") && content.contains("#") {
707 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
708 if let Some(id_match) = caps.get(1) {
709 file_index.add_attribute_anchor(id_match.as_str().to_string());
710 }
711 }
712 }
713
714 if line_info.heading.is_none()
716 && let Some(bq) = &line_info.blockquote
717 && let Some((clean_text, custom_id)) = Self::parse_blockquote_heading(&bq.content)
718 {
719 let fragment = self.anchor_style.generate_fragment(&clean_text);
720 Self::add_heading_to_index(
721 &fragment,
722 &clean_text,
723 custom_id,
724 line_idx + 1,
725 &mut fragment_counts,
726 file_index,
727 use_underscore_dedup,
728 );
729 }
730
731 if let Some(heading) = &line_info.heading {
733 let fragment = self.anchor_style.generate_fragment(&heading.text);
734
735 Self::add_heading_to_index(
736 &fragment,
737 &heading.text,
738 heading.custom_id.clone(),
739 line_idx + 1,
740 &mut fragment_counts,
741 file_index,
742 use_underscore_dedup,
743 );
744
745 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
750 && let Some(caps) = MD_SETTING_PATTERN.captures(content)
751 && let Some(name) = caps.get(1)
752 {
753 file_index.add_html_anchor(name.as_str().to_string());
754 }
755 }
756 }
757
758 for link in &ctx.links {
760 if link.is_reference {
761 continue;
762 }
763
764 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
766 continue;
767 }
768
769 if matches!(link.link_type, LinkType::WikiLink { .. }) {
772 continue;
773 }
774
775 let url = &link.url;
776
777 if Self::is_external_url_fast(url) {
779 continue;
780 }
781
782 if Self::is_cross_file_link(url)
784 && let Some(fragment_pos) = url.find('#')
785 {
786 let path_part = &url[..fragment_pos];
787 let fragment = &url[fragment_pos + 1..];
788
789 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
791 continue;
792 }
793
794 file_index.add_cross_file_link(CrossFileLinkIndex {
795 target_path: path_part.to_string(),
796 fragment: fragment.to_string(),
797 line: link.line,
798 column: link.start_col + 1,
799 });
800 }
801 }
802 }
803
804 fn cross_file_check(
805 &self,
806 file_path: &Path,
807 file_index: &FileIndex,
808 workspace_index: &crate::workspace_index::WorkspaceIndex,
809 ) -> LintResult {
810 let mut warnings = Vec::new();
811
812 const MARKDOWN_EXTENSIONS: &[&str] = &[
814 ".md",
815 ".markdown",
816 ".mdx",
817 ".mkd",
818 ".mkdn",
819 ".mdown",
820 ".mdwn",
821 ".qmd",
822 ".rmd",
823 ];
824
825 for cross_link in &file_index.cross_file_links {
827 if cross_link.fragment.is_empty() {
829 continue;
830 }
831
832 let base_target_path = if let Some(parent) = file_path.parent() {
834 parent.join(&cross_link.target_path)
835 } else {
836 Path::new(&cross_link.target_path).to_path_buf()
837 };
838
839 let base_target_path = normalize_path(&base_target_path);
841
842 let target_paths_to_try = Self::resolve_path_with_extensions(&base_target_path, MARKDOWN_EXTENSIONS);
845
846 let mut target_file_index = None;
848
849 for target_path in &target_paths_to_try {
850 if let Some(index) = workspace_index.get_file(target_path) {
851 target_file_index = Some(index);
852 break;
853 }
854 }
855
856 if let Some(target_file_index) = target_file_index {
857 if !target_file_index.has_anchor(&cross_link.fragment) {
859 warnings.push(LintWarning {
860 rule_name: Some(self.name().to_string()),
861 line: cross_link.line,
862 column: cross_link.column,
863 end_line: cross_link.line,
864 end_column: cross_link.column + cross_link.target_path.len() + 1 + cross_link.fragment.len(),
865 message: format!(
866 "Link fragment '{}' not found in '{}'",
867 cross_link.fragment, cross_link.target_path
868 ),
869 severity: Severity::Error,
870 fix: None,
871 });
872 }
873 }
874 }
876
877 Ok(warnings)
878 }
879
880 fn default_config_section(&self) -> Option<(String, toml::Value)> {
881 let value: toml::Value = toml::from_str(
882 r#"
883# Anchor generation style to match your target platform
884# Options: "github" (default), "kramdown-gfm", "kramdown"
885# Note: "jekyll" is accepted as an alias for "kramdown-gfm" (backward compatibility)
886anchor-style = "github"
887"#,
888 )
889 .ok()?;
890 Some(("MD051".to_string(), value))
891 }
892}
893
894#[cfg(test)]
895mod tests {
896 use super::*;
897 use crate::lint_context::LintContext;
898
899 #[test]
900 fn test_quarto_cross_references() {
901 let rule = MD051LinkFragments::new();
902
903 let content = r#"# Test Document
905
906## Figures
907
908See [@fig-plot] for the visualization.
909
910More details in [@tbl-results] and [@sec-methods].
911
912The equation [@eq-regression] shows the relationship.
913
914Reference to [@lst-code] for implementation."#;
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
916 let result = rule.check(&ctx).unwrap();
917 assert!(
918 result.is_empty(),
919 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
920 result.len()
921 );
922
923 let content_with_anchor = r#"# Test
925
926See [link](#test) for details."#;
927 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
928 let result_anchor = rule.check(&ctx_anchor).unwrap();
929 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
930
931 let content_invalid = r#"# Test
933
934See [link](#nonexistent) for details."#;
935 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
936 let result_invalid = rule.check(&ctx_invalid).unwrap();
937 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
938 }
939
940 #[test]
942 fn test_cross_file_scope() {
943 let rule = MD051LinkFragments::new();
944 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
945 }
946
947 #[test]
948 fn test_contribute_to_index_extracts_headings() {
949 let rule = MD051LinkFragments::new();
950 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952
953 let mut file_index = FileIndex::new();
954 rule.contribute_to_index(&ctx, &mut file_index);
955
956 assert_eq!(file_index.headings.len(), 3);
957 assert_eq!(file_index.headings[0].text, "First Heading");
958 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
959 assert!(file_index.headings[0].custom_anchor.is_none());
960
961 assert_eq!(file_index.headings[1].text, "Second");
962 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
963
964 assert_eq!(file_index.headings[2].text, "Third");
965 }
966
967 #[test]
968 fn test_contribute_to_index_extracts_cross_file_links() {
969 let rule = MD051LinkFragments::new();
970 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972
973 let mut file_index = FileIndex::new();
974 rule.contribute_to_index(&ctx, &mut file_index);
975
976 assert_eq!(file_index.cross_file_links.len(), 2);
977 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
978 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
979 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
980 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
981 }
982
983 #[test]
984 fn test_cross_file_check_valid_fragment() {
985 use crate::workspace_index::WorkspaceIndex;
986
987 let rule = MD051LinkFragments::new();
988
989 let mut workspace_index = WorkspaceIndex::new();
991 let mut target_file_index = FileIndex::new();
992 target_file_index.add_heading(HeadingIndex {
993 text: "Installation Guide".to_string(),
994 auto_anchor: "installation-guide".to_string(),
995 custom_anchor: None,
996 line: 1,
997 });
998 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
999
1000 let mut current_file_index = FileIndex::new();
1002 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1003 target_path: "install.md".to_string(),
1004 fragment: "installation-guide".to_string(),
1005 line: 3,
1006 column: 5,
1007 });
1008
1009 let warnings = rule
1010 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1011 .unwrap();
1012
1013 assert!(warnings.is_empty());
1015 }
1016
1017 #[test]
1018 fn test_cross_file_check_invalid_fragment() {
1019 use crate::workspace_index::WorkspaceIndex;
1020
1021 let rule = MD051LinkFragments::new();
1022
1023 let mut workspace_index = WorkspaceIndex::new();
1025 let mut target_file_index = FileIndex::new();
1026 target_file_index.add_heading(HeadingIndex {
1027 text: "Installation Guide".to_string(),
1028 auto_anchor: "installation-guide".to_string(),
1029 custom_anchor: None,
1030 line: 1,
1031 });
1032 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1033
1034 let mut current_file_index = FileIndex::new();
1036 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1037 target_path: "install.md".to_string(),
1038 fragment: "nonexistent".to_string(),
1039 line: 3,
1040 column: 5,
1041 });
1042
1043 let warnings = rule
1044 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1045 .unwrap();
1046
1047 assert_eq!(warnings.len(), 1);
1049 assert!(warnings[0].message.contains("nonexistent"));
1050 assert!(warnings[0].message.contains("install.md"));
1051 }
1052
1053 #[test]
1054 fn test_cross_file_check_custom_anchor_match() {
1055 use crate::workspace_index::WorkspaceIndex;
1056
1057 let rule = MD051LinkFragments::new();
1058
1059 let mut workspace_index = WorkspaceIndex::new();
1061 let mut target_file_index = FileIndex::new();
1062 target_file_index.add_heading(HeadingIndex {
1063 text: "Installation Guide".to_string(),
1064 auto_anchor: "installation-guide".to_string(),
1065 custom_anchor: Some("install".to_string()),
1066 line: 1,
1067 });
1068 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1069
1070 let mut current_file_index = FileIndex::new();
1072 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1073 target_path: "install.md".to_string(),
1074 fragment: "install".to_string(),
1075 line: 3,
1076 column: 5,
1077 });
1078
1079 let warnings = rule
1080 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1081 .unwrap();
1082
1083 assert!(warnings.is_empty());
1085 }
1086
1087 #[test]
1088 fn test_cross_file_check_target_not_in_workspace() {
1089 use crate::workspace_index::WorkspaceIndex;
1090
1091 let rule = MD051LinkFragments::new();
1092
1093 let workspace_index = WorkspaceIndex::new();
1095
1096 let mut current_file_index = FileIndex::new();
1098 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1099 target_path: "external.md".to_string(),
1100 fragment: "heading".to_string(),
1101 line: 3,
1102 column: 5,
1103 });
1104
1105 let warnings = rule
1106 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1107 .unwrap();
1108
1109 assert!(warnings.is_empty());
1111 }
1112
1113 #[test]
1114 fn test_wikilinks_skipped_in_check() {
1115 let rule = MD051LinkFragments::new();
1117
1118 let content = r#"# Test Document
1119
1120## Valid Heading
1121
1122[[Microsoft#Windows OS]]
1123[[SomePage#section]]
1124[[page|Display Text]]
1125[[path/to/page#section]]
1126"#;
1127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1128 let result = rule.check(&ctx).unwrap();
1129
1130 assert!(
1131 result.is_empty(),
1132 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1133 );
1134 }
1135
1136 #[test]
1137 fn test_wikilinks_not_added_to_cross_file_index() {
1138 let rule = MD051LinkFragments::new();
1140
1141 let content = r#"# Test Document
1142
1143[[Microsoft#Windows OS]]
1144[[SomePage#section]]
1145[Regular Link](other.md#section)
1146"#;
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148
1149 let mut file_index = FileIndex::new();
1150 rule.contribute_to_index(&ctx, &mut file_index);
1151
1152 let cross_file_links = &file_index.cross_file_links;
1155 assert_eq!(
1156 cross_file_links.len(),
1157 1,
1158 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1159 );
1160 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1161 assert_eq!(file_index.cross_file_links[0].fragment, "section");
1162 }
1163}