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 insert_deduplicated_fragment(
207 fragment: String,
208 fragment_counts: &mut HashMap<String, usize>,
209 markdown_headings: &mut HashSet<String>,
210 mut markdown_headings_exact: Option<&mut HashSet<String>>,
211 use_underscore_dedup: bool,
212 ) {
213 let mut also_insert_exact = |form: &str| {
219 if let Some(set) = markdown_headings_exact.as_deref_mut() {
220 set.insert(form.to_string());
221 }
222 };
223
224 if fragment.is_empty() {
225 if !use_underscore_dedup {
226 return;
227 }
228 let count = fragment_counts.entry(fragment).or_insert(0);
230 *count += 1;
231 let formed = format!("_{count}");
232 also_insert_exact(&formed);
233 markdown_headings.insert(formed);
234 return;
235 }
236 if let Some(count) = fragment_counts.get_mut(&fragment) {
237 let suffix = *count;
238 *count += 1;
239 if use_underscore_dedup {
240 let underscore_form = format!("{fragment}_{suffix}");
242 also_insert_exact(&underscore_form);
243 markdown_headings.insert(underscore_form);
244 let dash_form = format!("{fragment}-{suffix}");
246 also_insert_exact(&dash_form);
247 markdown_headings.insert(dash_form);
248 } else {
249 let form = format!("{fragment}-{suffix}");
251 also_insert_exact(&form);
252 markdown_headings.insert(form);
253 }
254 } else {
255 fragment_counts.insert(fragment.clone(), 1);
256 also_insert_exact(&fragment);
257 markdown_headings.insert(fragment);
258 }
259 }
260
261 #[allow(clippy::too_many_arguments)]
270 fn add_heading_to_index(
271 fragment: &str,
272 text: &str,
273 custom_anchor: Option<String>,
274 line: usize,
275 is_setext: bool,
276 fragment_counts: &mut HashMap<String, usize>,
277 file_index: &mut FileIndex,
278 use_underscore_dedup: bool,
279 ) {
280 if fragment.is_empty() {
281 if !use_underscore_dedup {
282 return;
283 }
284 let count = fragment_counts.entry(fragment.to_string()).or_insert(0);
286 *count += 1;
287 file_index.add_heading(HeadingIndex {
288 text: text.to_string(),
289 auto_anchor: format!("_{count}"),
290 custom_anchor,
291 line,
292 is_setext,
293 });
294 return;
295 }
296 if let Some(count) = fragment_counts.get_mut(fragment) {
297 let suffix = *count;
298 *count += 1;
299 let (primary, alias) = if use_underscore_dedup {
300 (format!("{fragment}_{suffix}"), Some(format!("{fragment}-{suffix}")))
302 } else {
303 (format!("{fragment}-{suffix}"), None)
305 };
306 file_index.add_heading(HeadingIndex {
307 text: text.to_string(),
308 auto_anchor: primary,
309 custom_anchor,
310 line,
311 is_setext,
312 });
313 if let Some(alias_anchor) = alias {
314 let heading_idx = file_index.headings.len() - 1;
315 file_index.add_anchor_alias(&alias_anchor, heading_idx);
316 }
317 } else {
318 fragment_counts.insert(fragment.to_string(), 1);
319 file_index.add_heading(HeadingIndex {
320 text: text.to_string(),
321 auto_anchor: fragment.to_string(),
322 custom_anchor,
323 line,
324 is_setext,
325 });
326 }
327 }
328
329 fn extract_headings_from_context(&self, ctx: &crate::lint_context::LintContext) -> AnchorSets {
336 let track_exact = !self.config.ignore_case;
337 let mut markdown_headings = HashSet::with_capacity(32);
338 let mut markdown_headings_exact = if track_exact {
339 HashSet::with_capacity(32)
340 } else {
341 HashSet::new()
342 };
343 let mut html_anchors = HashSet::with_capacity(16);
344 let mut html_anchors_exact = if track_exact {
345 HashSet::with_capacity(16)
346 } else {
347 HashSet::new()
348 };
349 let mut fragment_counts = std::collections::HashMap::new();
350 let anchor_style = self.anchor_style(ctx);
351 let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
352
353 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
354 if line_info.in_front_matter {
355 continue;
356 }
357
358 if line_info.in_code_block {
360 continue;
361 }
362
363 let content = line_info.content(ctx.content);
364 let bytes = content.as_bytes();
365
366 if bytes.contains(&b'<') && (content.contains("id=") || content.contains("name=")) {
368 let mut pos = 0;
371 while pos < content.len() {
372 if let Some(start) = content[pos..].find('<') {
373 let tag_start = pos + start;
374 if let Some(end) = content[tag_start..].find('>') {
375 let tag_end = tag_start + end + 1;
376 let tag = &content[tag_start..tag_end];
377
378 if let Some(caps) = HTML_ANCHOR_PATTERN.find(tag) {
380 let matched_text = caps.as_str();
381 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(matched_text)
382 && let Some(id_match) = caps.get(1)
383 {
384 let id = id_match.as_str();
385 if !id.is_empty() {
386 html_anchors.insert(id.to_lowercase());
387 if track_exact {
388 html_anchors_exact.insert(id.to_string());
389 }
390 }
391 }
392 }
393 pos = tag_end;
394 } else {
395 break;
396 }
397 } else {
398 break;
399 }
400 }
401 }
402
403 let parsed_heading = ctx.heading_on_line(line_idx + 1);
406 if parsed_heading.is_none() && content.contains('{') && content.contains('#') {
407 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
408 if let Some(id_match) = caps.get(1) {
409 let id = id_match.as_str();
410 markdown_headings.insert(id.to_lowercase());
411 if track_exact {
412 markdown_headings_exact.insert(id.to_string());
413 }
414 }
415 }
416 }
417
418 if let Some(parsed) = parsed_heading {
420 let heading = parsed.heading;
421 if let Some(custom_id) = &heading.custom_id {
423 markdown_headings.insert(custom_id.to_lowercase());
424 if track_exact {
425 markdown_headings_exact.insert(custom_id.clone());
426 }
427 }
428
429 let fragment = anchor_style.generate_fragment(&heading.text);
433
434 Self::insert_deduplicated_fragment(
435 fragment,
436 &mut fragment_counts,
437 &mut markdown_headings,
438 track_exact.then_some(&mut markdown_headings_exact),
439 use_underscore_dedup,
440 );
441 }
442 }
443
444 AnchorSets {
445 markdown_headings,
446 markdown_headings_exact,
447 html_anchors,
448 html_anchors_exact,
449 }
450 }
451
452 #[inline]
454 fn is_external_url_fast(url: &str) -> bool {
455 url.starts_with("http://")
457 || url.starts_with("https://")
458 || url.starts_with("ftp://")
459 || url.starts_with("mailto:")
460 || url.starts_with("tel:")
461 || url.starts_with("//")
462 }
463
464 #[inline]
478 fn is_extensionless_path(path_part: &str) -> bool {
479 if path_part.is_empty() || path_part.contains('.') || path_part.contains('&') || path_part.contains('=') {
481 return false;
482 }
483
484 let mut has_alphanumeric = false;
486 for c in path_part.chars() {
487 if c.is_alphanumeric() {
488 has_alphanumeric = true;
489 } else if !matches!(c, '/' | '\\' | '-' | '_') {
490 return false;
492 }
493 }
494
495 has_alphanumeric
497 }
498
499 #[inline]
501 fn is_cross_file_link(url: &str) -> bool {
502 if let Some(fragment_pos) = url.find('#') {
503 let path_part = &url[..fragment_pos];
504
505 if path_part.is_empty() {
507 return false;
508 }
509
510 if let Some(tag_start) = path_part.find("{%")
516 && path_part[tag_start + 2..].contains("%}")
517 {
518 return true;
519 }
520 if let Some(var_start) = path_part.find("{{")
521 && path_part[var_start + 2..].contains("}}")
522 {
523 return true;
524 }
525
526 if path_part.starts_with('/') {
529 return true;
530 }
531
532 let path_part = path_part.split('?').next().unwrap_or(path_part);
535
536 if path_part.is_empty() {
538 return false;
539 }
540
541 let has_extension = path_part.contains('.')
547 && (
548 {
550 if let Some(after_dot) = path_part.strip_prefix('.') {
552 let dots_count = path_part.matches('.').count();
553 if dots_count == 1 {
554 !after_dot.is_empty() && after_dot.len() <= 10 &&
557 after_dot.chars().all(|c| c.is_ascii_alphanumeric())
558 } else {
559 path_part.split('.').next_back().is_some_and(|ext| {
561 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
562 })
563 }
564 } else {
565 path_part.split('.').next_back().is_some_and(|ext| {
567 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
568 })
569 }
570 } ||
571 path_part.contains('/') || path_part.contains('\\') ||
573 path_part.starts_with("./") || path_part.starts_with("../")
575 );
576
577 let is_extensionless = Self::is_extensionless_path(path_part);
580
581 has_extension || is_extensionless
582 } else {
583 false
584 }
585 }
586
587 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
592 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
593 }
594
595 fn front_matter_links(&self, ctx: &crate::lint_context::LintContext) -> Vec<frontmatter_values::FrontMatterLink> {
598 if !self.checks_front_matter_of(ctx) {
599 return Vec::new();
600 }
601 frontmatter_values::link_destinations(ctx)
602 .into_iter()
603 .filter(|link| !link.field_is_in(&self.ignored_front_matter_fields))
604 .collect()
605 }
606
607 fn reports_link_from(&self, origin: &LinkOrigin) -> bool {
614 match origin {
615 LinkOrigin::Body => true,
616 LinkOrigin::FrontMatter { field } => {
617 self.config.check_frontmatter
618 && !field
619 .as_ref()
620 .is_some_and(|field| self.ignored_front_matter_fields.contains(field))
621 }
622 }
623 }
624
625 fn fragment_is_exempt(&self, ctx: &crate::lint_context::LintContext, fragment: &str) -> bool {
628 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
633 && (fragment.starts_with("fn:")
634 || fragment.starts_with("fnref:")
635 || (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
636 {
637 return true;
638 }
639
640 self.ignored_pattern_regex
642 .as_ref()
643 .is_some_and(|re| re.is_match(fragment))
644 }
645
646 fn fragment_resolves(&self, fragment: &str, anchors: &AnchorSets) -> bool {
650 if self.config.ignore_case {
651 let lower = fragment.to_lowercase();
652 anchors.html_anchors.contains(&lower) || anchors.markdown_headings.contains(&lower)
653 } else {
654 anchors.html_anchors_exact.contains(fragment) || anchors.markdown_headings_exact.contains(fragment)
655 }
656 }
657
658 fn check_front_matter(
665 &self,
666 ctx: &crate::lint_context::LintContext,
667 links: &[frontmatter_values::FrontMatterLink],
668 anchors: &AnchorSets,
669 warnings: &mut Vec<LintWarning>,
670 ) {
671 for link in links {
672 let line = ctx.lines[link.line - 1].content(ctx.content);
673 let Some(fragment) = line[link.range.clone()].strip_prefix('#') else {
674 continue;
675 };
676 if fragment.is_empty() {
677 continue;
678 }
679
680 if ctx.flavor.is_pandoc_compatible() && ctx.has_pandoc_slug(fragment) {
683 continue;
684 }
685
686 if self.fragment_is_exempt(ctx, fragment) || self.fragment_resolves(fragment, anchors) {
687 continue;
688 }
689
690 let column = byte_to_char_count(line, link.range.start);
691 warnings.push(LintWarning {
692 rule_name: Some(self.name().to_string()),
693 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
694 line: link.line,
695 column,
696 end_line: link.line,
697 end_column: column + 1 + fragment.chars().count(),
698 severity: Severity::Error,
699 fix: None,
700 });
701 }
702 }
703}
704
705impl Rule for MD051LinkFragments {
706 fn name(&self) -> &'static str {
707 "MD051"
708 }
709
710 fn description(&self) -> &'static str {
711 "Link fragments should reference valid headings"
712 }
713
714 fn fix_capability(&self) -> FixCapability {
715 FixCapability::Unfixable
716 }
717
718 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
719 if !ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx) {
723 return true;
724 }
725 !ctx.has_char('#')
727 }
728
729 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
730 let mut warnings = Vec::new();
731
732 if ctx.content.is_empty() || self.should_skip(ctx) {
733 return Ok(warnings);
734 }
735
736 let front_matter_links = self.front_matter_links(ctx);
737 if ctx.links().is_empty() && front_matter_links.is_empty() {
738 return Ok(warnings);
739 }
740
741 let anchors = self.extract_headings_from_context(ctx);
742
743 for link in ctx.links() {
744 if link.is_reference {
745 continue;
746 }
747
748 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
750 continue;
751 }
752
753 if matches!(link.link_type, LinkType::WikiLink { .. }) {
755 continue;
756 }
757
758 if ctx.is_in_jinja_range(link.byte_offset) {
760 continue;
761 }
762
763 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
766 continue;
767 }
768
769 if ctx.is_in_shortcode(link.byte_offset) {
772 continue;
773 }
774
775 let url = &link.url;
776
777 if !url.contains('#') || Self::is_external_url_fast(url) {
779 continue;
780 }
781
782 if url.contains("{{#") && url.contains("}}") {
785 continue;
786 }
787
788 if ctx.flavor.is_pandoc_compatible()
794 && let Some(frag) = url.strip_prefix('#')
795 && ctx.has_pandoc_slug(frag)
796 {
797 continue;
798 }
799
800 if url.starts_with('@') {
804 continue;
805 }
806
807 if Self::is_cross_file_link(url) {
809 continue;
810 }
811
812 let Some(fragment_pos) = url.find('#') else {
813 continue;
814 };
815
816 let fragment = &url[fragment_pos + 1..];
817
818 if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
820 continue;
821 }
822
823 if fragment.is_empty() {
824 continue;
825 }
826
827 if self.fragment_is_exempt(ctx, fragment) {
828 continue;
829 }
830
831 if !self.fragment_resolves(fragment, &anchors) {
832 warnings.push(LintWarning {
833 rule_name: Some(self.name().to_string()),
834 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
835 line: link.line,
836 column: link.start_col + 1,
837 end_line: link.end_line,
838 end_column: link.end_col + 1,
839 severity: Severity::Error,
840 fix: None,
841 });
842 }
843 }
844
845 self.check_front_matter(ctx, &front_matter_links, &anchors, &mut warnings);
846
847 Ok(warnings)
848 }
849
850 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
851 Ok(ctx.content.to_string())
854 }
855
856 fn as_any(&self) -> &dyn std::any::Any {
857 self
858 }
859
860 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
861 where
862 Self: Sized,
863 {
864 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD051Config>(config);
865
866 let explicit_style_present = config
871 .rules
872 .get("MD051")
873 .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
874 if !explicit_style_present {
875 rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
876 }
877
878 Box::new(MD051LinkFragments::build(
879 rule_config,
880 config.withheld_rule_values.contains("MD051"),
881 explicit_style_present,
882 ))
883 }
884
885 fn category(&self) -> RuleCategory {
886 RuleCategory::Link
887 }
888
889 fn skippable_by_category(&self) -> bool {
890 !self.config.check_frontmatter
893 }
894
895 fn cross_file_scope(&self) -> CrossFileScope {
896 CrossFileScope::Workspace
897 }
898
899 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
900 let mut fragment_counts = HashMap::new();
901 let anchor_style = self.anchor_style(ctx);
902 let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
903
904 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
906 if line_info.in_front_matter {
907 continue;
908 }
909
910 if line_info.in_code_block {
912 continue;
913 }
914
915 let content = line_info.content(ctx.content);
916
917 if content.contains('<') && (content.contains("id=") || content.contains("name=")) {
919 let mut pos = 0;
920 while pos < content.len() {
921 if let Some(start) = content[pos..].find('<') {
922 let tag_start = pos + start;
923 if let Some(end) = content[tag_start..].find('>') {
924 let tag_end = tag_start + end + 1;
925 let tag = &content[tag_start..tag_end];
926
927 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(tag)
928 && let Some(id_match) = caps.get(1)
929 {
930 file_index.add_html_anchor(id_match.as_str());
931 }
932 pos = tag_end;
933 } else {
934 break;
935 }
936 } else {
937 break;
938 }
939 }
940 }
941
942 let parsed_heading = ctx.heading_on_line(line_idx + 1);
945 if parsed_heading.is_none() && content.contains('{') && content.contains('#') {
946 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
947 if let Some(id_match) = caps.get(1) {
948 file_index.add_attribute_anchor(id_match.as_str());
949 }
950 }
951 }
952
953 if let Some(parsed) = parsed_heading {
955 let heading = parsed.heading;
956 let fragment = anchor_style.generate_fragment(&heading.text);
957
958 Self::add_heading_to_index(
959 &fragment,
960 &heading.text,
961 heading.custom_id.clone(),
962 line_idx + 1,
963 parsed.is_setext(),
964 &mut fragment_counts,
965 file_index,
966 use_underscore_dedup,
967 );
968
969 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
974 && let Some(caps) = MD_SETTING_PATTERN.captures(content)
975 && let Some(name) = caps.get(1)
976 {
977 file_index.add_html_anchor(name.as_str());
978 }
979 }
980 }
981
982 for link in ctx.links() {
984 if link.is_reference {
985 continue;
986 }
987
988 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
990 continue;
991 }
992
993 if matches!(link.link_type, LinkType::WikiLink { .. }) {
996 continue;
997 }
998
999 let url = &link.url;
1000
1001 if Self::is_external_url_fast(url) {
1003 continue;
1004 }
1005
1006 if Self::is_cross_file_link(url)
1008 && let Some(fragment_pos) = url.find('#')
1009 {
1010 let path_part = &url[..fragment_pos];
1011 let fragment = &url[fragment_pos + 1..];
1012
1013 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1015 continue;
1016 }
1017
1018 file_index.add_cross_file_link(CrossFileLinkIndex {
1019 target_path: path_part.to_string(),
1020 fragment: fragment.to_string(),
1021 line: link.line,
1022 column: link.start_col + 1,
1023 origin: LinkOrigin::Body,
1024 });
1025 }
1026 }
1027
1028 for link in frontmatter_values::link_destinations(ctx) {
1035 let line = ctx.lines[link.line - 1].content(ctx.content);
1036 let value = &line[link.range.clone()];
1037
1038 if Self::is_external_url_fast(value) || !Self::is_cross_file_link(value) {
1039 continue;
1040 }
1041
1042 let Some(fragment_pos) = value.find('#') else {
1043 continue;
1044 };
1045 let path_part = &value[..fragment_pos];
1046 let fragment = &value[fragment_pos + 1..];
1047
1048 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1050 continue;
1051 }
1052
1053 file_index.add_cross_file_link(CrossFileLinkIndex {
1054 target_path: path_part.to_string(),
1055 fragment: fragment.to_string(),
1056 line: link.line,
1057 column: byte_to_char_count(line, link.range.start),
1058 origin: LinkOrigin::FrontMatter { field: link.field },
1059 });
1060 }
1061 }
1062
1063 fn cross_file_check(
1064 &self,
1065 file_path: &Path,
1066 file_index: &FileIndex,
1067 workspace_index: &crate::workspace_index::WorkspaceIndex,
1068 ) -> LintResult {
1069 let mut warnings = Vec::new();
1070
1071 let ignored_pattern = self.ignored_pattern_regex.as_ref();
1072 let ignore_case = self.config.ignore_case;
1073
1074 for cross_link in &file_index.cross_file_links {
1076 if cross_link.fragment.is_empty() {
1078 continue;
1079 }
1080
1081 if !self.reports_link_from(&cross_link.origin) {
1084 continue;
1085 }
1086
1087 if ignored_pattern.is_some_and(|re| re.is_match(&cross_link.fragment)) {
1089 continue;
1090 }
1091
1092 let target_paths_to_try =
1095 crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path);
1096
1097 let mut target_file_index = None;
1099
1100 for target_path in &target_paths_to_try {
1101 if let Some(index) = workspace_index.get_file(target_path) {
1102 target_file_index = Some(index);
1103 break;
1104 }
1105 }
1106
1107 if let Some(target_file_index) = target_file_index {
1108 if !target_file_index.has_anchor_with_case(&cross_link.fragment, ignore_case) {
1110 warnings.push(LintWarning {
1111 rule_name: Some(self.name().to_string()),
1112 line: cross_link.line,
1113 column: cross_link.column,
1114 end_line: cross_link.line,
1115 end_column: cross_link.column
1116 + cross_link.target_path.chars().count()
1117 + 1
1118 + cross_link.fragment.chars().count(),
1119 message: format!(
1120 "Link fragment '{}' not found in '{}'",
1121 cross_link.fragment, cross_link.target_path
1122 ),
1123 severity: Severity::Error,
1124 fix: None,
1125 });
1126 }
1127 }
1128 }
1130
1131 Ok(warnings)
1132 }
1133
1134 crate::impl_rule_config_sections!(MD051Config);
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use crate::lint_context::LintContext;
1141 use std::path::PathBuf;
1142
1143 const ANCHOR_STYLE_PROBE: &str = "### Getting Started — Advanced\n\n\
1147 [python-markdown slug](#getting-started-advanced)\n\
1148 [github slug](#getting-started--advanced)\n";
1149
1150 fn flagged_fragment(rule: &dyn Rule, flavor: crate::config::MarkdownFlavor) -> String {
1151 let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
1152 let warnings = rule.check(&ctx).unwrap();
1153 assert_eq!(
1154 warnings.len(),
1155 1,
1156 "exactly one of the two links must be invalid under any style: {warnings:?}"
1157 );
1158 warnings[0].message.clone()
1159 }
1160
1161 #[test]
1165 fn test_unpinned_anchor_style_follows_the_file_flavor() {
1166 let rule_from_global = |flavor| {
1167 let mut config = crate::config::Config::default();
1168 config.global.flavor = flavor;
1169 MD051LinkFragments::from_config(&config)
1170 };
1171
1172 let standard_global = rule_from_global(crate::config::MarkdownFlavor::Standard);
1174 assert!(
1177 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1178 .contains("#getting-started-advanced'"),
1179 "a standard file must be checked against GitHub anchors"
1180 );
1181 assert!(
1184 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1185 .contains("#getting-started--advanced'"),
1186 "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
1187 );
1188
1189 let mkdocs_global = rule_from_global(crate::config::MarkdownFlavor::MkDocs);
1191 assert!(
1192 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1193 .contains("#getting-started--advanced'"),
1194 "a mkdocs file must be checked against Python-Markdown anchors"
1195 );
1196 assert!(
1197 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1198 .contains("#getting-started-advanced'"),
1199 "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
1200 );
1201 }
1202
1203 #[test]
1206 fn test_pinned_anchor_style_ignores_the_file_flavor() {
1207 let mut config = crate::config::Config::default();
1208 config.global.flavor = crate::config::MarkdownFlavor::Standard;
1209 let mut rule_config = crate::config::RuleConfig::default();
1210 rule_config
1211 .values
1212 .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
1213 config.rules.insert("MD051".to_string(), rule_config);
1214 let rule = MD051LinkFragments::from_config(&config);
1215
1216 for flavor in [
1217 crate::config::MarkdownFlavor::Standard,
1218 crate::config::MarkdownFlavor::MkDocs,
1219 crate::config::MarkdownFlavor::Kramdown,
1220 ] {
1221 assert!(
1222 flagged_fragment(rule.as_ref(), flavor).contains("#getting-started-advanced'"),
1223 "pinned github anchors must survive a {flavor:?} file"
1224 );
1225 }
1226 }
1227
1228 #[test]
1231 fn test_directly_constructed_rule_keeps_its_anchor_style() {
1232 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1233 anchor_style: AnchorStyle::PythonMarkdown,
1234 ..Default::default()
1235 });
1236 assert!(
1237 flagged_fragment(&rule, crate::config::MarkdownFlavor::Standard).contains("#getting-started--advanced'"),
1238 "an explicitly constructed Python-Markdown rule must not follow the file flavor"
1239 );
1240 }
1241
1242 #[test]
1243 fn test_quarto_cross_references() {
1244 let rule = MD051LinkFragments::new();
1245
1246 let content = r#"# Test Document
1248
1249## Figures
1250
1251See [@fig-plot] for the visualization.
1252
1253More details in [@tbl-results] and [@sec-methods].
1254
1255The equation [@eq-regression] shows the relationship.
1256
1257Reference to [@lst-code] for implementation."#;
1258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1259 let result = rule.check(&ctx).unwrap();
1260 assert!(
1261 result.is_empty(),
1262 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
1263 result.len()
1264 );
1265
1266 let content_with_anchor = r#"# Test
1268
1269See [link](#test) for details."#;
1270 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
1271 let result_anchor = rule.check(&ctx_anchor).unwrap();
1272 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
1273
1274 let content_invalid = r#"# Test
1276
1277See [link](#nonexistent) for details."#;
1278 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
1279 let result_invalid = rule.check(&ctx_invalid).unwrap();
1280 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
1281 }
1282
1283 #[test]
1284 fn test_jsx_in_heading_anchor() {
1285 let rule = MD051LinkFragments::new();
1287
1288 let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
1290 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1291 let result = rule.check(&ctx).unwrap();
1292 assert!(
1293 result.is_empty(),
1294 "JSX self-closing tag should be stripped from anchor: got {result:?}"
1295 );
1296
1297 let content2 =
1299 "### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
1300 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1301 let result2 = rule.check(&ctx2).unwrap();
1302 assert!(
1303 result2.is_empty(),
1304 "JSX tag with attributes should be stripped from anchor: got {result2:?}"
1305 );
1306
1307 let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
1309 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1310 let result3 = rule.check(&ctx3).unwrap();
1311 assert!(
1312 result3.is_empty(),
1313 "HTML tag content should be preserved in anchor: got {result3:?}"
1314 );
1315 }
1316
1317 #[test]
1319 fn test_cross_file_scope() {
1320 let rule = MD051LinkFragments::new();
1321 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1322 }
1323
1324 #[test]
1325 fn test_contribute_to_index_extracts_headings() {
1326 let rule = MD051LinkFragments::new();
1327 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329
1330 let mut file_index = FileIndex::new();
1331 rule.contribute_to_index(&ctx, &mut file_index);
1332
1333 assert_eq!(file_index.headings.len(), 3);
1334 assert_eq!(file_index.headings[0].text, "First Heading");
1335 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
1336 assert!(file_index.headings[0].custom_anchor.is_none());
1337
1338 assert_eq!(file_index.headings[1].text, "Second");
1339 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
1340
1341 assert_eq!(file_index.headings[2].text, "Third");
1342 }
1343
1344 #[test]
1345 fn test_contribute_to_index_extracts_cross_file_links() {
1346 let rule = MD051LinkFragments::new();
1347 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
1348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349
1350 let mut file_index = FileIndex::new();
1351 rule.contribute_to_index(&ctx, &mut file_index);
1352
1353 assert_eq!(file_index.cross_file_links.len(), 2);
1354 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1355 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
1356 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
1357 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
1358 }
1359
1360 #[test]
1364 fn test_contribute_to_index_records_setext_headings() {
1365 let rule = MD051LinkFragments::new();
1366 let content = "Setext One\n==========\n\nSetext Two\n----------\n\n### Atx Three\n";
1367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1368
1369 let mut file_index = FileIndex::new();
1370 rule.contribute_to_index(&ctx, &mut file_index);
1371
1372 let styles: Vec<(&str, bool)> = file_index
1373 .headings
1374 .iter()
1375 .map(|h| (h.text.as_str(), h.is_setext))
1376 .collect();
1377 assert_eq!(
1378 styles,
1379 vec![("Setext One", true), ("Setext Two", true), ("Atx Three", false)]
1380 );
1381 }
1382
1383 #[test]
1391 fn test_a_frontmatter_link_is_indexed_regardless_of_the_indexing_config() {
1392 let content = "---\nlink: 'other.md#nope'\n---\n\n# Real\n";
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394
1395 for check_frontmatter in [true, false] {
1396 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1397 check_frontmatter,
1398 ..Default::default()
1399 });
1400 let mut file_index = FileIndex::new();
1401 rule.contribute_to_index(&ctx, &mut file_index);
1402
1403 assert_eq!(
1404 file_index.cross_file_links.len(),
1405 1,
1406 "check_frontmatter = {check_frontmatter} changed what was indexed"
1407 );
1408 assert_eq!(
1409 file_index.cross_file_links[0].origin,
1410 LinkOrigin::FrontMatter {
1411 field: Some("link".to_string())
1412 },
1413 );
1414 }
1415 }
1416
1417 #[test]
1421 fn test_cross_file_check_applies_this_files_frontmatter_config() {
1422 use crate::workspace_index::WorkspaceIndex;
1423
1424 let mut workspace_index = WorkspaceIndex::new();
1425 let mut target = FileIndex::new();
1426 target.add_heading(HeadingIndex {
1427 text: "Real".to_string(),
1428 auto_anchor: "real".to_string(),
1429 custom_anchor: None,
1430 line: 1,
1431 is_setext: false,
1432 });
1433 workspace_index.insert_file(PathBuf::from("docs/other.md"), target);
1434
1435 let mut file_index = FileIndex::new();
1436 file_index.add_cross_file_link(CrossFileLinkIndex {
1437 target_path: "other.md".to_string(),
1438 fragment: "nope".to_string(),
1439 line: 2,
1440 column: 7,
1441 origin: LinkOrigin::FrontMatter {
1442 field: Some("link".to_string()),
1443 },
1444 });
1445 file_index.add_cross_file_link(CrossFileLinkIndex {
1449 target_path: "other.md".to_string(),
1450 fragment: "nope".to_string(),
1451 line: 6,
1452 column: 5,
1453 origin: LinkOrigin::Body,
1454 });
1455
1456 let count = |config: MD051Config| {
1457 MD051LinkFragments::from_config_struct(config)
1458 .cross_file_check(Path::new("docs/readme.md"), &file_index, &workspace_index)
1459 .unwrap()
1460 .len()
1461 };
1462
1463 assert_eq!(
1464 count(MD051Config {
1465 check_frontmatter: true,
1466 ..Default::default()
1467 }),
1468 2,
1469 "checking frontmatter should report both the frontmatter and body links"
1470 );
1471 assert_eq!(
1472 count(MD051Config {
1473 check_frontmatter: false,
1474 ..Default::default()
1475 }),
1476 1,
1477 "not checking frontmatter should leave only the body link"
1478 );
1479 assert_eq!(
1480 count(MD051Config {
1481 check_frontmatter: true,
1482 ignore_frontmatter_fields: vec!["LINK".to_string()],
1483 ..Default::default()
1484 }),
1485 1,
1486 "an ignored field should be matched case-insensitively"
1487 );
1488 }
1489
1490 #[test]
1491 fn test_cross_file_check_valid_fragment() {
1492 use crate::workspace_index::WorkspaceIndex;
1493
1494 let rule = MD051LinkFragments::new();
1495
1496 let mut workspace_index = WorkspaceIndex::new();
1498 let mut target_file_index = FileIndex::new();
1499 target_file_index.add_heading(HeadingIndex {
1500 text: "Installation Guide".to_string(),
1501 auto_anchor: "installation-guide".to_string(),
1502 custom_anchor: None,
1503 line: 1,
1504 is_setext: false,
1505 });
1506 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1507
1508 let mut current_file_index = FileIndex::new();
1510 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1511 target_path: "install.md".to_string(),
1512 fragment: "installation-guide".to_string(),
1513 line: 3,
1514 column: 5,
1515 origin: LinkOrigin::Body,
1516 });
1517
1518 let warnings = rule
1519 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1520 .unwrap();
1521
1522 assert!(warnings.is_empty());
1524 }
1525
1526 #[test]
1527 fn test_cross_file_check_invalid_fragment() {
1528 use crate::workspace_index::WorkspaceIndex;
1529
1530 let rule = MD051LinkFragments::new();
1531
1532 let mut workspace_index = WorkspaceIndex::new();
1534 let mut target_file_index = FileIndex::new();
1535 target_file_index.add_heading(HeadingIndex {
1536 text: "Installation Guide".to_string(),
1537 auto_anchor: "installation-guide".to_string(),
1538 custom_anchor: None,
1539 line: 1,
1540 is_setext: false,
1541 });
1542 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1543
1544 let mut current_file_index = FileIndex::new();
1546 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1547 target_path: "install.md".to_string(),
1548 fragment: "nonexistent".to_string(),
1549 line: 3,
1550 column: 5,
1551 origin: LinkOrigin::Body,
1552 });
1553
1554 let warnings = rule
1555 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1556 .unwrap();
1557
1558 assert_eq!(warnings.len(), 1);
1560 assert!(warnings[0].message.contains("nonexistent"));
1561 assert!(warnings[0].message.contains("install.md"));
1562 }
1563
1564 #[test]
1565 fn test_cross_file_check_custom_anchor_match() {
1566 use crate::workspace_index::WorkspaceIndex;
1567
1568 let rule = MD051LinkFragments::new();
1569
1570 let mut workspace_index = WorkspaceIndex::new();
1572 let mut target_file_index = FileIndex::new();
1573 target_file_index.add_heading(HeadingIndex {
1574 text: "Installation Guide".to_string(),
1575 auto_anchor: "installation-guide".to_string(),
1576 custom_anchor: Some("install".to_string()),
1577 line: 1,
1578 is_setext: false,
1579 });
1580 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1581
1582 let mut current_file_index = FileIndex::new();
1584 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1585 target_path: "install.md".to_string(),
1586 fragment: "install".to_string(),
1587 line: 3,
1588 column: 5,
1589 origin: LinkOrigin::Body,
1590 });
1591
1592 let warnings = rule
1593 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1594 .unwrap();
1595
1596 assert!(warnings.is_empty());
1598 }
1599
1600 #[test]
1601 fn test_cross_file_check_target_not_in_workspace() {
1602 use crate::workspace_index::WorkspaceIndex;
1603
1604 let rule = MD051LinkFragments::new();
1605
1606 let workspace_index = WorkspaceIndex::new();
1608
1609 let mut current_file_index = FileIndex::new();
1611 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1612 target_path: "external.md".to_string(),
1613 fragment: "heading".to_string(),
1614 line: 3,
1615 column: 5,
1616 origin: LinkOrigin::Body,
1617 });
1618
1619 let warnings = rule
1620 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1621 .unwrap();
1622
1623 assert!(warnings.is_empty());
1625 }
1626
1627 #[test]
1628 fn test_wikilinks_skipped_in_check() {
1629 let rule = MD051LinkFragments::new();
1631
1632 let content = r#"# Test Document
1633
1634## Valid Heading
1635
1636[[Microsoft#Windows OS]]
1637[[SomePage#section]]
1638[[page|Display Text]]
1639[[path/to/page#section]]
1640"#;
1641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1642 let result = rule.check(&ctx).unwrap();
1643
1644 assert!(
1645 result.is_empty(),
1646 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1647 );
1648 }
1649
1650 #[test]
1651 fn test_wikilinks_not_added_to_cross_file_index() {
1652 let rule = MD051LinkFragments::new();
1654
1655 let content = r#"# Test Document
1656
1657[[Microsoft#Windows OS]]
1658[[SomePage#section]]
1659[Regular Link](other.md#section)
1660"#;
1661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1662
1663 let mut file_index = FileIndex::new();
1664 rule.contribute_to_index(&ctx, &mut file_index);
1665
1666 let cross_file_links = &file_index.cross_file_links;
1669 assert_eq!(
1670 cross_file_links.len(),
1671 1,
1672 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1673 );
1674 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1675 assert_eq!(file_index.cross_file_links[0].fragment, "section");
1676 }
1677
1678 #[test]
1679 fn test_pandoc_flavor_skips_citations() {
1680 let rule = MD051LinkFragments::new();
1684 let content = "# Test Document\n\nSee [@smith2020] for details.\n";
1685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1686 let result = rule.check(&ctx).unwrap();
1687 assert!(
1688 result.is_empty(),
1689 "MD051 should skip Pandoc citations under Pandoc flavor: {result:?}"
1690 );
1691 }
1692
1693 #[test]
1694 fn md051_pandoc_resolves_pandoc_slug_diverging_from_github() {
1695 use crate::config::MarkdownFlavor;
1702 let rule = MD051LinkFragments::new();
1703 let content = "# 5. Five Things\n\nSee [details](#5.-five-things).\n";
1704
1705 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1708 let std_result = rule.check(&ctx_std).unwrap();
1709 assert_eq!(
1710 std_result.len(),
1711 1,
1712 "Standard flavor should flag the Pandoc-style fragment: {std_result:?}"
1713 );
1714
1715 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1717 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1718 assert!(
1719 pandoc_result.is_empty(),
1720 "Pandoc flavor should resolve `#5.-five-things` against the heading slug: {pandoc_result:?}"
1721 );
1722 }
1723
1724 #[test]
1728 fn md051_pandoc_flags_missing_fragment_with_email_in_link_text() {
1729 use crate::config::MarkdownFlavor;
1730 let rule = MD051LinkFragments::new();
1731 let content = "# Title\n\n[contact user@example.com](#missing)\n";
1732
1733 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1734 let std_result = rule.check(&ctx_std).unwrap();
1735 assert_eq!(
1736 std_result.len(),
1737 1,
1738 "Standard flavor must flag the missing fragment: {std_result:?}"
1739 );
1740
1741 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1742 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1743 assert_eq!(
1744 pandoc_result.len(),
1745 1,
1746 "Pandoc flavor must also flag the missing fragment — link text with embedded email is not a citation: {pandoc_result:?}"
1747 );
1748 }
1749
1750 #[test]
1754 fn md051_pandoc_flags_missing_fragment_with_citation_in_link_text() {
1755 use crate::config::MarkdownFlavor;
1756 let rule = MD051LinkFragments::new();
1757 let content = "# Title\n\n[see @smith2020](#missing)\n";
1758
1759 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1760 let std_result = rule.check(&ctx_std).unwrap();
1761 assert_eq!(
1762 std_result.len(),
1763 1,
1764 "Standard flavor must flag the missing fragment: {std_result:?}"
1765 );
1766
1767 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1768 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1769 assert_eq!(
1770 pandoc_result.len(),
1771 1,
1772 "Pandoc flavor must flag the missing fragment — `[label](url)` is a link, not a citation: {pandoc_result:?}"
1773 );
1774 }
1775
1776 #[test]
1780 fn md051_pandoc_resolves_duplicate_heading_suffix_slug() {
1781 use crate::config::MarkdownFlavor;
1782 let rule = MD051LinkFragments::new();
1783 let content = "# A.\n\nfirst\n\n# A.\n\nsecond\n\n[first](#a.) and [second](#a.-1).\n";
1784
1785 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1786 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1787 assert!(
1788 pandoc_result.is_empty(),
1789 "Pandoc flavor should resolve `#a.` and `#a.-1` against duplicate headings: {pandoc_result:?}"
1790 );
1791
1792 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1793 let quarto_result = rule.check(&ctx_quarto).unwrap();
1794 assert!(
1795 quarto_result.is_empty(),
1796 "Quarto flavor should also resolve duplicate-heading suffix slugs: {quarto_result:?}"
1797 );
1798 }
1799
1800 #[test]
1803 fn md051_pandoc_flags_overshoot_duplicate_suffix() {
1804 use crate::config::MarkdownFlavor;
1805 let rule = MD051LinkFragments::new();
1806 let content = "# A.\n\n# A.\n\n[overshoot](#a.-2)\n";
1807
1808 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1809 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1810 assert_eq!(
1811 pandoc_result.len(),
1812 1,
1813 "Pandoc must flag `#a.-2` when only `-1` exists (two duplicates): {pandoc_result:?}"
1814 );
1815 }
1816
1817 fn front_matter_checked() -> MD051Config {
1818 MD051Config {
1819 check_frontmatter: true,
1820 ..MD051Config::default()
1821 }
1822 }
1823
1824 fn check_front_matter(content: &str, config: MD051Config) -> Vec<LintWarning> {
1825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1826 MD051LinkFragments::from_config_struct(config).check(&ctx).unwrap()
1827 }
1828
1829 #[test]
1830 fn a_broken_frontmatter_fragment_is_reported_when_enabled() {
1831 let content = "---\nanchor: '#missing'\nvalid: '#title'\n---\n\n# Title\n";
1832 let result = check_front_matter(content, front_matter_checked());
1833
1834 assert_eq!(
1835 result.len(),
1836 1,
1837 "Only the unresolved fragment is reported. Got: {result:?}"
1838 );
1839 assert_eq!(
1840 result[0].message,
1841 "Link anchor '#missing' does not exist in document headings"
1842 );
1843 assert_eq!(result[0].line, 2);
1844 assert_eq!(result[0].column, 10, "The warning points at the value, not the key");
1845 assert_eq!(result[0].end_column, 18);
1846 }
1847
1848 #[test]
1849 fn frontmatter_fragments_are_not_checked_by_default() {
1850 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
1851 let result = check_front_matter(content, MD051Config::default());
1852
1853 assert!(
1854 result.is_empty(),
1855 "Frontmatter is only checked on request. Got: {result:?}"
1856 );
1857 }
1858
1859 #[test]
1860 fn an_ignored_frontmatter_field_is_not_checked() {
1861 let content = "---\nhero: '#missing'\nanchor: '#other'\n---\n\n# Title\n";
1862 let config = MD051Config {
1863 check_frontmatter: true,
1864 ignore_frontmatter_fields: vec!["Hero".to_string()],
1865 ..MD051Config::default()
1866 };
1867 let result = check_front_matter(content, config);
1868
1869 assert_eq!(
1870 result.len(),
1871 1,
1872 "The ignored field is skipped and the other is not. Got: {result:?}"
1873 );
1874 assert_eq!(result[0].line, 3);
1875 }
1876
1877 #[test]
1878 fn the_ignored_pattern_applies_to_frontmatter_fragments() {
1879 let content = "---\nnote: '#fn:1'\nanchor: '#missing'\n---\n\n# Title\n";
1880 let config = MD051Config {
1881 check_frontmatter: true,
1882 ignored_pattern: Some("^fn:".to_string()),
1883 ..MD051Config::default()
1884 };
1885 let result = check_front_matter(content, config);
1886
1887 assert_eq!(
1888 result.len(),
1889 1,
1890 "The matching fragment is skipped and the other is not. Got: {result:?}"
1891 );
1892 assert_eq!(result[0].line, 3);
1893 }
1894
1895 #[test]
1896 fn a_frontmatter_fragment_honors_ignore_case() {
1897 let content = "---\nanchor: '#Title'\n---\n\n# Title\n";
1898
1899 let permissive = check_front_matter(content, front_matter_checked());
1900 assert!(
1901 permissive.is_empty(),
1902 "The default resolves a case mismatch. Got: {permissive:?}"
1903 );
1904
1905 let strict = check_front_matter(
1906 content,
1907 MD051Config {
1908 check_frontmatter: true,
1909 ignore_case: false,
1910 ..MD051Config::default()
1911 },
1912 );
1913 assert_eq!(strict.len(), 1, "Strict matching reports it. Got: {strict:?}");
1914 }
1915
1916 #[test]
1917 fn prose_in_frontmatter_is_not_read_as_a_fragment() {
1918 let content = "---\ntitle: Node.js\ntags: ci/cd\n---\n\n# Title\n";
1919 let result = check_front_matter(content, front_matter_checked());
1920
1921 assert!(
1922 result.is_empty(),
1923 "Only path-shaped values are destinations. Got: {result:?}"
1924 );
1925 }
1926
1927 #[test]
1928 fn a_frontmatter_path_with_a_fragment_is_validated_across_files() {
1929 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1930 let source = "---\ntemplate: other.md#missing\nvalid: other.md#target\n---\n\n# Source\n";
1931
1932 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1933 let mut source_index = FileIndex::default();
1934 rule.contribute_to_index(&source_ctx, &mut source_index);
1935
1936 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1937 let mut target_index = FileIndex::default();
1938 rule.contribute_to_index(&target_ctx, &mut target_index);
1939
1940 let source_path = PathBuf::from("docs/source.md");
1941 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1942 workspace.insert_file(source_path.clone(), source_index.clone());
1943 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1944
1945 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1946
1947 assert_eq!(
1948 warnings.len(),
1949 1,
1950 "Only the unresolved fragment is reported. Got: {warnings:?}"
1951 );
1952 assert_eq!(warnings[0].message, "Link fragment 'missing' not found in 'other.md'");
1953 assert_eq!(warnings[0].line, 2);
1954 assert_eq!(warnings[0].column, 11);
1955 }
1956
1957 #[test]
1958 fn a_query_string_does_not_hide_the_target_file() {
1959 let rule = MD051LinkFragments::new();
1960 let source = "# Source\n\n- [a](other.md?raw=true#missing)\n- [b](other.md?raw=true#target)\n";
1961
1962 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1963 let mut source_index = FileIndex::default();
1964 rule.contribute_to_index(&source_ctx, &mut source_index);
1965
1966 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1967 let mut target_index = FileIndex::default();
1968 rule.contribute_to_index(&target_ctx, &mut target_index);
1969
1970 let source_path = PathBuf::from("docs/source.md");
1971 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1972 workspace.insert_file(source_path.clone(), source_index.clone());
1973 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1974
1975 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1976
1977 assert_eq!(
1978 warnings.len(),
1979 1,
1980 "The query is stripped to find the file, so both fragments resolve against it. Got: {warnings:?}"
1981 );
1982 assert_eq!(
1983 warnings[0].message,
1984 "Link fragment 'missing' not found in 'other.md?raw=true'"
1985 );
1986 assert_eq!(warnings[0].line, 3);
1987 }
1988
1989 #[test]
1990 fn a_query_string_does_not_hide_an_extensionless_target_file() {
1991 let rule = MD051LinkFragments::new();
1992 let source = "# Source\n\n- [a](other?raw=true#target)\n- [b](other#target)\n- [c](other?raw=true#absent)\n";
1993
1994 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1995 let same_document = rule.check(&source_ctx).unwrap();
1996 assert!(
1997 same_document.is_empty(),
1998 "Every fragment here belongs to another file, so none is a missing anchor of this one. Got: {same_document:?}"
1999 );
2000
2001 let mut source_index = FileIndex::default();
2002 rule.contribute_to_index(&source_ctx, &mut source_index);
2003
2004 let target_ctx = LintContext::new("# Other\n\n## Target\n", crate::config::MarkdownFlavor::Standard, None);
2005 let mut target_index = FileIndex::default();
2006 rule.contribute_to_index(&target_ctx, &mut target_index);
2007
2008 let source_path = PathBuf::from("docs/source.md");
2009 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
2010 workspace.insert_file(source_path.clone(), source_index.clone());
2011 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
2012
2013 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
2014
2015 assert_eq!(
2016 warnings.len(),
2017 1,
2018 "The query is stripped before the markdown extension is added. Got: {warnings:?}"
2019 );
2020 assert_eq!(
2021 warnings[0].message,
2022 "Link fragment 'absent' not found in 'other?raw=true'"
2023 );
2024 assert_eq!(warnings[0].line, 5);
2025 }
2026
2027 #[test]
2028 fn a_destination_that_is_only_a_query_stays_on_this_page() {
2029 let rule = MD051LinkFragments::new();
2030 let source = "# Source\n\n## Here\n\n- [a](?raw=true#here)\n- [b](?raw=true#nowhere)\n";
2031
2032 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2033 let warnings = rule.check(&source_ctx).unwrap();
2034
2035 assert_eq!(
2036 warnings.len(),
2037 1,
2038 "Only the absent anchor is reported. Got: {warnings:?}"
2039 );
2040 assert_eq!(
2041 warnings[0].message,
2042 "Link anchor '#nowhere' does not exist in document headings"
2043 );
2044 assert_eq!(warnings[0].line, 6);
2045
2046 let mut source_index = FileIndex::default();
2047 rule.contribute_to_index(&source_ctx, &mut source_index);
2048 assert!(
2049 source_index.cross_file_links.is_empty(),
2050 "A query with no path names no other file. Got: {:?}",
2051 source_index.cross_file_links
2052 );
2053 }
2054
2055 #[test]
2056 fn blockquote_syntax_inside_raw_html_does_not_create_an_anchor() {
2057 let rule = MD051LinkFragments::new();
2058 let source = "<div>\n> ## Hidden\n</div>\n\n[link](#hidden)\n";
2059 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2060
2061 let warnings = rule.check(&ctx).unwrap();
2062 assert_eq!(
2063 warnings.len(),
2064 1,
2065 "raw HTML must not satisfy the fragment: {warnings:?}"
2066 );
2067
2068 let mut file_index = FileIndex::default();
2069 rule.contribute_to_index(&ctx, &mut file_index);
2070 assert!(
2071 file_index.headings.is_empty(),
2072 "raw HTML must not enter the workspace index"
2073 );
2074 }
2075
2076 #[test]
2077 fn a_frontmatter_path_carrying_a_query_is_indexed() {
2078 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
2079 let source = "---\ntemplate: docs/other.md?raw=true#missing\n---\n\n# Source\n";
2080
2081 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2082 let mut source_index = FileIndex::default();
2083 rule.contribute_to_index(&source_ctx, &mut source_index);
2084
2085 assert_eq!(source_index.cross_file_links.len(), 1);
2086 assert_eq!(source_index.cross_file_links[0].target_path, "docs/other.md?raw=true");
2087 assert_eq!(source_index.cross_file_links[0].fragment, "missing");
2088 }
2089
2090 #[test]
2094 fn frontmatter_cross_file_paths_are_not_reported_by_default() {
2095 use crate::workspace_index::WorkspaceIndex;
2096
2097 let rule = MD051LinkFragments::new();
2098 let source = "---\ntemplate: other.md#missing\n---\n\n# Source\n";
2099
2100 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2101 let mut source_index = FileIndex::default();
2102 rule.contribute_to_index(&source_ctx, &mut source_index);
2103 assert_eq!(source_index.cross_file_links.len(), 1);
2104
2105 let mut workspace_index = WorkspaceIndex::new();
2106 let mut target = FileIndex::new();
2107 target.add_heading(HeadingIndex {
2108 text: "Present".to_string(),
2109 auto_anchor: "present".to_string(),
2110 custom_anchor: None,
2111 line: 1,
2112 is_setext: false,
2113 });
2114 workspace_index.insert_file(PathBuf::from("other.md"), target);
2115
2116 let warnings = rule
2117 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2118 .unwrap();
2119 assert!(
2120 warnings.is_empty(),
2121 "Frontmatter is only checked on request. Got: {warnings:?}"
2122 );
2123
2124 let checking = MD051LinkFragments::from_config_struct(MD051Config {
2128 check_frontmatter: true,
2129 ..Default::default()
2130 });
2131 assert_eq!(
2132 checking
2133 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2134 .unwrap()
2135 .len(),
2136 1
2137 );
2138 }
2139}