1use crate::rule::{CrossFileScope, FixCapability, 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-Z0-9_][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 is_setext: false,
174 });
175 return;
176 }
177 if let Some(count) = fragment_counts.get_mut(fragment) {
178 let suffix = *count;
179 *count += 1;
180 let (primary, alias) = if use_underscore_dedup {
181 (format!("{fragment}_{suffix}"), Some(format!("{fragment}-{suffix}")))
183 } else {
184 (format!("{fragment}-{suffix}"), None)
186 };
187 file_index.add_heading(HeadingIndex {
188 text: text.to_string(),
189 auto_anchor: primary,
190 custom_anchor,
191 line,
192 is_setext: false,
193 });
194 if let Some(alias_anchor) = alias {
195 let heading_idx = file_index.headings.len() - 1;
196 file_index.add_anchor_alias(alias_anchor, heading_idx);
197 }
198 } else {
199 fragment_counts.insert(fragment.to_string(), 1);
200 file_index.add_heading(HeadingIndex {
201 text: text.to_string(),
202 auto_anchor: fragment.to_string(),
203 custom_anchor,
204 line,
205 is_setext: false,
206 });
207 }
208 }
209
210 fn extract_headings_from_context(
214 &self,
215 ctx: &crate::lint_context::LintContext,
216 ) -> (HashSet<String>, HashSet<String>) {
217 let mut markdown_headings = HashSet::with_capacity(32);
218 let mut html_anchors = HashSet::with_capacity(16);
219 let mut fragment_counts = std::collections::HashMap::new();
220 let use_underscore_dedup = self.anchor_style == AnchorStyle::PythonMarkdown;
221
222 for line_info in &ctx.lines {
223 if line_info.in_front_matter {
224 continue;
225 }
226
227 if line_info.in_code_block {
229 continue;
230 }
231
232 let content = line_info.content(ctx.content);
233 let bytes = content.as_bytes();
234
235 if bytes.contains(&b'<') && (content.contains("id=") || content.contains("name=")) {
237 let mut pos = 0;
240 while pos < content.len() {
241 if let Some(start) = content[pos..].find('<') {
242 let tag_start = pos + start;
243 if let Some(end) = content[tag_start..].find('>') {
244 let tag_end = tag_start + end + 1;
245 let tag = &content[tag_start..tag_end];
246
247 if let Some(caps) = HTML_ANCHOR_PATTERN.find(tag) {
249 let matched_text = caps.as_str();
250 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(matched_text)
251 && let Some(id_match) = caps.get(1)
252 {
253 let id = id_match.as_str();
254 if !id.is_empty() {
255 html_anchors.insert(id.to_string());
256 }
257 }
258 }
259 pos = tag_end;
260 } else {
261 break;
262 }
263 } else {
264 break;
265 }
266 }
267 }
268
269 if line_info.heading.is_none() && content.contains('{') && content.contains('#') {
272 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
273 if let Some(id_match) = caps.get(1) {
274 markdown_headings.insert(id_match.as_str().to_lowercase());
276 }
277 }
278 }
279
280 if line_info.heading.is_none()
284 && let Some(bq) = &line_info.blockquote
285 && let Some((clean_text, custom_id)) = Self::parse_blockquote_heading(&bq.content)
286 {
287 if let Some(id) = custom_id {
288 markdown_headings.insert(id.to_lowercase());
289 }
290 let fragment = self.anchor_style.generate_fragment(&clean_text);
291 Self::insert_deduplicated_fragment(
292 fragment,
293 &mut fragment_counts,
294 &mut markdown_headings,
295 use_underscore_dedup,
296 );
297 }
298
299 if let Some(heading) = &line_info.heading {
301 if let Some(custom_id) = &heading.custom_id {
303 markdown_headings.insert(custom_id.to_lowercase());
304 }
305
306 let fragment = self.anchor_style.generate_fragment(&heading.text);
310
311 Self::insert_deduplicated_fragment(
312 fragment,
313 &mut fragment_counts,
314 &mut markdown_headings,
315 use_underscore_dedup,
316 );
317 }
318 }
319
320 (markdown_headings, html_anchors)
321 }
322
323 #[inline]
325 fn is_external_url_fast(url: &str) -> bool {
326 url.starts_with("http://")
328 || url.starts_with("https://")
329 || url.starts_with("ftp://")
330 || url.starts_with("mailto:")
331 || url.starts_with("tel:")
332 || url.starts_with("//")
333 }
334
335 #[inline]
343 fn resolve_path_with_extensions(path: &Path, extensions: &[&str]) -> Vec<PathBuf> {
344 if path.extension().is_none() {
345 let mut paths = Vec::with_capacity(extensions.len() + 1);
347 paths.push(path.to_path_buf());
349 for ext in extensions {
351 let path_with_ext = path.with_extension(&ext[1..]); paths.push(path_with_ext);
353 }
354 paths
355 } else {
356 vec![path.to_path_buf()]
358 }
359 }
360
361 #[inline]
375 fn is_extensionless_path(path_part: &str) -> bool {
376 if path_part.is_empty()
378 || path_part.contains('.')
379 || path_part.contains('?')
380 || path_part.contains('&')
381 || path_part.contains('=')
382 {
383 return false;
384 }
385
386 let mut has_alphanumeric = false;
388 for c in path_part.chars() {
389 if c.is_alphanumeric() {
390 has_alphanumeric = true;
391 } else if !matches!(c, '/' | '\\' | '-' | '_') {
392 return false;
394 }
395 }
396
397 has_alphanumeric
399 }
400
401 #[inline]
403 fn is_cross_file_link(url: &str) -> bool {
404 if let Some(fragment_pos) = url.find('#') {
405 let path_part = &url[..fragment_pos];
406
407 if path_part.is_empty() {
409 return false;
410 }
411
412 if let Some(tag_start) = path_part.find("{%")
418 && path_part[tag_start + 2..].contains("%}")
419 {
420 return true;
421 }
422 if let Some(var_start) = path_part.find("{{")
423 && path_part[var_start + 2..].contains("}}")
424 {
425 return true;
426 }
427
428 if path_part.starts_with('/') {
431 return true;
432 }
433
434 let has_extension = path_part.contains('.')
440 && (
441 {
443 let clean_path = path_part.split('?').next().unwrap_or(path_part);
444 if let Some(after_dot) = clean_path.strip_prefix('.') {
446 let dots_count = clean_path.matches('.').count();
447 if dots_count == 1 {
448 !after_dot.is_empty() && after_dot.len() <= 10 &&
451 after_dot.chars().all(|c| c.is_ascii_alphanumeric())
452 } else {
453 clean_path.split('.').next_back().is_some_and(|ext| {
455 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
456 })
457 }
458 } else {
459 clean_path.split('.').next_back().is_some_and(|ext| {
461 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
462 })
463 }
464 } ||
465 path_part.contains('/') || path_part.contains('\\') ||
467 path_part.starts_with("./") || path_part.starts_with("../")
469 );
470
471 let is_extensionless = Self::is_extensionless_path(path_part);
474
475 has_extension || is_extensionless
476 } else {
477 false
478 }
479 }
480}
481
482impl Rule for MD051LinkFragments {
483 fn name(&self) -> &'static str {
484 "MD051"
485 }
486
487 fn description(&self) -> &'static str {
488 "Link fragments should reference valid headings"
489 }
490
491 fn fix_capability(&self) -> FixCapability {
492 FixCapability::Unfixable
493 }
494
495 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
496 if !ctx.likely_has_links_or_images() {
498 return true;
499 }
500 !ctx.has_char('#')
502 }
503
504 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
505 let mut warnings = Vec::new();
506
507 if ctx.content.is_empty() || ctx.links.is_empty() || self.should_skip(ctx) {
508 return Ok(warnings);
509 }
510
511 let (markdown_headings, html_anchors) = self.extract_headings_from_context(ctx);
512
513 for link in &ctx.links {
514 if link.is_reference {
515 continue;
516 }
517
518 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
520 continue;
521 }
522
523 if matches!(link.link_type, LinkType::WikiLink { .. }) {
525 continue;
526 }
527
528 if ctx.is_in_jinja_range(link.byte_offset) {
530 continue;
531 }
532
533 if ctx.flavor == crate::config::MarkdownFlavor::Quarto && ctx.is_in_citation(link.byte_offset) {
536 continue;
537 }
538
539 if ctx.is_in_shortcode(link.byte_offset) {
542 continue;
543 }
544
545 let url = &link.url;
546
547 if !url.contains('#') || Self::is_external_url_fast(url) {
549 continue;
550 }
551
552 if url.contains("{{#") && url.contains("}}") {
555 continue;
556 }
557
558 if url.starts_with('@') {
562 continue;
563 }
564
565 if Self::is_cross_file_link(url) {
567 continue;
568 }
569
570 let Some(fragment_pos) = url.find('#') else {
571 continue;
572 };
573
574 let fragment = &url[fragment_pos + 1..];
575
576 if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
578 continue;
579 }
580
581 if fragment.is_empty() {
582 continue;
583 }
584
585 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
590 && (fragment.starts_with("fn:")
591 || fragment.starts_with("fnref:")
592 || (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
593 {
594 continue;
595 }
596
597 let found = if html_anchors.contains(fragment) {
600 true
601 } else {
602 let fragment_lower = fragment.to_lowercase();
603 markdown_headings.contains(&fragment_lower)
604 };
605
606 if !found {
607 warnings.push(LintWarning {
608 rule_name: Some(self.name().to_string()),
609 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
610 line: link.line,
611 column: link.start_col + 1,
612 end_line: link.line,
613 end_column: link.end_col + 1,
614 severity: Severity::Error,
615 fix: None,
616 });
617 }
618 }
619
620 Ok(warnings)
621 }
622
623 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
624 Ok(ctx.content.to_string())
627 }
628
629 fn as_any(&self) -> &dyn std::any::Any {
630 self
631 }
632
633 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
634 where
635 Self: Sized,
636 {
637 let explicit_style = config
639 .rules
640 .get("MD051")
641 .and_then(|rc| rc.values.get("anchor-style"))
642 .and_then(|v| v.as_str())
643 .map(|style_str| match style_str.to_lowercase().as_str() {
644 "kramdown" => AnchorStyle::Kramdown,
645 "kramdown-gfm" | "jekyll" => AnchorStyle::KramdownGfm,
646 "python-markdown" | "python_markdown" | "mkdocs" => AnchorStyle::PythonMarkdown,
647 _ => AnchorStyle::GitHub,
648 });
649
650 let anchor_style = explicit_style.unwrap_or(match config.global.flavor {
653 crate::config::MarkdownFlavor::MkDocs => AnchorStyle::PythonMarkdown,
654 crate::config::MarkdownFlavor::Kramdown => AnchorStyle::KramdownGfm,
655 _ => AnchorStyle::GitHub,
656 });
657
658 Box::new(MD051LinkFragments::with_anchor_style(anchor_style))
659 }
660
661 fn category(&self) -> RuleCategory {
662 RuleCategory::Link
663 }
664
665 fn cross_file_scope(&self) -> CrossFileScope {
666 CrossFileScope::Workspace
667 }
668
669 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
670 let mut fragment_counts = HashMap::new();
671 let use_underscore_dedup = self.anchor_style == AnchorStyle::PythonMarkdown;
672
673 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
675 if line_info.in_front_matter {
676 continue;
677 }
678
679 if line_info.in_code_block {
681 continue;
682 }
683
684 let content = line_info.content(ctx.content);
685
686 if content.contains('<') && (content.contains("id=") || content.contains("name=")) {
688 let mut pos = 0;
689 while pos < content.len() {
690 if let Some(start) = content[pos..].find('<') {
691 let tag_start = pos + start;
692 if let Some(end) = content[tag_start..].find('>') {
693 let tag_end = tag_start + end + 1;
694 let tag = &content[tag_start..tag_end];
695
696 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(tag)
697 && let Some(id_match) = caps.get(1)
698 {
699 file_index.add_html_anchor(id_match.as_str().to_string());
700 }
701 pos = tag_end;
702 } else {
703 break;
704 }
705 } else {
706 break;
707 }
708 }
709 }
710
711 if line_info.heading.is_none() && content.contains("{") && content.contains("#") {
714 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
715 if let Some(id_match) = caps.get(1) {
716 file_index.add_attribute_anchor(id_match.as_str().to_string());
717 }
718 }
719 }
720
721 if line_info.heading.is_none()
723 && let Some(bq) = &line_info.blockquote
724 && let Some((clean_text, custom_id)) = Self::parse_blockquote_heading(&bq.content)
725 {
726 let fragment = self.anchor_style.generate_fragment(&clean_text);
727 Self::add_heading_to_index(
728 &fragment,
729 &clean_text,
730 custom_id,
731 line_idx + 1,
732 &mut fragment_counts,
733 file_index,
734 use_underscore_dedup,
735 );
736 }
737
738 if let Some(heading) = &line_info.heading {
740 let fragment = self.anchor_style.generate_fragment(&heading.text);
741
742 Self::add_heading_to_index(
743 &fragment,
744 &heading.text,
745 heading.custom_id.clone(),
746 line_idx + 1,
747 &mut fragment_counts,
748 file_index,
749 use_underscore_dedup,
750 );
751
752 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
757 && let Some(caps) = MD_SETTING_PATTERN.captures(content)
758 && let Some(name) = caps.get(1)
759 {
760 file_index.add_html_anchor(name.as_str().to_string());
761 }
762 }
763 }
764
765 for link in &ctx.links {
767 if link.is_reference {
768 continue;
769 }
770
771 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
773 continue;
774 }
775
776 if matches!(link.link_type, LinkType::WikiLink { .. }) {
779 continue;
780 }
781
782 let url = &link.url;
783
784 if Self::is_external_url_fast(url) {
786 continue;
787 }
788
789 if Self::is_cross_file_link(url)
791 && let Some(fragment_pos) = url.find('#')
792 {
793 let path_part = &url[..fragment_pos];
794 let fragment = &url[fragment_pos + 1..];
795
796 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
798 continue;
799 }
800
801 file_index.add_cross_file_link(CrossFileLinkIndex {
802 target_path: path_part.to_string(),
803 fragment: fragment.to_string(),
804 line: link.line,
805 column: link.start_col + 1,
806 });
807 }
808 }
809 }
810
811 fn cross_file_check(
812 &self,
813 file_path: &Path,
814 file_index: &FileIndex,
815 workspace_index: &crate::workspace_index::WorkspaceIndex,
816 ) -> LintResult {
817 let mut warnings = Vec::new();
818
819 const MARKDOWN_EXTENSIONS: &[&str] = &[
821 ".md",
822 ".markdown",
823 ".mdx",
824 ".mkd",
825 ".mkdn",
826 ".mdown",
827 ".mdwn",
828 ".qmd",
829 ".rmd",
830 ];
831
832 for cross_link in &file_index.cross_file_links {
834 if cross_link.fragment.is_empty() {
836 continue;
837 }
838
839 let base_target_path = if let Some(parent) = file_path.parent() {
841 parent.join(&cross_link.target_path)
842 } else {
843 Path::new(&cross_link.target_path).to_path_buf()
844 };
845
846 let base_target_path = normalize_path(&base_target_path);
848
849 let target_paths_to_try = Self::resolve_path_with_extensions(&base_target_path, MARKDOWN_EXTENSIONS);
852
853 let mut target_file_index = None;
855
856 for target_path in &target_paths_to_try {
857 if let Some(index) = workspace_index.get_file(target_path) {
858 target_file_index = Some(index);
859 break;
860 }
861 }
862
863 if let Some(target_file_index) = target_file_index {
864 if !target_file_index.has_anchor(&cross_link.fragment) {
866 warnings.push(LintWarning {
867 rule_name: Some(self.name().to_string()),
868 line: cross_link.line,
869 column: cross_link.column,
870 end_line: cross_link.line,
871 end_column: cross_link.column + cross_link.target_path.len() + 1 + cross_link.fragment.len(),
872 message: format!(
873 "Link fragment '{}' not found in '{}'",
874 cross_link.fragment, cross_link.target_path
875 ),
876 severity: Severity::Error,
877 fix: None,
878 });
879 }
880 }
881 }
883
884 Ok(warnings)
885 }
886
887 fn default_config_section(&self) -> Option<(String, toml::Value)> {
888 let value: toml::Value = toml::from_str(
889 r#"
890# Anchor generation style to match your target platform
891# Options: "github" (default), "kramdown-gfm", "kramdown"
892# Note: "jekyll" is accepted as an alias for "kramdown-gfm" (backward compatibility)
893anchor-style = "github"
894"#,
895 )
896 .ok()?;
897 Some(("MD051".to_string(), value))
898 }
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904 use crate::lint_context::LintContext;
905
906 #[test]
907 fn test_quarto_cross_references() {
908 let rule = MD051LinkFragments::new();
909
910 let content = r#"# Test Document
912
913## Figures
914
915See [@fig-plot] for the visualization.
916
917More details in [@tbl-results] and [@sec-methods].
918
919The equation [@eq-regression] shows the relationship.
920
921Reference to [@lst-code] for implementation."#;
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
923 let result = rule.check(&ctx).unwrap();
924 assert!(
925 result.is_empty(),
926 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
927 result.len()
928 );
929
930 let content_with_anchor = r#"# Test
932
933See [link](#test) for details."#;
934 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
935 let result_anchor = rule.check(&ctx_anchor).unwrap();
936 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
937
938 let content_invalid = r#"# Test
940
941See [link](#nonexistent) for details."#;
942 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
943 let result_invalid = rule.check(&ctx_invalid).unwrap();
944 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
945 }
946
947 #[test]
948 fn test_jsx_in_heading_anchor() {
949 let rule = MD051LinkFragments::new();
951
952 let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let result = rule.check(&ctx).unwrap();
956 assert!(
957 result.is_empty(),
958 "JSX self-closing tag should be stripped from anchor: got {result:?}"
959 );
960
961 let content2 =
963 "### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
964 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
965 let result2 = rule.check(&ctx2).unwrap();
966 assert!(
967 result2.is_empty(),
968 "JSX tag with attributes should be stripped from anchor: got {result2:?}"
969 );
970
971 let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
973 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
974 let result3 = rule.check(&ctx3).unwrap();
975 assert!(
976 result3.is_empty(),
977 "HTML tag content should be preserved in anchor: got {result3:?}"
978 );
979 }
980
981 #[test]
983 fn test_cross_file_scope() {
984 let rule = MD051LinkFragments::new();
985 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
986 }
987
988 #[test]
989 fn test_contribute_to_index_extracts_headings() {
990 let rule = MD051LinkFragments::new();
991 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993
994 let mut file_index = FileIndex::new();
995 rule.contribute_to_index(&ctx, &mut file_index);
996
997 assert_eq!(file_index.headings.len(), 3);
998 assert_eq!(file_index.headings[0].text, "First Heading");
999 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
1000 assert!(file_index.headings[0].custom_anchor.is_none());
1001
1002 assert_eq!(file_index.headings[1].text, "Second");
1003 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
1004
1005 assert_eq!(file_index.headings[2].text, "Third");
1006 }
1007
1008 #[test]
1009 fn test_contribute_to_index_extracts_cross_file_links() {
1010 let rule = MD051LinkFragments::new();
1011 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
1012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1013
1014 let mut file_index = FileIndex::new();
1015 rule.contribute_to_index(&ctx, &mut file_index);
1016
1017 assert_eq!(file_index.cross_file_links.len(), 2);
1018 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1019 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
1020 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
1021 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
1022 }
1023
1024 #[test]
1025 fn test_cross_file_check_valid_fragment() {
1026 use crate::workspace_index::WorkspaceIndex;
1027
1028 let rule = MD051LinkFragments::new();
1029
1030 let mut workspace_index = WorkspaceIndex::new();
1032 let mut target_file_index = FileIndex::new();
1033 target_file_index.add_heading(HeadingIndex {
1034 text: "Installation Guide".to_string(),
1035 auto_anchor: "installation-guide".to_string(),
1036 custom_anchor: None,
1037 line: 1,
1038 is_setext: false,
1039 });
1040 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1041
1042 let mut current_file_index = FileIndex::new();
1044 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1045 target_path: "install.md".to_string(),
1046 fragment: "installation-guide".to_string(),
1047 line: 3,
1048 column: 5,
1049 });
1050
1051 let warnings = rule
1052 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1053 .unwrap();
1054
1055 assert!(warnings.is_empty());
1057 }
1058
1059 #[test]
1060 fn test_cross_file_check_invalid_fragment() {
1061 use crate::workspace_index::WorkspaceIndex;
1062
1063 let rule = MD051LinkFragments::new();
1064
1065 let mut workspace_index = WorkspaceIndex::new();
1067 let mut target_file_index = FileIndex::new();
1068 target_file_index.add_heading(HeadingIndex {
1069 text: "Installation Guide".to_string(),
1070 auto_anchor: "installation-guide".to_string(),
1071 custom_anchor: None,
1072 line: 1,
1073 is_setext: false,
1074 });
1075 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1076
1077 let mut current_file_index = FileIndex::new();
1079 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1080 target_path: "install.md".to_string(),
1081 fragment: "nonexistent".to_string(),
1082 line: 3,
1083 column: 5,
1084 });
1085
1086 let warnings = rule
1087 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1088 .unwrap();
1089
1090 assert_eq!(warnings.len(), 1);
1092 assert!(warnings[0].message.contains("nonexistent"));
1093 assert!(warnings[0].message.contains("install.md"));
1094 }
1095
1096 #[test]
1097 fn test_cross_file_check_custom_anchor_match() {
1098 use crate::workspace_index::WorkspaceIndex;
1099
1100 let rule = MD051LinkFragments::new();
1101
1102 let mut workspace_index = WorkspaceIndex::new();
1104 let mut target_file_index = FileIndex::new();
1105 target_file_index.add_heading(HeadingIndex {
1106 text: "Installation Guide".to_string(),
1107 auto_anchor: "installation-guide".to_string(),
1108 custom_anchor: Some("install".to_string()),
1109 line: 1,
1110 is_setext: false,
1111 });
1112 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1113
1114 let mut current_file_index = FileIndex::new();
1116 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1117 target_path: "install.md".to_string(),
1118 fragment: "install".to_string(),
1119 line: 3,
1120 column: 5,
1121 });
1122
1123 let warnings = rule
1124 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1125 .unwrap();
1126
1127 assert!(warnings.is_empty());
1129 }
1130
1131 #[test]
1132 fn test_cross_file_check_target_not_in_workspace() {
1133 use crate::workspace_index::WorkspaceIndex;
1134
1135 let rule = MD051LinkFragments::new();
1136
1137 let workspace_index = WorkspaceIndex::new();
1139
1140 let mut current_file_index = FileIndex::new();
1142 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1143 target_path: "external.md".to_string(),
1144 fragment: "heading".to_string(),
1145 line: 3,
1146 column: 5,
1147 });
1148
1149 let warnings = rule
1150 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1151 .unwrap();
1152
1153 assert!(warnings.is_empty());
1155 }
1156
1157 #[test]
1158 fn test_wikilinks_skipped_in_check() {
1159 let rule = MD051LinkFragments::new();
1161
1162 let content = r#"# Test Document
1163
1164## Valid Heading
1165
1166[[Microsoft#Windows OS]]
1167[[SomePage#section]]
1168[[page|Display Text]]
1169[[path/to/page#section]]
1170"#;
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let result = rule.check(&ctx).unwrap();
1173
1174 assert!(
1175 result.is_empty(),
1176 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1177 );
1178 }
1179
1180 #[test]
1181 fn test_wikilinks_not_added_to_cross_file_index() {
1182 let rule = MD051LinkFragments::new();
1184
1185 let content = r#"# Test Document
1186
1187[[Microsoft#Windows OS]]
1188[[SomePage#section]]
1189[Regular Link](other.md#section)
1190"#;
1191 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1192
1193 let mut file_index = FileIndex::new();
1194 rule.contribute_to_index(&ctx, &mut file_index);
1195
1196 let cross_file_links = &file_index.cross_file_links;
1199 assert_eq!(
1200 cross_file_links.len(),
1201 1,
1202 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1203 );
1204 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1205 assert_eq!(file_index.cross_file_links[0].fragment, "section");
1206 }
1207}