1use crate::rule::{CrossFileScope, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::anchor_styles::AnchorStyle;
4use crate::utils::frontmatter_values;
5use crate::utils::range_utils::byte_to_char_count;
6use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex, LinkOrigin};
7use pulldown_cmark::LinkType;
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12use std::sync::LazyLock;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "kebab-case")]
17pub struct MD051Config {
18 #[serde(default, alias = "anchor_style")]
20 pub anchor_style: AnchorStyle,
21
22 #[serde(default = "default_ignore_case", alias = "ignore_case")]
28 pub ignore_case: bool,
29
30 #[serde(default, alias = "ignored_pattern")]
34 pub ignored_pattern: Option<String>,
35
36 #[serde(default)]
55 pub check_frontmatter: bool,
56
57 #[serde(default)]
61 pub ignore_frontmatter_fields: Vec<String>,
62}
63
64fn default_ignore_case() -> bool {
65 true
66}
67
68impl Default for MD051Config {
69 fn default() -> Self {
70 Self {
71 anchor_style: AnchorStyle::default(),
72 ignore_case: true,
73 ignored_pattern: None,
74 check_frontmatter: false,
75 ignore_frontmatter_fields: Vec::new(),
76 }
77 }
78}
79
80impl RuleConfig for MD051Config {
81 const RULE_NAME: &'static str = "MD051";
82}
83static HTML_ANCHOR_PATTERN: LazyLock<Regex> =
86 LazyLock::new(|| Regex::new(r#"\b(?:id|name)\s*=\s*["']([^"']+)["']"#).unwrap());
87
88static ATTR_ANCHOR_PATTERN: LazyLock<Regex> =
92 LazyLock::new(|| Regex::new(r#"\{\s*#([a-zA-Z0-9_][a-zA-Z0-9_-]*)[^}]*\}"#).unwrap());
93
94static MD_SETTING_PATTERN: LazyLock<Regex> =
97 LazyLock::new(|| Regex::new(r"<!--\s*md:setting\s+([^\s]+)\s*-->").unwrap());
98
99#[derive(Clone)]
106pub struct MD051LinkFragments {
107 config: MD051Config,
108 ignored_pattern_regex: Option<Regex>,
112 ignored_front_matter_fields: HashSet<String>,
114 anchor_style_pinned: bool,
118}
119
120struct AnchorSets {
125 markdown_headings: HashSet<String>,
126 markdown_headings_exact: HashSet<String>,
127 html_anchors: HashSet<String>,
128 html_anchors_exact: HashSet<String>,
129}
130
131impl Default for MD051LinkFragments {
132 fn default() -> Self {
133 Self::new()
134 }
135}
136
137impl MD051LinkFragments {
138 pub fn new() -> Self {
139 Self::from_config_struct(MD051Config::default())
140 }
141
142 pub fn with_anchor_style(style: AnchorStyle) -> Self {
144 Self::from_config_struct(MD051Config {
145 anchor_style: style,
146 ..MD051Config::default()
147 })
148 }
149
150 pub fn from_config_struct(config: MD051Config) -> Self {
156 Self::from_config_struct_from(config, false)
157 }
158
159 fn from_config_struct_from(config: MD051Config, values_withheld: bool) -> Self {
162 Self::build(config, values_withheld, true)
163 }
164
165 fn build(config: MD051Config, values_withheld: bool, anchor_style_pinned: bool) -> Self {
169 let ignored_pattern_regex = config.ignored_pattern.as_deref().and_then(|pattern| {
170 crate::rule_config_serde::compile_config_regex(pattern, "MD051", "ignored-pattern", values_withheld)
171 });
172 let ignored_front_matter_fields = config
173 .ignore_frontmatter_fields
174 .iter()
175 .map(|field| field.to_lowercase())
176 .collect();
177 Self {
178 config,
179 ignored_pattern_regex,
180 ignored_front_matter_fields,
181 anchor_style_pinned,
182 }
183 }
184
185 fn anchor_style(&self, ctx: &crate::lint_context::LintContext) -> AnchorStyle {
192 if self.anchor_style_pinned {
193 self.config.anchor_style
194 } else {
195 AnchorStyle::for_flavor(ctx.flavor)
196 }
197 }
198
199 fn parse_blockquote_heading(bq_content: &str) -> Option<(String, Option<String>)> {
203 crate::utils::header_id_utils::parse_blockquote_atx_heading(bq_content)
204 }
205
206 fn insert_deduplicated_fragment(
214 fragment: String,
215 fragment_counts: &mut HashMap<String, usize>,
216 markdown_headings: &mut HashSet<String>,
217 mut markdown_headings_exact: Option<&mut HashSet<String>>,
218 use_underscore_dedup: bool,
219 ) {
220 let mut also_insert_exact = |form: &str| {
226 if let Some(set) = markdown_headings_exact.as_deref_mut() {
227 set.insert(form.to_string());
228 }
229 };
230
231 if fragment.is_empty() {
232 if !use_underscore_dedup {
233 return;
234 }
235 let count = fragment_counts.entry(fragment).or_insert(0);
237 *count += 1;
238 let formed = format!("_{count}");
239 also_insert_exact(&formed);
240 markdown_headings.insert(formed);
241 return;
242 }
243 if let Some(count) = fragment_counts.get_mut(&fragment) {
244 let suffix = *count;
245 *count += 1;
246 if use_underscore_dedup {
247 let underscore_form = format!("{fragment}_{suffix}");
249 also_insert_exact(&underscore_form);
250 markdown_headings.insert(underscore_form);
251 let dash_form = format!("{fragment}-{suffix}");
253 also_insert_exact(&dash_form);
254 markdown_headings.insert(dash_form);
255 } else {
256 let form = format!("{fragment}-{suffix}");
258 also_insert_exact(&form);
259 markdown_headings.insert(form);
260 }
261 } else {
262 fragment_counts.insert(fragment.clone(), 1);
263 also_insert_exact(&fragment);
264 markdown_headings.insert(fragment);
265 }
266 }
267
268 #[allow(clippy::too_many_arguments)]
277 fn add_heading_to_index(
278 fragment: &str,
279 text: &str,
280 custom_anchor: Option<String>,
281 line: usize,
282 is_setext: bool,
283 fragment_counts: &mut HashMap<String, usize>,
284 file_index: &mut FileIndex,
285 use_underscore_dedup: bool,
286 ) {
287 if fragment.is_empty() {
288 if !use_underscore_dedup {
289 return;
290 }
291 let count = fragment_counts.entry(fragment.to_string()).or_insert(0);
293 *count += 1;
294 file_index.add_heading(HeadingIndex {
295 text: text.to_string(),
296 auto_anchor: format!("_{count}"),
297 custom_anchor,
298 line,
299 is_setext,
300 });
301 return;
302 }
303 if let Some(count) = fragment_counts.get_mut(fragment) {
304 let suffix = *count;
305 *count += 1;
306 let (primary, alias) = if use_underscore_dedup {
307 (format!("{fragment}_{suffix}"), Some(format!("{fragment}-{suffix}")))
309 } else {
310 (format!("{fragment}-{suffix}"), None)
312 };
313 file_index.add_heading(HeadingIndex {
314 text: text.to_string(),
315 auto_anchor: primary,
316 custom_anchor,
317 line,
318 is_setext,
319 });
320 if let Some(alias_anchor) = alias {
321 let heading_idx = file_index.headings.len() - 1;
322 file_index.add_anchor_alias(&alias_anchor, heading_idx);
323 }
324 } else {
325 fragment_counts.insert(fragment.to_string(), 1);
326 file_index.add_heading(HeadingIndex {
327 text: text.to_string(),
328 auto_anchor: fragment.to_string(),
329 custom_anchor,
330 line,
331 is_setext,
332 });
333 }
334 }
335
336 fn extract_headings_from_context(&self, ctx: &crate::lint_context::LintContext) -> AnchorSets {
343 let track_exact = !self.config.ignore_case;
344 let mut markdown_headings = HashSet::with_capacity(32);
345 let mut markdown_headings_exact = if track_exact {
346 HashSet::with_capacity(32)
347 } else {
348 HashSet::new()
349 };
350 let mut html_anchors = HashSet::with_capacity(16);
351 let mut html_anchors_exact = if track_exact {
352 HashSet::with_capacity(16)
353 } else {
354 HashSet::new()
355 };
356 let mut fragment_counts = std::collections::HashMap::new();
357 let anchor_style = self.anchor_style(ctx);
358 let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
359
360 for line_info in &ctx.lines {
361 if line_info.in_front_matter {
362 continue;
363 }
364
365 if line_info.in_code_block {
367 continue;
368 }
369
370 let content = line_info.content(ctx.content);
371 let bytes = content.as_bytes();
372
373 if bytes.contains(&b'<') && (content.contains("id=") || content.contains("name=")) {
375 let mut pos = 0;
378 while pos < content.len() {
379 if let Some(start) = content[pos..].find('<') {
380 let tag_start = pos + start;
381 if let Some(end) = content[tag_start..].find('>') {
382 let tag_end = tag_start + end + 1;
383 let tag = &content[tag_start..tag_end];
384
385 if let Some(caps) = HTML_ANCHOR_PATTERN.find(tag) {
387 let matched_text = caps.as_str();
388 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(matched_text)
389 && let Some(id_match) = caps.get(1)
390 {
391 let id = id_match.as_str();
392 if !id.is_empty() {
393 html_anchors.insert(id.to_lowercase());
394 if track_exact {
395 html_anchors_exact.insert(id.to_string());
396 }
397 }
398 }
399 }
400 pos = tag_end;
401 } else {
402 break;
403 }
404 } else {
405 break;
406 }
407 }
408 }
409
410 if line_info.heading.is_none() && content.contains('{') && content.contains('#') {
413 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
414 if let Some(id_match) = caps.get(1) {
415 let id = id_match.as_str();
416 markdown_headings.insert(id.to_lowercase());
417 if track_exact {
418 markdown_headings_exact.insert(id.to_string());
419 }
420 }
421 }
422 }
423
424 if line_info.heading.is_none()
428 && let Some(bq) = &line_info.blockquote
429 && let Some((clean_text, custom_id)) = Self::parse_blockquote_heading(&bq.content)
430 {
431 if let Some(id) = custom_id {
432 markdown_headings.insert(id.to_lowercase());
433 if track_exact {
434 markdown_headings_exact.insert(id);
435 }
436 }
437 let fragment = anchor_style.generate_fragment(&clean_text);
438 Self::insert_deduplicated_fragment(
439 fragment,
440 &mut fragment_counts,
441 &mut markdown_headings,
442 track_exact.then_some(&mut markdown_headings_exact),
443 use_underscore_dedup,
444 );
445 }
446
447 if let Some(heading) = &line_info.heading {
449 if let Some(custom_id) = &heading.custom_id {
451 markdown_headings.insert(custom_id.to_lowercase());
452 if track_exact {
453 markdown_headings_exact.insert(custom_id.clone());
454 }
455 }
456
457 let fragment = anchor_style.generate_fragment(&heading.text);
461
462 Self::insert_deduplicated_fragment(
463 fragment,
464 &mut fragment_counts,
465 &mut markdown_headings,
466 track_exact.then_some(&mut markdown_headings_exact),
467 use_underscore_dedup,
468 );
469 }
470 }
471
472 AnchorSets {
473 markdown_headings,
474 markdown_headings_exact,
475 html_anchors,
476 html_anchors_exact,
477 }
478 }
479
480 #[inline]
482 fn is_external_url_fast(url: &str) -> bool {
483 url.starts_with("http://")
485 || url.starts_with("https://")
486 || url.starts_with("ftp://")
487 || url.starts_with("mailto:")
488 || url.starts_with("tel:")
489 || url.starts_with("//")
490 }
491
492 #[inline]
506 fn is_extensionless_path(path_part: &str) -> bool {
507 if path_part.is_empty() || path_part.contains('.') || path_part.contains('&') || path_part.contains('=') {
509 return false;
510 }
511
512 let mut has_alphanumeric = false;
514 for c in path_part.chars() {
515 if c.is_alphanumeric() {
516 has_alphanumeric = true;
517 } else if !matches!(c, '/' | '\\' | '-' | '_') {
518 return false;
520 }
521 }
522
523 has_alphanumeric
525 }
526
527 #[inline]
529 fn is_cross_file_link(url: &str) -> bool {
530 if let Some(fragment_pos) = url.find('#') {
531 let path_part = &url[..fragment_pos];
532
533 if path_part.is_empty() {
535 return false;
536 }
537
538 if let Some(tag_start) = path_part.find("{%")
544 && path_part[tag_start + 2..].contains("%}")
545 {
546 return true;
547 }
548 if let Some(var_start) = path_part.find("{{")
549 && path_part[var_start + 2..].contains("}}")
550 {
551 return true;
552 }
553
554 if path_part.starts_with('/') {
557 return true;
558 }
559
560 let path_part = path_part.split('?').next().unwrap_or(path_part);
563
564 if path_part.is_empty() {
566 return false;
567 }
568
569 let has_extension = path_part.contains('.')
575 && (
576 {
578 if let Some(after_dot) = path_part.strip_prefix('.') {
580 let dots_count = path_part.matches('.').count();
581 if dots_count == 1 {
582 !after_dot.is_empty() && after_dot.len() <= 10 &&
585 after_dot.chars().all(|c| c.is_ascii_alphanumeric())
586 } else {
587 path_part.split('.').next_back().is_some_and(|ext| {
589 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
590 })
591 }
592 } else {
593 path_part.split('.').next_back().is_some_and(|ext| {
595 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
596 })
597 }
598 } ||
599 path_part.contains('/') || path_part.contains('\\') ||
601 path_part.starts_with("./") || path_part.starts_with("../")
603 );
604
605 let is_extensionless = Self::is_extensionless_path(path_part);
608
609 has_extension || is_extensionless
610 } else {
611 false
612 }
613 }
614
615 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
620 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
621 }
622
623 fn front_matter_links(&self, ctx: &crate::lint_context::LintContext) -> Vec<frontmatter_values::FrontMatterLink> {
626 if !self.checks_front_matter_of(ctx) {
627 return Vec::new();
628 }
629 frontmatter_values::link_destinations(ctx)
630 .into_iter()
631 .filter(|link| !link.field_is_in(&self.ignored_front_matter_fields))
632 .collect()
633 }
634
635 fn reports_link_from(&self, origin: &LinkOrigin) -> bool {
642 match origin {
643 LinkOrigin::Body => true,
644 LinkOrigin::FrontMatter { field } => {
645 self.config.check_frontmatter
646 && !field
647 .as_ref()
648 .is_some_and(|field| self.ignored_front_matter_fields.contains(field))
649 }
650 }
651 }
652
653 fn fragment_is_exempt(&self, ctx: &crate::lint_context::LintContext, fragment: &str) -> bool {
656 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
661 && (fragment.starts_with("fn:")
662 || fragment.starts_with("fnref:")
663 || (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
664 {
665 return true;
666 }
667
668 self.ignored_pattern_regex
670 .as_ref()
671 .is_some_and(|re| re.is_match(fragment))
672 }
673
674 fn fragment_resolves(&self, fragment: &str, anchors: &AnchorSets) -> bool {
678 if self.config.ignore_case {
679 let lower = fragment.to_lowercase();
680 anchors.html_anchors.contains(&lower) || anchors.markdown_headings.contains(&lower)
681 } else {
682 anchors.html_anchors_exact.contains(fragment) || anchors.markdown_headings_exact.contains(fragment)
683 }
684 }
685
686 fn check_front_matter(
693 &self,
694 ctx: &crate::lint_context::LintContext,
695 links: &[frontmatter_values::FrontMatterLink],
696 anchors: &AnchorSets,
697 warnings: &mut Vec<LintWarning>,
698 ) {
699 for link in links {
700 let line = ctx.lines[link.line - 1].content(ctx.content);
701 let Some(fragment) = line[link.range.clone()].strip_prefix('#') else {
702 continue;
703 };
704 if fragment.is_empty() {
705 continue;
706 }
707
708 if ctx.flavor.is_pandoc_compatible() && ctx.has_pandoc_slug(fragment) {
711 continue;
712 }
713
714 if self.fragment_is_exempt(ctx, fragment) || self.fragment_resolves(fragment, anchors) {
715 continue;
716 }
717
718 let column = byte_to_char_count(line, link.range.start);
719 warnings.push(LintWarning {
720 rule_name: Some(self.name().to_string()),
721 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
722 line: link.line,
723 column,
724 end_line: link.line,
725 end_column: column + 1 + fragment.chars().count(),
726 severity: Severity::Error,
727 fix: None,
728 });
729 }
730 }
731}
732
733impl Rule for MD051LinkFragments {
734 fn name(&self) -> &'static str {
735 "MD051"
736 }
737
738 fn description(&self) -> &'static str {
739 "Link fragments should reference valid headings"
740 }
741
742 fn fix_capability(&self) -> FixCapability {
743 FixCapability::Unfixable
744 }
745
746 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
747 if !ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx) {
751 return true;
752 }
753 !ctx.has_char('#')
755 }
756
757 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
758 let mut warnings = Vec::new();
759
760 if ctx.content.is_empty() || self.should_skip(ctx) {
761 return Ok(warnings);
762 }
763
764 let front_matter_links = self.front_matter_links(ctx);
765 if ctx.links.is_empty() && front_matter_links.is_empty() {
766 return Ok(warnings);
767 }
768
769 let anchors = self.extract_headings_from_context(ctx);
770
771 for link in &ctx.links {
772 if link.is_reference {
773 continue;
774 }
775
776 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
778 continue;
779 }
780
781 if matches!(link.link_type, LinkType::WikiLink { .. }) {
783 continue;
784 }
785
786 if ctx.is_in_jinja_range(link.byte_offset) {
788 continue;
789 }
790
791 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
794 continue;
795 }
796
797 if ctx.is_in_shortcode(link.byte_offset) {
800 continue;
801 }
802
803 let url = &link.url;
804
805 if !url.contains('#') || Self::is_external_url_fast(url) {
807 continue;
808 }
809
810 if url.contains("{{#") && url.contains("}}") {
813 continue;
814 }
815
816 if ctx.flavor.is_pandoc_compatible()
822 && let Some(frag) = url.strip_prefix('#')
823 && ctx.has_pandoc_slug(frag)
824 {
825 continue;
826 }
827
828 if url.starts_with('@') {
832 continue;
833 }
834
835 if Self::is_cross_file_link(url) {
837 continue;
838 }
839
840 let Some(fragment_pos) = url.find('#') else {
841 continue;
842 };
843
844 let fragment = &url[fragment_pos + 1..];
845
846 if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
848 continue;
849 }
850
851 if fragment.is_empty() {
852 continue;
853 }
854
855 if self.fragment_is_exempt(ctx, fragment) {
856 continue;
857 }
858
859 if !self.fragment_resolves(fragment, &anchors) {
860 warnings.push(LintWarning {
861 rule_name: Some(self.name().to_string()),
862 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
863 line: link.line,
864 column: link.start_col + 1,
865 end_line: link.end_line,
866 end_column: link.end_col + 1,
867 severity: Severity::Error,
868 fix: None,
869 });
870 }
871 }
872
873 self.check_front_matter(ctx, &front_matter_links, &anchors, &mut warnings);
874
875 Ok(warnings)
876 }
877
878 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
879 Ok(ctx.content.to_string())
882 }
883
884 fn as_any(&self) -> &dyn std::any::Any {
885 self
886 }
887
888 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
889 where
890 Self: Sized,
891 {
892 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD051Config>(config);
893
894 let explicit_style_present = config
899 .rules
900 .get("MD051")
901 .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
902 if !explicit_style_present {
903 rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
904 }
905
906 Box::new(MD051LinkFragments::build(
907 rule_config,
908 config.withheld_rule_values.contains("MD051"),
909 explicit_style_present,
910 ))
911 }
912
913 fn category(&self) -> RuleCategory {
914 RuleCategory::Link
915 }
916
917 fn skippable_by_category(&self) -> bool {
918 !self.config.check_frontmatter
921 }
922
923 fn cross_file_scope(&self) -> CrossFileScope {
924 CrossFileScope::Workspace
925 }
926
927 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
928 let mut fragment_counts = HashMap::new();
929 let anchor_style = self.anchor_style(ctx);
930 let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
931
932 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
934 if line_info.in_front_matter {
935 continue;
936 }
937
938 if line_info.in_code_block {
940 continue;
941 }
942
943 let content = line_info.content(ctx.content);
944
945 if content.contains('<') && (content.contains("id=") || content.contains("name=")) {
947 let mut pos = 0;
948 while pos < content.len() {
949 if let Some(start) = content[pos..].find('<') {
950 let tag_start = pos + start;
951 if let Some(end) = content[tag_start..].find('>') {
952 let tag_end = tag_start + end + 1;
953 let tag = &content[tag_start..tag_end];
954
955 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(tag)
956 && let Some(id_match) = caps.get(1)
957 {
958 file_index.add_html_anchor(id_match.as_str());
959 }
960 pos = tag_end;
961 } else {
962 break;
963 }
964 } else {
965 break;
966 }
967 }
968 }
969
970 if line_info.heading.is_none() && content.contains('{') && content.contains('#') {
973 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
974 if let Some(id_match) = caps.get(1) {
975 file_index.add_attribute_anchor(id_match.as_str());
976 }
977 }
978 }
979
980 if line_info.heading.is_none()
982 && let Some(bq) = &line_info.blockquote
983 && let Some((clean_text, custom_id)) = Self::parse_blockquote_heading(&bq.content)
984 {
985 let fragment = anchor_style.generate_fragment(&clean_text);
986 Self::add_heading_to_index(
987 &fragment,
988 &clean_text,
989 custom_id,
990 line_idx + 1,
991 false,
992 &mut fragment_counts,
993 file_index,
994 use_underscore_dedup,
995 );
996 }
997
998 if let Some(heading) = &line_info.heading {
1000 let fragment = anchor_style.generate_fragment(&heading.text);
1001 let is_setext = matches!(
1002 heading.style,
1003 crate::lint_context::types::HeadingStyle::Setext1
1004 | crate::lint_context::types::HeadingStyle::Setext2
1005 );
1006
1007 Self::add_heading_to_index(
1008 &fragment,
1009 &heading.text,
1010 heading.custom_id.clone(),
1011 line_idx + 1,
1012 is_setext,
1013 &mut fragment_counts,
1014 file_index,
1015 use_underscore_dedup,
1016 );
1017
1018 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
1023 && let Some(caps) = MD_SETTING_PATTERN.captures(content)
1024 && let Some(name) = caps.get(1)
1025 {
1026 file_index.add_html_anchor(name.as_str());
1027 }
1028 }
1029 }
1030
1031 for link in &ctx.links {
1033 if link.is_reference {
1034 continue;
1035 }
1036
1037 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
1039 continue;
1040 }
1041
1042 if matches!(link.link_type, LinkType::WikiLink { .. }) {
1045 continue;
1046 }
1047
1048 let url = &link.url;
1049
1050 if Self::is_external_url_fast(url) {
1052 continue;
1053 }
1054
1055 if Self::is_cross_file_link(url)
1057 && let Some(fragment_pos) = url.find('#')
1058 {
1059 let path_part = &url[..fragment_pos];
1060 let fragment = &url[fragment_pos + 1..];
1061
1062 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1064 continue;
1065 }
1066
1067 file_index.add_cross_file_link(CrossFileLinkIndex {
1068 target_path: path_part.to_string(),
1069 fragment: fragment.to_string(),
1070 line: link.line,
1071 column: link.start_col + 1,
1072 origin: LinkOrigin::Body,
1073 });
1074 }
1075 }
1076
1077 for link in frontmatter_values::link_destinations(ctx) {
1084 let line = ctx.lines[link.line - 1].content(ctx.content);
1085 let value = &line[link.range.clone()];
1086
1087 if Self::is_external_url_fast(value) || !Self::is_cross_file_link(value) {
1088 continue;
1089 }
1090
1091 let Some(fragment_pos) = value.find('#') else {
1092 continue;
1093 };
1094 let path_part = &value[..fragment_pos];
1095 let fragment = &value[fragment_pos + 1..];
1096
1097 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1099 continue;
1100 }
1101
1102 file_index.add_cross_file_link(CrossFileLinkIndex {
1103 target_path: path_part.to_string(),
1104 fragment: fragment.to_string(),
1105 line: link.line,
1106 column: byte_to_char_count(line, link.range.start),
1107 origin: LinkOrigin::FrontMatter { field: link.field },
1108 });
1109 }
1110 }
1111
1112 fn cross_file_check(
1113 &self,
1114 file_path: &Path,
1115 file_index: &FileIndex,
1116 workspace_index: &crate::workspace_index::WorkspaceIndex,
1117 ) -> LintResult {
1118 let mut warnings = Vec::new();
1119
1120 let ignored_pattern = self.ignored_pattern_regex.as_ref();
1121 let ignore_case = self.config.ignore_case;
1122
1123 for cross_link in &file_index.cross_file_links {
1125 if cross_link.fragment.is_empty() {
1127 continue;
1128 }
1129
1130 if !self.reports_link_from(&cross_link.origin) {
1133 continue;
1134 }
1135
1136 if ignored_pattern.is_some_and(|re| re.is_match(&cross_link.fragment)) {
1138 continue;
1139 }
1140
1141 let target_paths_to_try =
1144 crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path);
1145
1146 let mut target_file_index = None;
1148
1149 for target_path in &target_paths_to_try {
1150 if let Some(index) = workspace_index.get_file(target_path) {
1151 target_file_index = Some(index);
1152 break;
1153 }
1154 }
1155
1156 if let Some(target_file_index) = target_file_index {
1157 if !target_file_index.has_anchor_with_case(&cross_link.fragment, ignore_case) {
1159 warnings.push(LintWarning {
1160 rule_name: Some(self.name().to_string()),
1161 line: cross_link.line,
1162 column: cross_link.column,
1163 end_line: cross_link.line,
1164 end_column: cross_link.column
1165 + cross_link.target_path.chars().count()
1166 + 1
1167 + cross_link.fragment.chars().count(),
1168 message: format!(
1169 "Link fragment '{}' not found in '{}'",
1170 cross_link.fragment, cross_link.target_path
1171 ),
1172 severity: Severity::Error,
1173 fix: None,
1174 });
1175 }
1176 }
1177 }
1179
1180 Ok(warnings)
1181 }
1182
1183 crate::impl_rule_config_sections!(MD051Config);
1184}
1185
1186#[cfg(test)]
1187mod tests {
1188 use super::*;
1189 use crate::lint_context::LintContext;
1190 use std::path::PathBuf;
1191
1192 const ANCHOR_STYLE_PROBE: &str = "### Getting Started — Advanced\n\n\
1196 [python-markdown slug](#getting-started-advanced)\n\
1197 [github slug](#getting-started--advanced)\n";
1198
1199 fn flagged_fragment(rule: &dyn Rule, flavor: crate::config::MarkdownFlavor) -> String {
1200 let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
1201 let warnings = rule.check(&ctx).unwrap();
1202 assert_eq!(
1203 warnings.len(),
1204 1,
1205 "exactly one of the two links must be invalid under any style: {warnings:?}"
1206 );
1207 warnings[0].message.clone()
1208 }
1209
1210 #[test]
1214 fn test_unpinned_anchor_style_follows_the_file_flavor() {
1215 let rule_from_global = |flavor| {
1216 let mut config = crate::config::Config::default();
1217 config.global.flavor = flavor;
1218 MD051LinkFragments::from_config(&config)
1219 };
1220
1221 let standard_global = rule_from_global(crate::config::MarkdownFlavor::Standard);
1223 assert!(
1226 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1227 .contains("#getting-started-advanced'"),
1228 "a standard file must be checked against GitHub anchors"
1229 );
1230 assert!(
1233 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1234 .contains("#getting-started--advanced'"),
1235 "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
1236 );
1237
1238 let mkdocs_global = rule_from_global(crate::config::MarkdownFlavor::MkDocs);
1240 assert!(
1241 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1242 .contains("#getting-started--advanced'"),
1243 "a mkdocs file must be checked against Python-Markdown anchors"
1244 );
1245 assert!(
1246 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1247 .contains("#getting-started-advanced'"),
1248 "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
1249 );
1250 }
1251
1252 #[test]
1255 fn test_pinned_anchor_style_ignores_the_file_flavor() {
1256 let mut config = crate::config::Config::default();
1257 config.global.flavor = crate::config::MarkdownFlavor::Standard;
1258 let mut rule_config = crate::config::RuleConfig::default();
1259 rule_config
1260 .values
1261 .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
1262 config.rules.insert("MD051".to_string(), rule_config);
1263 let rule = MD051LinkFragments::from_config(&config);
1264
1265 for flavor in [
1266 crate::config::MarkdownFlavor::Standard,
1267 crate::config::MarkdownFlavor::MkDocs,
1268 crate::config::MarkdownFlavor::Kramdown,
1269 ] {
1270 assert!(
1271 flagged_fragment(rule.as_ref(), flavor).contains("#getting-started-advanced'"),
1272 "pinned github anchors must survive a {flavor:?} file"
1273 );
1274 }
1275 }
1276
1277 #[test]
1280 fn test_directly_constructed_rule_keeps_its_anchor_style() {
1281 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1282 anchor_style: AnchorStyle::PythonMarkdown,
1283 ..Default::default()
1284 });
1285 assert!(
1286 flagged_fragment(&rule, crate::config::MarkdownFlavor::Standard).contains("#getting-started--advanced'"),
1287 "an explicitly constructed Python-Markdown rule must not follow the file flavor"
1288 );
1289 }
1290
1291 #[test]
1292 fn test_quarto_cross_references() {
1293 let rule = MD051LinkFragments::new();
1294
1295 let content = r#"# Test Document
1297
1298## Figures
1299
1300See [@fig-plot] for the visualization.
1301
1302More details in [@tbl-results] and [@sec-methods].
1303
1304The equation [@eq-regression] shows the relationship.
1305
1306Reference to [@lst-code] for implementation."#;
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1308 let result = rule.check(&ctx).unwrap();
1309 assert!(
1310 result.is_empty(),
1311 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
1312 result.len()
1313 );
1314
1315 let content_with_anchor = r#"# Test
1317
1318See [link](#test) for details."#;
1319 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
1320 let result_anchor = rule.check(&ctx_anchor).unwrap();
1321 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
1322
1323 let content_invalid = r#"# Test
1325
1326See [link](#nonexistent) for details."#;
1327 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
1328 let result_invalid = rule.check(&ctx_invalid).unwrap();
1329 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
1330 }
1331
1332 #[test]
1333 fn test_jsx_in_heading_anchor() {
1334 let rule = MD051LinkFragments::new();
1336
1337 let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
1339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340 let result = rule.check(&ctx).unwrap();
1341 assert!(
1342 result.is_empty(),
1343 "JSX self-closing tag should be stripped from anchor: got {result:?}"
1344 );
1345
1346 let content2 =
1348 "### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
1349 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1350 let result2 = rule.check(&ctx2).unwrap();
1351 assert!(
1352 result2.is_empty(),
1353 "JSX tag with attributes should be stripped from anchor: got {result2:?}"
1354 );
1355
1356 let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
1358 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1359 let result3 = rule.check(&ctx3).unwrap();
1360 assert!(
1361 result3.is_empty(),
1362 "HTML tag content should be preserved in anchor: got {result3:?}"
1363 );
1364 }
1365
1366 #[test]
1368 fn test_cross_file_scope() {
1369 let rule = MD051LinkFragments::new();
1370 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1371 }
1372
1373 #[test]
1374 fn test_contribute_to_index_extracts_headings() {
1375 let rule = MD051LinkFragments::new();
1376 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378
1379 let mut file_index = FileIndex::new();
1380 rule.contribute_to_index(&ctx, &mut file_index);
1381
1382 assert_eq!(file_index.headings.len(), 3);
1383 assert_eq!(file_index.headings[0].text, "First Heading");
1384 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
1385 assert!(file_index.headings[0].custom_anchor.is_none());
1386
1387 assert_eq!(file_index.headings[1].text, "Second");
1388 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
1389
1390 assert_eq!(file_index.headings[2].text, "Third");
1391 }
1392
1393 #[test]
1394 fn test_contribute_to_index_extracts_cross_file_links() {
1395 let rule = MD051LinkFragments::new();
1396 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
1397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1398
1399 let mut file_index = FileIndex::new();
1400 rule.contribute_to_index(&ctx, &mut file_index);
1401
1402 assert_eq!(file_index.cross_file_links.len(), 2);
1403 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1404 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
1405 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
1406 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
1407 }
1408
1409 #[test]
1413 fn test_contribute_to_index_records_setext_headings() {
1414 let rule = MD051LinkFragments::new();
1415 let content = "Setext One\n==========\n\nSetext Two\n----------\n\n### Atx Three\n";
1416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417
1418 let mut file_index = FileIndex::new();
1419 rule.contribute_to_index(&ctx, &mut file_index);
1420
1421 let styles: Vec<(&str, bool)> = file_index
1422 .headings
1423 .iter()
1424 .map(|h| (h.text.as_str(), h.is_setext))
1425 .collect();
1426 assert_eq!(
1427 styles,
1428 vec![("Setext One", true), ("Setext Two", true), ("Atx Three", false)]
1429 );
1430 }
1431
1432 #[test]
1440 fn test_a_frontmatter_link_is_indexed_regardless_of_the_indexing_config() {
1441 let content = "---\nlink: 'other.md#nope'\n---\n\n# Real\n";
1442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1443
1444 for check_frontmatter in [true, false] {
1445 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1446 check_frontmatter,
1447 ..Default::default()
1448 });
1449 let mut file_index = FileIndex::new();
1450 rule.contribute_to_index(&ctx, &mut file_index);
1451
1452 assert_eq!(
1453 file_index.cross_file_links.len(),
1454 1,
1455 "check_frontmatter = {check_frontmatter} changed what was indexed"
1456 );
1457 assert_eq!(
1458 file_index.cross_file_links[0].origin,
1459 LinkOrigin::FrontMatter {
1460 field: Some("link".to_string())
1461 },
1462 );
1463 }
1464 }
1465
1466 #[test]
1470 fn test_cross_file_check_applies_this_files_frontmatter_config() {
1471 use crate::workspace_index::WorkspaceIndex;
1472
1473 let mut workspace_index = WorkspaceIndex::new();
1474 let mut target = FileIndex::new();
1475 target.add_heading(HeadingIndex {
1476 text: "Real".to_string(),
1477 auto_anchor: "real".to_string(),
1478 custom_anchor: None,
1479 line: 1,
1480 is_setext: false,
1481 });
1482 workspace_index.insert_file(PathBuf::from("docs/other.md"), target);
1483
1484 let mut file_index = FileIndex::new();
1485 file_index.add_cross_file_link(CrossFileLinkIndex {
1486 target_path: "other.md".to_string(),
1487 fragment: "nope".to_string(),
1488 line: 2,
1489 column: 7,
1490 origin: LinkOrigin::FrontMatter {
1491 field: Some("link".to_string()),
1492 },
1493 });
1494 file_index.add_cross_file_link(CrossFileLinkIndex {
1498 target_path: "other.md".to_string(),
1499 fragment: "nope".to_string(),
1500 line: 6,
1501 column: 5,
1502 origin: LinkOrigin::Body,
1503 });
1504
1505 let count = |config: MD051Config| {
1506 MD051LinkFragments::from_config_struct(config)
1507 .cross_file_check(Path::new("docs/readme.md"), &file_index, &workspace_index)
1508 .unwrap()
1509 .len()
1510 };
1511
1512 assert_eq!(
1513 count(MD051Config {
1514 check_frontmatter: true,
1515 ..Default::default()
1516 }),
1517 2,
1518 "checking frontmatter should report both the frontmatter and body links"
1519 );
1520 assert_eq!(
1521 count(MD051Config {
1522 check_frontmatter: false,
1523 ..Default::default()
1524 }),
1525 1,
1526 "not checking frontmatter should leave only the body link"
1527 );
1528 assert_eq!(
1529 count(MD051Config {
1530 check_frontmatter: true,
1531 ignore_frontmatter_fields: vec!["LINK".to_string()],
1532 ..Default::default()
1533 }),
1534 1,
1535 "an ignored field should be matched case-insensitively"
1536 );
1537 }
1538
1539 #[test]
1540 fn test_cross_file_check_valid_fragment() {
1541 use crate::workspace_index::WorkspaceIndex;
1542
1543 let rule = MD051LinkFragments::new();
1544
1545 let mut workspace_index = WorkspaceIndex::new();
1547 let mut target_file_index = FileIndex::new();
1548 target_file_index.add_heading(HeadingIndex {
1549 text: "Installation Guide".to_string(),
1550 auto_anchor: "installation-guide".to_string(),
1551 custom_anchor: None,
1552 line: 1,
1553 is_setext: false,
1554 });
1555 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1556
1557 let mut current_file_index = FileIndex::new();
1559 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1560 target_path: "install.md".to_string(),
1561 fragment: "installation-guide".to_string(),
1562 line: 3,
1563 column: 5,
1564 origin: LinkOrigin::Body,
1565 });
1566
1567 let warnings = rule
1568 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1569 .unwrap();
1570
1571 assert!(warnings.is_empty());
1573 }
1574
1575 #[test]
1576 fn test_cross_file_check_invalid_fragment() {
1577 use crate::workspace_index::WorkspaceIndex;
1578
1579 let rule = MD051LinkFragments::new();
1580
1581 let mut workspace_index = WorkspaceIndex::new();
1583 let mut target_file_index = FileIndex::new();
1584 target_file_index.add_heading(HeadingIndex {
1585 text: "Installation Guide".to_string(),
1586 auto_anchor: "installation-guide".to_string(),
1587 custom_anchor: None,
1588 line: 1,
1589 is_setext: false,
1590 });
1591 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1592
1593 let mut current_file_index = FileIndex::new();
1595 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1596 target_path: "install.md".to_string(),
1597 fragment: "nonexistent".to_string(),
1598 line: 3,
1599 column: 5,
1600 origin: LinkOrigin::Body,
1601 });
1602
1603 let warnings = rule
1604 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1605 .unwrap();
1606
1607 assert_eq!(warnings.len(), 1);
1609 assert!(warnings[0].message.contains("nonexistent"));
1610 assert!(warnings[0].message.contains("install.md"));
1611 }
1612
1613 #[test]
1614 fn test_cross_file_check_custom_anchor_match() {
1615 use crate::workspace_index::WorkspaceIndex;
1616
1617 let rule = MD051LinkFragments::new();
1618
1619 let mut workspace_index = WorkspaceIndex::new();
1621 let mut target_file_index = FileIndex::new();
1622 target_file_index.add_heading(HeadingIndex {
1623 text: "Installation Guide".to_string(),
1624 auto_anchor: "installation-guide".to_string(),
1625 custom_anchor: Some("install".to_string()),
1626 line: 1,
1627 is_setext: false,
1628 });
1629 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1630
1631 let mut current_file_index = FileIndex::new();
1633 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1634 target_path: "install.md".to_string(),
1635 fragment: "install".to_string(),
1636 line: 3,
1637 column: 5,
1638 origin: LinkOrigin::Body,
1639 });
1640
1641 let warnings = rule
1642 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1643 .unwrap();
1644
1645 assert!(warnings.is_empty());
1647 }
1648
1649 #[test]
1650 fn test_cross_file_check_target_not_in_workspace() {
1651 use crate::workspace_index::WorkspaceIndex;
1652
1653 let rule = MD051LinkFragments::new();
1654
1655 let workspace_index = WorkspaceIndex::new();
1657
1658 let mut current_file_index = FileIndex::new();
1660 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1661 target_path: "external.md".to_string(),
1662 fragment: "heading".to_string(),
1663 line: 3,
1664 column: 5,
1665 origin: LinkOrigin::Body,
1666 });
1667
1668 let warnings = rule
1669 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1670 .unwrap();
1671
1672 assert!(warnings.is_empty());
1674 }
1675
1676 #[test]
1677 fn test_wikilinks_skipped_in_check() {
1678 let rule = MD051LinkFragments::new();
1680
1681 let content = r#"# Test Document
1682
1683## Valid Heading
1684
1685[[Microsoft#Windows OS]]
1686[[SomePage#section]]
1687[[page|Display Text]]
1688[[path/to/page#section]]
1689"#;
1690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1691 let result = rule.check(&ctx).unwrap();
1692
1693 assert!(
1694 result.is_empty(),
1695 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1696 );
1697 }
1698
1699 #[test]
1700 fn test_wikilinks_not_added_to_cross_file_index() {
1701 let rule = MD051LinkFragments::new();
1703
1704 let content = r#"# Test Document
1705
1706[[Microsoft#Windows OS]]
1707[[SomePage#section]]
1708[Regular Link](other.md#section)
1709"#;
1710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1711
1712 let mut file_index = FileIndex::new();
1713 rule.contribute_to_index(&ctx, &mut file_index);
1714
1715 let cross_file_links = &file_index.cross_file_links;
1718 assert_eq!(
1719 cross_file_links.len(),
1720 1,
1721 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1722 );
1723 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1724 assert_eq!(file_index.cross_file_links[0].fragment, "section");
1725 }
1726
1727 #[test]
1728 fn test_pandoc_flavor_skips_citations() {
1729 let rule = MD051LinkFragments::new();
1733 let content = "# Test Document\n\nSee [@smith2020] for details.\n";
1734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1735 let result = rule.check(&ctx).unwrap();
1736 assert!(
1737 result.is_empty(),
1738 "MD051 should skip Pandoc citations under Pandoc flavor: {result:?}"
1739 );
1740 }
1741
1742 #[test]
1743 fn md051_pandoc_resolves_pandoc_slug_diverging_from_github() {
1744 use crate::config::MarkdownFlavor;
1751 let rule = MD051LinkFragments::new();
1752 let content = "# 5. Five Things\n\nSee [details](#5.-five-things).\n";
1753
1754 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1757 let std_result = rule.check(&ctx_std).unwrap();
1758 assert_eq!(
1759 std_result.len(),
1760 1,
1761 "Standard flavor should flag the Pandoc-style fragment: {std_result:?}"
1762 );
1763
1764 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1766 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1767 assert!(
1768 pandoc_result.is_empty(),
1769 "Pandoc flavor should resolve `#5.-five-things` against the heading slug: {pandoc_result:?}"
1770 );
1771 }
1772
1773 #[test]
1777 fn md051_pandoc_flags_missing_fragment_with_email_in_link_text() {
1778 use crate::config::MarkdownFlavor;
1779 let rule = MD051LinkFragments::new();
1780 let content = "# Title\n\n[contact user@example.com](#missing)\n";
1781
1782 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1783 let std_result = rule.check(&ctx_std).unwrap();
1784 assert_eq!(
1785 std_result.len(),
1786 1,
1787 "Standard flavor must flag the missing fragment: {std_result:?}"
1788 );
1789
1790 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1791 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1792 assert_eq!(
1793 pandoc_result.len(),
1794 1,
1795 "Pandoc flavor must also flag the missing fragment — link text with embedded email is not a citation: {pandoc_result:?}"
1796 );
1797 }
1798
1799 #[test]
1803 fn md051_pandoc_flags_missing_fragment_with_citation_in_link_text() {
1804 use crate::config::MarkdownFlavor;
1805 let rule = MD051LinkFragments::new();
1806 let content = "# Title\n\n[see @smith2020](#missing)\n";
1807
1808 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1809 let std_result = rule.check(&ctx_std).unwrap();
1810 assert_eq!(
1811 std_result.len(),
1812 1,
1813 "Standard flavor must flag the missing fragment: {std_result:?}"
1814 );
1815
1816 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1817 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1818 assert_eq!(
1819 pandoc_result.len(),
1820 1,
1821 "Pandoc flavor must flag the missing fragment — `[label](url)` is a link, not a citation: {pandoc_result:?}"
1822 );
1823 }
1824
1825 #[test]
1829 fn md051_pandoc_resolves_duplicate_heading_suffix_slug() {
1830 use crate::config::MarkdownFlavor;
1831 let rule = MD051LinkFragments::new();
1832 let content = "# A.\n\nfirst\n\n# A.\n\nsecond\n\n[first](#a.) and [second](#a.-1).\n";
1833
1834 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1835 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1836 assert!(
1837 pandoc_result.is_empty(),
1838 "Pandoc flavor should resolve `#a.` and `#a.-1` against duplicate headings: {pandoc_result:?}"
1839 );
1840
1841 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1842 let quarto_result = rule.check(&ctx_quarto).unwrap();
1843 assert!(
1844 quarto_result.is_empty(),
1845 "Quarto flavor should also resolve duplicate-heading suffix slugs: {quarto_result:?}"
1846 );
1847 }
1848
1849 #[test]
1852 fn md051_pandoc_flags_overshoot_duplicate_suffix() {
1853 use crate::config::MarkdownFlavor;
1854 let rule = MD051LinkFragments::new();
1855 let content = "# A.\n\n# A.\n\n[overshoot](#a.-2)\n";
1856
1857 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1858 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1859 assert_eq!(
1860 pandoc_result.len(),
1861 1,
1862 "Pandoc must flag `#a.-2` when only `-1` exists (two duplicates): {pandoc_result:?}"
1863 );
1864 }
1865
1866 fn front_matter_checked() -> MD051Config {
1867 MD051Config {
1868 check_frontmatter: true,
1869 ..MD051Config::default()
1870 }
1871 }
1872
1873 fn check_front_matter(content: &str, config: MD051Config) -> Vec<LintWarning> {
1874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1875 MD051LinkFragments::from_config_struct(config).check(&ctx).unwrap()
1876 }
1877
1878 #[test]
1879 fn a_broken_frontmatter_fragment_is_reported_when_enabled() {
1880 let content = "---\nanchor: '#missing'\nvalid: '#title'\n---\n\n# Title\n";
1881 let result = check_front_matter(content, front_matter_checked());
1882
1883 assert_eq!(
1884 result.len(),
1885 1,
1886 "Only the unresolved fragment is reported. Got: {result:?}"
1887 );
1888 assert_eq!(
1889 result[0].message,
1890 "Link anchor '#missing' does not exist in document headings"
1891 );
1892 assert_eq!(result[0].line, 2);
1893 assert_eq!(result[0].column, 10, "The warning points at the value, not the key");
1894 assert_eq!(result[0].end_column, 18);
1895 }
1896
1897 #[test]
1898 fn frontmatter_fragments_are_not_checked_by_default() {
1899 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
1900 let result = check_front_matter(content, MD051Config::default());
1901
1902 assert!(
1903 result.is_empty(),
1904 "Frontmatter is only checked on request. Got: {result:?}"
1905 );
1906 }
1907
1908 #[test]
1909 fn an_ignored_frontmatter_field_is_not_checked() {
1910 let content = "---\nhero: '#missing'\nanchor: '#other'\n---\n\n# Title\n";
1911 let config = MD051Config {
1912 check_frontmatter: true,
1913 ignore_frontmatter_fields: vec!["Hero".to_string()],
1914 ..MD051Config::default()
1915 };
1916 let result = check_front_matter(content, config);
1917
1918 assert_eq!(
1919 result.len(),
1920 1,
1921 "The ignored field is skipped and the other is not. Got: {result:?}"
1922 );
1923 assert_eq!(result[0].line, 3);
1924 }
1925
1926 #[test]
1927 fn the_ignored_pattern_applies_to_frontmatter_fragments() {
1928 let content = "---\nnote: '#fn:1'\nanchor: '#missing'\n---\n\n# Title\n";
1929 let config = MD051Config {
1930 check_frontmatter: true,
1931 ignored_pattern: Some("^fn:".to_string()),
1932 ..MD051Config::default()
1933 };
1934 let result = check_front_matter(content, config);
1935
1936 assert_eq!(
1937 result.len(),
1938 1,
1939 "The matching fragment is skipped and the other is not. Got: {result:?}"
1940 );
1941 assert_eq!(result[0].line, 3);
1942 }
1943
1944 #[test]
1945 fn a_frontmatter_fragment_honors_ignore_case() {
1946 let content = "---\nanchor: '#Title'\n---\n\n# Title\n";
1947
1948 let permissive = check_front_matter(content, front_matter_checked());
1949 assert!(
1950 permissive.is_empty(),
1951 "The default resolves a case mismatch. Got: {permissive:?}"
1952 );
1953
1954 let strict = check_front_matter(
1955 content,
1956 MD051Config {
1957 check_frontmatter: true,
1958 ignore_case: false,
1959 ..MD051Config::default()
1960 },
1961 );
1962 assert_eq!(strict.len(), 1, "Strict matching reports it. Got: {strict:?}");
1963 }
1964
1965 #[test]
1966 fn prose_in_frontmatter_is_not_read_as_a_fragment() {
1967 let content = "---\ntitle: Node.js\ntags: ci/cd\n---\n\n# Title\n";
1968 let result = check_front_matter(content, front_matter_checked());
1969
1970 assert!(
1971 result.is_empty(),
1972 "Only path-shaped values are destinations. Got: {result:?}"
1973 );
1974 }
1975
1976 #[test]
1977 fn a_frontmatter_path_with_a_fragment_is_validated_across_files() {
1978 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1979 let source = "---\ntemplate: other.md#missing\nvalid: other.md#target\n---\n\n# Source\n";
1980
1981 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1982 let mut source_index = FileIndex::default();
1983 rule.contribute_to_index(&source_ctx, &mut source_index);
1984
1985 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1986 let mut target_index = FileIndex::default();
1987 rule.contribute_to_index(&target_ctx, &mut target_index);
1988
1989 let source_path = PathBuf::from("docs/source.md");
1990 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1991 workspace.insert_file(source_path.clone(), source_index.clone());
1992 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1993
1994 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1995
1996 assert_eq!(
1997 warnings.len(),
1998 1,
1999 "Only the unresolved fragment is reported. Got: {warnings:?}"
2000 );
2001 assert_eq!(warnings[0].message, "Link fragment 'missing' not found in 'other.md'");
2002 assert_eq!(warnings[0].line, 2);
2003 assert_eq!(warnings[0].column, 11);
2004 }
2005
2006 #[test]
2007 fn a_query_string_does_not_hide_the_target_file() {
2008 let rule = MD051LinkFragments::new();
2009 let source = "# Source\n\n- [a](other.md?raw=true#missing)\n- [b](other.md?raw=true#target)\n";
2010
2011 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2012 let mut source_index = FileIndex::default();
2013 rule.contribute_to_index(&source_ctx, &mut source_index);
2014
2015 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
2016 let mut target_index = FileIndex::default();
2017 rule.contribute_to_index(&target_ctx, &mut target_index);
2018
2019 let source_path = PathBuf::from("docs/source.md");
2020 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
2021 workspace.insert_file(source_path.clone(), source_index.clone());
2022 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
2023
2024 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
2025
2026 assert_eq!(
2027 warnings.len(),
2028 1,
2029 "The query is stripped to find the file, so both fragments resolve against it. Got: {warnings:?}"
2030 );
2031 assert_eq!(
2032 warnings[0].message,
2033 "Link fragment 'missing' not found in 'other.md?raw=true'"
2034 );
2035 assert_eq!(warnings[0].line, 3);
2036 }
2037
2038 #[test]
2039 fn a_query_string_does_not_hide_an_extensionless_target_file() {
2040 let rule = MD051LinkFragments::new();
2041 let source = "# Source\n\n- [a](other?raw=true#target)\n- [b](other#target)\n- [c](other?raw=true#absent)\n";
2042
2043 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2044 let same_document = rule.check(&source_ctx).unwrap();
2045 assert!(
2046 same_document.is_empty(),
2047 "Every fragment here belongs to another file, so none is a missing anchor of this one. Got: {same_document:?}"
2048 );
2049
2050 let mut source_index = FileIndex::default();
2051 rule.contribute_to_index(&source_ctx, &mut source_index);
2052
2053 let target_ctx = LintContext::new("# Other\n\n## Target\n", crate::config::MarkdownFlavor::Standard, None);
2054 let mut target_index = FileIndex::default();
2055 rule.contribute_to_index(&target_ctx, &mut target_index);
2056
2057 let source_path = PathBuf::from("docs/source.md");
2058 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
2059 workspace.insert_file(source_path.clone(), source_index.clone());
2060 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
2061
2062 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
2063
2064 assert_eq!(
2065 warnings.len(),
2066 1,
2067 "The query is stripped before the markdown extension is added. Got: {warnings:?}"
2068 );
2069 assert_eq!(
2070 warnings[0].message,
2071 "Link fragment 'absent' not found in 'other?raw=true'"
2072 );
2073 assert_eq!(warnings[0].line, 5);
2074 }
2075
2076 #[test]
2077 fn a_destination_that_is_only_a_query_stays_on_this_page() {
2078 let rule = MD051LinkFragments::new();
2079 let source = "# Source\n\n## Here\n\n- [a](?raw=true#here)\n- [b](?raw=true#nowhere)\n";
2080
2081 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2082 let warnings = rule.check(&source_ctx).unwrap();
2083
2084 assert_eq!(
2085 warnings.len(),
2086 1,
2087 "Only the absent anchor is reported. Got: {warnings:?}"
2088 );
2089 assert_eq!(
2090 warnings[0].message,
2091 "Link anchor '#nowhere' does not exist in document headings"
2092 );
2093 assert_eq!(warnings[0].line, 6);
2094
2095 let mut source_index = FileIndex::default();
2096 rule.contribute_to_index(&source_ctx, &mut source_index);
2097 assert!(
2098 source_index.cross_file_links.is_empty(),
2099 "A query with no path names no other file. Got: {:?}",
2100 source_index.cross_file_links
2101 );
2102 }
2103
2104 #[test]
2105 fn a_frontmatter_path_carrying_a_query_is_indexed() {
2106 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
2107 let source = "---\ntemplate: docs/other.md?raw=true#missing\n---\n\n# Source\n";
2108
2109 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2110 let mut source_index = FileIndex::default();
2111 rule.contribute_to_index(&source_ctx, &mut source_index);
2112
2113 assert_eq!(source_index.cross_file_links.len(), 1);
2114 assert_eq!(source_index.cross_file_links[0].target_path, "docs/other.md?raw=true");
2115 assert_eq!(source_index.cross_file_links[0].fragment, "missing");
2116 }
2117
2118 #[test]
2122 fn frontmatter_cross_file_paths_are_not_reported_by_default() {
2123 use crate::workspace_index::WorkspaceIndex;
2124
2125 let rule = MD051LinkFragments::new();
2126 let source = "---\ntemplate: other.md#missing\n---\n\n# Source\n";
2127
2128 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2129 let mut source_index = FileIndex::default();
2130 rule.contribute_to_index(&source_ctx, &mut source_index);
2131 assert_eq!(source_index.cross_file_links.len(), 1);
2132
2133 let mut workspace_index = WorkspaceIndex::new();
2134 let mut target = FileIndex::new();
2135 target.add_heading(HeadingIndex {
2136 text: "Present".to_string(),
2137 auto_anchor: "present".to_string(),
2138 custom_anchor: None,
2139 line: 1,
2140 is_setext: false,
2141 });
2142 workspace_index.insert_file(PathBuf::from("other.md"), target);
2143
2144 let warnings = rule
2145 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2146 .unwrap();
2147 assert!(
2148 warnings.is_empty(),
2149 "Frontmatter is only checked on request. Got: {warnings:?}"
2150 );
2151
2152 let checking = MD051LinkFragments::from_config_struct(MD051Config {
2156 check_frontmatter: true,
2157 ..Default::default()
2158 });
2159 assert_eq!(
2160 checking
2161 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2162 .unwrap()
2163 .len(),
2164 1
2165 );
2166 }
2167}