1use crate::lint_context::{LineInfo, LintContext};
2use crate::rule::{CrossFileScope, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rule_config_serde::RuleConfig;
4use crate::utils::anchor_styles::AnchorStyle;
5use crate::utils::frontmatter_values;
6use crate::utils::header_id_utils::{HTML_BLOCK_OPEN_TAG, HTML_OPEN_TAG, html_tag_attribute, is_backslash_escaped};
7use crate::utils::range_utils::byte_to_char_count;
8use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex, LinkOrigin};
9use pulldown_cmark::LinkType;
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::path::Path;
14use std::sync::LazyLock;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18#[serde(rename_all = "kebab-case")]
19pub struct MD051Config {
20 #[serde(default, alias = "anchor_style")]
22 pub anchor_style: AnchorStyle,
23
24 #[serde(default = "default_ignore_case", alias = "ignore_case")]
30 pub ignore_case: bool,
31
32 #[serde(default, alias = "ignored_pattern")]
36 pub ignored_pattern: Option<String>,
37
38 #[serde(default)]
57 pub check_frontmatter: bool,
58
59 #[serde(default)]
63 pub ignore_frontmatter_fields: Vec<String>,
64}
65
66fn default_ignore_case() -> bool {
67 true
68}
69
70impl Default for MD051Config {
71 fn default() -> Self {
72 Self {
73 anchor_style: AnchorStyle::default(),
74 ignore_case: true,
75 ignored_pattern: None,
76 check_frontmatter: false,
77 ignore_frontmatter_fields: Vec::new(),
78 }
79 }
80}
81
82impl RuleConfig for MD051Config {
83 const RULE_NAME: &'static str = "MD051";
84}
85
86fn for_each_html_anchor_target(ctx: &LintContext, line_info: &LineInfo, mut record: impl FnMut(&str)) {
97 let content = line_info.content(ctx.content);
98 if !content.contains('<') {
99 return;
100 }
101
102 let escapes_apply = !line_info.in_html_block;
103 let open_tags: &Regex = if line_info.in_html_block {
104 &HTML_BLOCK_OPEN_TAG
105 } else {
106 &HTML_OPEN_TAG
107 };
108 let mut pos = 0;
109 while let Some(tag) = open_tags.captures_at(content, pos) {
110 let whole = tag.get(0).unwrap();
111 let byte_pos = line_info.byte_offset + whole.start();
112 if ctx.is_in_code_span_byte(byte_pos)
113 || ctx.is_in_html_comment(byte_pos)
114 || ctx.image_containing(byte_pos).is_some()
115 || (escapes_apply && is_backslash_escaped(content, whole.start()))
116 {
117 pos = whole.start() + 1;
119 continue;
120 }
121 pos = whole.end();
122
123 if let Some(id) = html_tag_attribute(whole.as_str(), "id") {
124 record(id);
125 }
126 if tag[1].eq_ignore_ascii_case("a")
127 && let Some(name) = html_tag_attribute(whole.as_str(), "name")
128 {
129 record(name);
130 }
131 }
132}
133
134static ATTR_ANCHOR_PATTERN: LazyLock<Regex> =
138 LazyLock::new(|| Regex::new(r#"\{\s*#([a-zA-Z0-9_][a-zA-Z0-9_-]*)[^}]*\}"#).unwrap());
139
140static MD_SETTING_PATTERN: LazyLock<Regex> =
143 LazyLock::new(|| Regex::new(r"<!--\s*md:setting\s+([^\s]+)\s*-->").unwrap());
144
145#[derive(Clone)]
152pub struct MD051LinkFragments {
153 config: MD051Config,
154 ignored_pattern_regex: Option<Regex>,
158 ignored_front_matter_fields: HashSet<String>,
160 anchor_style_pinned: bool,
164}
165
166struct AnchorSets {
171 markdown_headings: HashSet<String>,
172 markdown_headings_exact: HashSet<String>,
173 html_anchors: HashSet<String>,
174 html_anchors_exact: HashSet<String>,
175}
176
177impl Default for MD051LinkFragments {
178 fn default() -> Self {
179 Self::new()
180 }
181}
182
183impl MD051LinkFragments {
184 pub fn new() -> Self {
185 Self::from_config_struct(MD051Config::default())
186 }
187
188 pub fn with_anchor_style(style: AnchorStyle) -> Self {
190 Self::from_config_struct(MD051Config {
191 anchor_style: style,
192 ..MD051Config::default()
193 })
194 }
195
196 pub fn from_config_struct(config: MD051Config) -> Self {
202 Self::from_config_struct_from(config, false)
203 }
204
205 fn from_config_struct_from(config: MD051Config, values_withheld: bool) -> Self {
208 Self::build(config, values_withheld, true)
209 }
210
211 fn build(config: MD051Config, values_withheld: bool, anchor_style_pinned: bool) -> Self {
215 let ignored_pattern_regex = config.ignored_pattern.as_deref().and_then(|pattern| {
216 crate::rule_config_serde::compile_config_regex(pattern, "MD051", "ignored-pattern", values_withheld)
217 });
218 let ignored_front_matter_fields = config
219 .ignore_frontmatter_fields
220 .iter()
221 .map(|field| field.to_lowercase())
222 .collect();
223 Self {
224 config,
225 ignored_pattern_regex,
226 ignored_front_matter_fields,
227 anchor_style_pinned,
228 }
229 }
230
231 fn anchor_style(&self, ctx: &crate::lint_context::LintContext) -> AnchorStyle {
238 if self.anchor_style_pinned {
239 self.config.anchor_style
240 } else {
241 AnchorStyle::for_flavor(ctx.flavor)
242 }
243 }
244
245 fn insert_deduplicated_fragment(
253 fragment: String,
254 fragment_counts: &mut HashMap<String, usize>,
255 markdown_headings: &mut HashSet<String>,
256 mut markdown_headings_exact: Option<&mut HashSet<String>>,
257 use_underscore_dedup: bool,
258 ) {
259 let mut also_insert_exact = |form: &str| {
265 if let Some(set) = markdown_headings_exact.as_deref_mut() {
266 set.insert(form.to_string());
267 }
268 };
269
270 if fragment.is_empty() {
271 if !use_underscore_dedup {
272 return;
273 }
274 let count = fragment_counts.entry(fragment).or_insert(0);
276 *count += 1;
277 let formed = format!("_{count}");
278 also_insert_exact(&formed);
279 markdown_headings.insert(formed);
280 return;
281 }
282 if let Some(count) = fragment_counts.get_mut(&fragment) {
283 let suffix = *count;
284 *count += 1;
285 if use_underscore_dedup {
286 let underscore_form = format!("{fragment}_{suffix}");
288 also_insert_exact(&underscore_form);
289 markdown_headings.insert(underscore_form);
290 let dash_form = format!("{fragment}-{suffix}");
292 also_insert_exact(&dash_form);
293 markdown_headings.insert(dash_form);
294 } else {
295 let form = format!("{fragment}-{suffix}");
297 also_insert_exact(&form);
298 markdown_headings.insert(form);
299 }
300 } else {
301 fragment_counts.insert(fragment.clone(), 1);
302 also_insert_exact(&fragment);
303 markdown_headings.insert(fragment);
304 }
305 }
306
307 #[allow(clippy::too_many_arguments)]
316 fn add_heading_to_index(
317 fragment: &str,
318 text: &str,
319 custom_anchor: Option<String>,
320 line: usize,
321 is_setext: bool,
322 fragment_counts: &mut HashMap<String, usize>,
323 file_index: &mut FileIndex,
324 use_underscore_dedup: bool,
325 ) {
326 if fragment.is_empty() {
327 if !use_underscore_dedup {
328 return;
329 }
330 let count = fragment_counts.entry(fragment.to_string()).or_insert(0);
332 *count += 1;
333 file_index.add_heading(HeadingIndex {
334 text: text.to_string(),
335 auto_anchor: format!("_{count}"),
336 custom_anchor,
337 line,
338 is_setext,
339 });
340 return;
341 }
342 if let Some(count) = fragment_counts.get_mut(fragment) {
343 let suffix = *count;
344 *count += 1;
345 let (primary, alias) = if use_underscore_dedup {
346 (format!("{fragment}_{suffix}"), Some(format!("{fragment}-{suffix}")))
348 } else {
349 (format!("{fragment}-{suffix}"), None)
351 };
352 file_index.add_heading(HeadingIndex {
353 text: text.to_string(),
354 auto_anchor: primary,
355 custom_anchor,
356 line,
357 is_setext,
358 });
359 if let Some(alias_anchor) = alias {
360 let heading_idx = file_index.headings.len() - 1;
361 file_index.add_anchor_alias(&alias_anchor, heading_idx);
362 }
363 } else {
364 fragment_counts.insert(fragment.to_string(), 1);
365 file_index.add_heading(HeadingIndex {
366 text: text.to_string(),
367 auto_anchor: fragment.to_string(),
368 custom_anchor,
369 line,
370 is_setext,
371 });
372 }
373 }
374
375 fn extract_headings_from_context(&self, ctx: &crate::lint_context::LintContext) -> AnchorSets {
382 let track_exact = !self.config.ignore_case;
383 let mut markdown_headings = HashSet::with_capacity(32);
384 let mut markdown_headings_exact = if track_exact {
385 HashSet::with_capacity(32)
386 } else {
387 HashSet::new()
388 };
389 let mut html_anchors = HashSet::with_capacity(16);
390 let mut html_anchors_exact = if track_exact {
391 HashSet::with_capacity(16)
392 } else {
393 HashSet::new()
394 };
395 let mut fragment_counts = std::collections::HashMap::new();
396 let anchor_style = self.anchor_style(ctx);
397 let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
398
399 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
400 if line_info.in_front_matter {
401 continue;
402 }
403
404 if line_info.in_code_block {
406 continue;
407 }
408
409 let content = line_info.content(ctx.content);
410
411 for_each_html_anchor_target(ctx, line_info, |id| {
412 html_anchors.insert(id.to_lowercase());
413 if track_exact {
414 html_anchors_exact.insert(id.to_string());
415 }
416 });
417
418 let parsed_heading = ctx.heading_on_line(line_idx + 1);
421 if parsed_heading.is_none() && content.contains('{') && content.contains('#') {
422 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
423 if let Some(id_match) = caps.get(1) {
424 let id = id_match.as_str();
425 markdown_headings.insert(id.to_lowercase());
426 if track_exact {
427 markdown_headings_exact.insert(id.to_string());
428 }
429 }
430 }
431 }
432
433 if let Some(parsed) = parsed_heading {
435 let heading = parsed.heading;
436 if let Some(custom_id) = &heading.custom_id {
438 markdown_headings.insert(custom_id.to_lowercase());
439 if track_exact {
440 markdown_headings_exact.insert(custom_id.clone());
441 }
442 }
443
444 let fragment = anchor_style.generate_fragment(&heading.text);
448
449 Self::insert_deduplicated_fragment(
450 fragment,
451 &mut fragment_counts,
452 &mut markdown_headings,
453 track_exact.then_some(&mut markdown_headings_exact),
454 use_underscore_dedup,
455 );
456 }
457 }
458
459 AnchorSets {
460 markdown_headings,
461 markdown_headings_exact,
462 html_anchors,
463 html_anchors_exact,
464 }
465 }
466
467 #[inline]
469 fn is_external_url_fast(url: &str) -> bool {
470 url.starts_with("http://")
472 || url.starts_with("https://")
473 || url.starts_with("ftp://")
474 || url.starts_with("mailto:")
475 || url.starts_with("tel:")
476 || url.starts_with("//")
477 }
478
479 #[inline]
493 fn is_extensionless_path(path_part: &str) -> bool {
494 if path_part.is_empty() || path_part.contains('.') || path_part.contains('&') || path_part.contains('=') {
496 return false;
497 }
498
499 let mut has_alphanumeric = false;
501 for c in path_part.chars() {
502 if c.is_alphanumeric() {
503 has_alphanumeric = true;
504 } else if !matches!(c, '/' | '\\' | '-' | '_') {
505 return false;
507 }
508 }
509
510 has_alphanumeric
512 }
513
514 #[inline]
516 fn is_cross_file_link(url: &str) -> bool {
517 if let Some(fragment_pos) = url.find('#') {
518 let path_part = &url[..fragment_pos];
519
520 if path_part.is_empty() {
522 return false;
523 }
524
525 if let Some(tag_start) = path_part.find("{%")
531 && path_part[tag_start + 2..].contains("%}")
532 {
533 return true;
534 }
535 if let Some(var_start) = path_part.find("{{")
536 && path_part[var_start + 2..].contains("}}")
537 {
538 return true;
539 }
540
541 if path_part.starts_with('/') {
544 return true;
545 }
546
547 let path_part = path_part.split('?').next().unwrap_or(path_part);
550
551 if path_part.is_empty() {
553 return false;
554 }
555
556 let has_extension = path_part.contains('.')
562 && (
563 {
565 if let Some(after_dot) = path_part.strip_prefix('.') {
567 let dots_count = path_part.matches('.').count();
568 if dots_count == 1 {
569 !after_dot.is_empty() && after_dot.len() <= 10 &&
572 after_dot.chars().all(|c| c.is_ascii_alphanumeric())
573 } else {
574 path_part.split('.').next_back().is_some_and(|ext| {
576 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
577 })
578 }
579 } else {
580 path_part.split('.').next_back().is_some_and(|ext| {
582 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
583 })
584 }
585 } ||
586 path_part.contains('/') || path_part.contains('\\') ||
588 path_part.starts_with("./") || path_part.starts_with("../")
590 );
591
592 let is_extensionless = Self::is_extensionless_path(path_part);
595
596 has_extension || is_extensionless
597 } else {
598 false
599 }
600 }
601
602 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
607 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
608 }
609
610 fn front_matter_links(&self, ctx: &crate::lint_context::LintContext) -> Vec<frontmatter_values::FrontMatterLink> {
613 if !self.checks_front_matter_of(ctx) {
614 return Vec::new();
615 }
616 frontmatter_values::link_destinations(ctx)
617 .into_iter()
618 .filter(|link| !link.field_is_in(&self.ignored_front_matter_fields))
619 .collect()
620 }
621
622 fn reports_link_from(&self, origin: &LinkOrigin) -> bool {
629 match origin {
630 LinkOrigin::Body => true,
631 LinkOrigin::FrontMatter { field } => {
632 self.config.check_frontmatter
633 && !field
634 .as_ref()
635 .is_some_and(|field| self.ignored_front_matter_fields.contains(field))
636 }
637 }
638 }
639
640 fn fragment_is_exempt(&self, ctx: &crate::lint_context::LintContext, fragment: &str) -> bool {
643 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
648 && (fragment.starts_with("fn:")
649 || fragment.starts_with("fnref:")
650 || (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
651 {
652 return true;
653 }
654
655 self.ignored_pattern_regex
657 .as_ref()
658 .is_some_and(|re| re.is_match(fragment))
659 }
660
661 fn fragment_resolves(&self, fragment: &str, anchors: &AnchorSets) -> bool {
665 if self.config.ignore_case {
666 let lower = fragment.to_lowercase();
667 anchors.html_anchors.contains(&lower) || anchors.markdown_headings.contains(&lower)
668 } else {
669 anchors.html_anchors_exact.contains(fragment) || anchors.markdown_headings_exact.contains(fragment)
670 }
671 }
672
673 fn check_front_matter(
680 &self,
681 ctx: &crate::lint_context::LintContext,
682 links: &[frontmatter_values::FrontMatterLink],
683 anchors: &AnchorSets,
684 warnings: &mut Vec<LintWarning>,
685 ) {
686 for link in links {
687 let line = ctx.lines[link.line - 1].content(ctx.content);
688 let Some(fragment) = line[link.range.clone()].strip_prefix('#') else {
689 continue;
690 };
691 if fragment.is_empty() {
692 continue;
693 }
694
695 if ctx.flavor.is_pandoc_compatible() && ctx.has_pandoc_slug(fragment) {
698 continue;
699 }
700
701 if self.fragment_is_exempt(ctx, fragment) || self.fragment_resolves(fragment, anchors) {
702 continue;
703 }
704
705 let column = byte_to_char_count(line, link.range.start);
706 warnings.push(LintWarning {
707 rule_name: Some(self.name().to_string()),
708 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
709 line: link.line,
710 column,
711 end_line: link.line,
712 end_column: column + 1 + fragment.chars().count(),
713 severity: Severity::Error,
714 fix: None,
715 });
716 }
717 }
718}
719
720impl Rule for MD051LinkFragments {
721 fn name(&self) -> &'static str {
722 "MD051"
723 }
724
725 fn description(&self) -> &'static str {
726 "Link fragments should reference valid headings"
727 }
728
729 fn fix_capability(&self) -> FixCapability {
730 FixCapability::Unfixable
731 }
732
733 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
734 if !ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx) {
738 return true;
739 }
740 !ctx.has_char('#')
742 }
743
744 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
745 let mut warnings = Vec::new();
746
747 if ctx.content.is_empty() || self.should_skip(ctx) {
748 return Ok(warnings);
749 }
750
751 let front_matter_links = self.front_matter_links(ctx);
752 if ctx.links().is_empty() && front_matter_links.is_empty() {
753 return Ok(warnings);
754 }
755
756 let anchors = self.extract_headings_from_context(ctx);
757
758 for link in ctx.links() {
759 if link.is_reference {
760 continue;
761 }
762
763 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
765 continue;
766 }
767
768 if matches!(link.link_type, LinkType::WikiLink { .. }) {
770 continue;
771 }
772
773 if ctx.is_in_jinja_range(link.byte_offset) {
775 continue;
776 }
777
778 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
781 continue;
782 }
783
784 if ctx.is_in_shortcode(link.byte_offset) {
787 continue;
788 }
789
790 let url = &link.url;
791
792 if !url.contains('#') || Self::is_external_url_fast(url) {
794 continue;
795 }
796
797 if url.contains("{{#") && url.contains("}}") {
800 continue;
801 }
802
803 if ctx.flavor.is_pandoc_compatible()
809 && let Some(frag) = url.strip_prefix('#')
810 && ctx.has_pandoc_slug(frag)
811 {
812 continue;
813 }
814
815 if url.starts_with('@') {
819 continue;
820 }
821
822 if Self::is_cross_file_link(url) {
824 continue;
825 }
826
827 let Some(fragment_pos) = url.find('#') else {
828 continue;
829 };
830
831 let fragment = &url[fragment_pos + 1..];
832
833 if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
835 continue;
836 }
837
838 if fragment.is_empty() {
839 continue;
840 }
841
842 if self.fragment_is_exempt(ctx, fragment) {
843 continue;
844 }
845
846 if !self.fragment_resolves(fragment, &anchors) {
847 warnings.push(LintWarning {
848 rule_name: Some(self.name().to_string()),
849 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
850 line: link.line,
851 column: link.start_col + 1,
852 end_line: link.end_line,
853 end_column: link.end_col + 1,
854 severity: Severity::Error,
855 fix: None,
856 });
857 }
858 }
859
860 self.check_front_matter(ctx, &front_matter_links, &anchors, &mut warnings);
861
862 Ok(warnings)
863 }
864
865 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
866 Ok(ctx.content.to_string())
869 }
870
871 fn as_any(&self) -> &dyn std::any::Any {
872 self
873 }
874
875 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
876 where
877 Self: Sized,
878 {
879 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD051Config>(config);
880
881 let explicit_style_present = config
886 .rules
887 .get("MD051")
888 .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
889 if !explicit_style_present {
890 rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
891 }
892
893 Box::new(MD051LinkFragments::build(
894 rule_config,
895 config.withheld_rule_values.contains("MD051"),
896 explicit_style_present,
897 ))
898 }
899
900 fn category(&self) -> RuleCategory {
901 RuleCategory::Link
902 }
903
904 fn skippable_by_category(&self) -> bool {
905 !self.config.check_frontmatter
908 }
909
910 fn cross_file_scope(&self) -> CrossFileScope {
911 CrossFileScope::Workspace
912 }
913
914 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
915 let mut fragment_counts = HashMap::new();
916 let anchor_style = self.anchor_style(ctx);
917 let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
918
919 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
921 if line_info.in_front_matter {
922 continue;
923 }
924
925 if line_info.in_code_block {
927 continue;
928 }
929
930 let content = line_info.content(ctx.content);
931
932 for_each_html_anchor_target(ctx, line_info, |id| file_index.add_html_anchor(id));
933
934 let parsed_heading = ctx.heading_on_line(line_idx + 1);
937 if parsed_heading.is_none() && content.contains('{') && content.contains('#') {
938 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
939 if let Some(id_match) = caps.get(1) {
940 file_index.add_attribute_anchor(id_match.as_str());
941 }
942 }
943 }
944
945 if let Some(parsed) = parsed_heading {
947 let heading = parsed.heading;
948 let fragment = anchor_style.generate_fragment(&heading.text);
949
950 Self::add_heading_to_index(
951 &fragment,
952 &heading.text,
953 heading.custom_id.clone(),
954 line_idx + 1,
955 parsed.is_setext(),
956 &mut fragment_counts,
957 file_index,
958 use_underscore_dedup,
959 );
960
961 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
966 && let Some(caps) = MD_SETTING_PATTERN.captures(content)
967 && let Some(name) = caps.get(1)
968 {
969 file_index.add_html_anchor(name.as_str());
970 }
971 }
972 }
973
974 for link in ctx.links() {
976 if link.is_reference {
977 continue;
978 }
979
980 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
982 continue;
983 }
984
985 if matches!(link.link_type, LinkType::WikiLink { .. }) {
988 continue;
989 }
990
991 let url = &link.url;
992
993 if Self::is_external_url_fast(url) {
995 continue;
996 }
997
998 if Self::is_cross_file_link(url)
1000 && let Some(fragment_pos) = url.find('#')
1001 {
1002 let path_part = &url[..fragment_pos];
1003 let fragment = &url[fragment_pos + 1..];
1004
1005 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1007 continue;
1008 }
1009
1010 file_index.add_cross_file_link(CrossFileLinkIndex {
1011 target_path: path_part.to_string(),
1012 fragment: fragment.to_string(),
1013 line: link.line,
1014 column: link.start_col + 1,
1015 origin: LinkOrigin::Body,
1016 });
1017 }
1018 }
1019
1020 for link in frontmatter_values::link_destinations(ctx) {
1027 let line = ctx.lines[link.line - 1].content(ctx.content);
1028 let value = &line[link.range.clone()];
1029
1030 if Self::is_external_url_fast(value) || !Self::is_cross_file_link(value) {
1031 continue;
1032 }
1033
1034 let Some(fragment_pos) = value.find('#') else {
1035 continue;
1036 };
1037 let path_part = &value[..fragment_pos];
1038 let fragment = &value[fragment_pos + 1..];
1039
1040 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1042 continue;
1043 }
1044
1045 file_index.add_cross_file_link(CrossFileLinkIndex {
1046 target_path: path_part.to_string(),
1047 fragment: fragment.to_string(),
1048 line: link.line,
1049 column: byte_to_char_count(line, link.range.start),
1050 origin: LinkOrigin::FrontMatter { field: link.field },
1051 });
1052 }
1053 }
1054
1055 fn cross_file_check(
1056 &self,
1057 file_path: &Path,
1058 file_index: &FileIndex,
1059 workspace_index: &crate::workspace_index::WorkspaceIndex,
1060 ) -> LintResult {
1061 let mut warnings = Vec::new();
1062
1063 let ignored_pattern = self.ignored_pattern_regex.as_ref();
1064 let ignore_case = self.config.ignore_case;
1065
1066 for cross_link in &file_index.cross_file_links {
1068 if cross_link.fragment.is_empty() {
1070 continue;
1071 }
1072
1073 if !self.reports_link_from(&cross_link.origin) {
1076 continue;
1077 }
1078
1079 if ignored_pattern.is_some_and(|re| re.is_match(&cross_link.fragment)) {
1081 continue;
1082 }
1083
1084 let target_paths_to_try =
1087 crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path);
1088
1089 let mut target_file_index = None;
1091
1092 for target_path in &target_paths_to_try {
1093 if let Some(index) = workspace_index.get_file(target_path) {
1094 target_file_index = Some(index);
1095 break;
1096 }
1097 }
1098
1099 if let Some(target_file_index) = target_file_index {
1100 if !target_file_index.has_anchor_with_case(&cross_link.fragment, ignore_case) {
1102 warnings.push(LintWarning {
1103 rule_name: Some(self.name().to_string()),
1104 line: cross_link.line,
1105 column: cross_link.column,
1106 end_line: cross_link.line,
1107 end_column: cross_link.column
1108 + cross_link.target_path.chars().count()
1109 + 1
1110 + cross_link.fragment.chars().count(),
1111 message: format!(
1112 "Link fragment '{}' not found in '{}'",
1113 cross_link.fragment, cross_link.target_path
1114 ),
1115 severity: Severity::Error,
1116 fix: None,
1117 });
1118 }
1119 }
1120 }
1122
1123 Ok(warnings)
1124 }
1125
1126 crate::impl_rule_config_sections!(MD051Config);
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131 use super::*;
1132 use crate::lint_context::LintContext;
1133 use std::path::PathBuf;
1134
1135 const ANCHOR_STYLE_PROBE: &str = "### Getting Started — Advanced\n\n\
1139 [python-markdown slug](#getting-started-advanced)\n\
1140 [github slug](#getting-started--advanced)\n";
1141
1142 fn flagged_fragment(rule: &dyn Rule, flavor: crate::config::MarkdownFlavor) -> String {
1143 let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
1144 let warnings = rule.check(&ctx).unwrap();
1145 assert_eq!(
1146 warnings.len(),
1147 1,
1148 "exactly one of the two links must be invalid under any style: {warnings:?}"
1149 );
1150 warnings[0].message.clone()
1151 }
1152
1153 #[test]
1157 fn test_unpinned_anchor_style_follows_the_file_flavor() {
1158 let rule_from_global = |flavor| {
1159 let mut config = crate::config::Config::default();
1160 config.global.flavor = flavor;
1161 MD051LinkFragments::from_config(&config)
1162 };
1163
1164 let standard_global = rule_from_global(crate::config::MarkdownFlavor::Standard);
1166 assert!(
1169 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1170 .contains("#getting-started-advanced'"),
1171 "a standard file must be checked against GitHub anchors"
1172 );
1173 assert!(
1176 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1177 .contains("#getting-started--advanced'"),
1178 "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
1179 );
1180
1181 let mkdocs_global = rule_from_global(crate::config::MarkdownFlavor::MkDocs);
1183 assert!(
1184 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1185 .contains("#getting-started--advanced'"),
1186 "a mkdocs file must be checked against Python-Markdown anchors"
1187 );
1188 assert!(
1189 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1190 .contains("#getting-started-advanced'"),
1191 "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
1192 );
1193 }
1194
1195 #[test]
1198 fn test_pinned_anchor_style_ignores_the_file_flavor() {
1199 let mut config = crate::config::Config::default();
1200 config.global.flavor = crate::config::MarkdownFlavor::Standard;
1201 let mut rule_config = crate::config::RuleConfig::default();
1202 rule_config
1203 .values
1204 .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
1205 config.rules.insert("MD051".to_string(), rule_config);
1206 let rule = MD051LinkFragments::from_config(&config);
1207
1208 for flavor in [
1209 crate::config::MarkdownFlavor::Standard,
1210 crate::config::MarkdownFlavor::MkDocs,
1211 crate::config::MarkdownFlavor::Kramdown,
1212 ] {
1213 assert!(
1214 flagged_fragment(rule.as_ref(), flavor).contains("#getting-started-advanced'"),
1215 "pinned github anchors must survive a {flavor:?} file"
1216 );
1217 }
1218 }
1219
1220 #[test]
1223 fn test_directly_constructed_rule_keeps_its_anchor_style() {
1224 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1225 anchor_style: AnchorStyle::PythonMarkdown,
1226 ..Default::default()
1227 });
1228 assert!(
1229 flagged_fragment(&rule, crate::config::MarkdownFlavor::Standard).contains("#getting-started--advanced'"),
1230 "an explicitly constructed Python-Markdown rule must not follow the file flavor"
1231 );
1232 }
1233
1234 #[test]
1235 fn test_quarto_cross_references() {
1236 let rule = MD051LinkFragments::new();
1237
1238 let content = r#"# Test Document
1240
1241## Figures
1242
1243See [@fig-plot] for the visualization.
1244
1245More details in [@tbl-results] and [@sec-methods].
1246
1247The equation [@eq-regression] shows the relationship.
1248
1249Reference to [@lst-code] for implementation."#;
1250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1251 let result = rule.check(&ctx).unwrap();
1252 assert!(
1253 result.is_empty(),
1254 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
1255 result.len()
1256 );
1257
1258 let content_with_anchor = r#"# Test
1260
1261See [link](#test) for details."#;
1262 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
1263 let result_anchor = rule.check(&ctx_anchor).unwrap();
1264 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
1265
1266 let content_invalid = r#"# Test
1268
1269See [link](#nonexistent) for details."#;
1270 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
1271 let result_invalid = rule.check(&ctx_invalid).unwrap();
1272 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
1273 }
1274
1275 #[test]
1276 fn test_jsx_in_heading_anchor() {
1277 let rule = MD051LinkFragments::new();
1279
1280 let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
1282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1283 let result = rule.check(&ctx).unwrap();
1284 assert!(
1285 result.is_empty(),
1286 "JSX self-closing tag should be stripped from anchor: got {result:?}"
1287 );
1288
1289 let content2 =
1291 "### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
1292 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1293 let result2 = rule.check(&ctx2).unwrap();
1294 assert!(
1295 result2.is_empty(),
1296 "JSX tag with attributes should be stripped from anchor: got {result2:?}"
1297 );
1298
1299 let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
1301 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1302 let result3 = rule.check(&ctx3).unwrap();
1303 assert!(
1304 result3.is_empty(),
1305 "HTML tag content should be preserved in anchor: got {result3:?}"
1306 );
1307 }
1308
1309 #[test]
1311 fn test_cross_file_scope() {
1312 let rule = MD051LinkFragments::new();
1313 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1314 }
1315
1316 #[test]
1317 fn test_contribute_to_index_extracts_headings() {
1318 let rule = MD051LinkFragments::new();
1319 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
1320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321
1322 let mut file_index = FileIndex::new();
1323 rule.contribute_to_index(&ctx, &mut file_index);
1324
1325 assert_eq!(file_index.headings.len(), 3);
1326 assert_eq!(file_index.headings[0].text, "First Heading");
1327 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
1328 assert!(file_index.headings[0].custom_anchor.is_none());
1329
1330 assert_eq!(file_index.headings[1].text, "Second");
1331 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
1332
1333 assert_eq!(file_index.headings[2].text, "Third");
1334 }
1335
1336 #[test]
1337 fn test_contribute_to_index_extracts_cross_file_links() {
1338 let rule = MD051LinkFragments::new();
1339 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
1340 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1341
1342 let mut file_index = FileIndex::new();
1343 rule.contribute_to_index(&ctx, &mut file_index);
1344
1345 assert_eq!(file_index.cross_file_links.len(), 2);
1346 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1347 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
1348 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
1349 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
1350 }
1351
1352 #[test]
1356 fn test_contribute_to_index_records_setext_headings() {
1357 let rule = MD051LinkFragments::new();
1358 let content = "Setext One\n==========\n\nSetext Two\n----------\n\n### Atx Three\n";
1359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1360
1361 let mut file_index = FileIndex::new();
1362 rule.contribute_to_index(&ctx, &mut file_index);
1363
1364 let styles: Vec<(&str, bool)> = file_index
1365 .headings
1366 .iter()
1367 .map(|h| (h.text.as_str(), h.is_setext))
1368 .collect();
1369 assert_eq!(
1370 styles,
1371 vec![("Setext One", true), ("Setext Two", true), ("Atx Three", false)]
1372 );
1373 }
1374
1375 #[test]
1383 fn test_a_frontmatter_link_is_indexed_regardless_of_the_indexing_config() {
1384 let content = "---\nlink: 'other.md#nope'\n---\n\n# Real\n";
1385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386
1387 for check_frontmatter in [true, false] {
1388 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1389 check_frontmatter,
1390 ..Default::default()
1391 });
1392 let mut file_index = FileIndex::new();
1393 rule.contribute_to_index(&ctx, &mut file_index);
1394
1395 assert_eq!(
1396 file_index.cross_file_links.len(),
1397 1,
1398 "check_frontmatter = {check_frontmatter} changed what was indexed"
1399 );
1400 assert_eq!(
1401 file_index.cross_file_links[0].origin,
1402 LinkOrigin::FrontMatter {
1403 field: Some("link".to_string())
1404 },
1405 );
1406 }
1407 }
1408
1409 #[test]
1413 fn test_cross_file_check_applies_this_files_frontmatter_config() {
1414 use crate::workspace_index::WorkspaceIndex;
1415
1416 let mut workspace_index = WorkspaceIndex::new();
1417 let mut target = FileIndex::new();
1418 target.add_heading(HeadingIndex {
1419 text: "Real".to_string(),
1420 auto_anchor: "real".to_string(),
1421 custom_anchor: None,
1422 line: 1,
1423 is_setext: false,
1424 });
1425 workspace_index.insert_file(PathBuf::from("docs/other.md"), target);
1426
1427 let mut file_index = FileIndex::new();
1428 file_index.add_cross_file_link(CrossFileLinkIndex {
1429 target_path: "other.md".to_string(),
1430 fragment: "nope".to_string(),
1431 line: 2,
1432 column: 7,
1433 origin: LinkOrigin::FrontMatter {
1434 field: Some("link".to_string()),
1435 },
1436 });
1437 file_index.add_cross_file_link(CrossFileLinkIndex {
1441 target_path: "other.md".to_string(),
1442 fragment: "nope".to_string(),
1443 line: 6,
1444 column: 5,
1445 origin: LinkOrigin::Body,
1446 });
1447
1448 let count = |config: MD051Config| {
1449 MD051LinkFragments::from_config_struct(config)
1450 .cross_file_check(Path::new("docs/readme.md"), &file_index, &workspace_index)
1451 .unwrap()
1452 .len()
1453 };
1454
1455 assert_eq!(
1456 count(MD051Config {
1457 check_frontmatter: true,
1458 ..Default::default()
1459 }),
1460 2,
1461 "checking frontmatter should report both the frontmatter and body links"
1462 );
1463 assert_eq!(
1464 count(MD051Config {
1465 check_frontmatter: false,
1466 ..Default::default()
1467 }),
1468 1,
1469 "not checking frontmatter should leave only the body link"
1470 );
1471 assert_eq!(
1472 count(MD051Config {
1473 check_frontmatter: true,
1474 ignore_frontmatter_fields: vec!["LINK".to_string()],
1475 ..Default::default()
1476 }),
1477 1,
1478 "an ignored field should be matched case-insensitively"
1479 );
1480 }
1481
1482 #[test]
1483 fn test_cross_file_check_valid_fragment() {
1484 use crate::workspace_index::WorkspaceIndex;
1485
1486 let rule = MD051LinkFragments::new();
1487
1488 let mut workspace_index = WorkspaceIndex::new();
1490 let mut target_file_index = FileIndex::new();
1491 target_file_index.add_heading(HeadingIndex {
1492 text: "Installation Guide".to_string(),
1493 auto_anchor: "installation-guide".to_string(),
1494 custom_anchor: None,
1495 line: 1,
1496 is_setext: false,
1497 });
1498 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1499
1500 let mut current_file_index = FileIndex::new();
1502 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1503 target_path: "install.md".to_string(),
1504 fragment: "installation-guide".to_string(),
1505 line: 3,
1506 column: 5,
1507 origin: LinkOrigin::Body,
1508 });
1509
1510 let warnings = rule
1511 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1512 .unwrap();
1513
1514 assert!(warnings.is_empty());
1516 }
1517
1518 #[test]
1519 fn test_cross_file_check_invalid_fragment() {
1520 use crate::workspace_index::WorkspaceIndex;
1521
1522 let rule = MD051LinkFragments::new();
1523
1524 let mut workspace_index = WorkspaceIndex::new();
1526 let mut target_file_index = FileIndex::new();
1527 target_file_index.add_heading(HeadingIndex {
1528 text: "Installation Guide".to_string(),
1529 auto_anchor: "installation-guide".to_string(),
1530 custom_anchor: None,
1531 line: 1,
1532 is_setext: false,
1533 });
1534 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1535
1536 let mut current_file_index = FileIndex::new();
1538 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1539 target_path: "install.md".to_string(),
1540 fragment: "nonexistent".to_string(),
1541 line: 3,
1542 column: 5,
1543 origin: LinkOrigin::Body,
1544 });
1545
1546 let warnings = rule
1547 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1548 .unwrap();
1549
1550 assert_eq!(warnings.len(), 1);
1552 assert!(warnings[0].message.contains("nonexistent"));
1553 assert!(warnings[0].message.contains("install.md"));
1554 }
1555
1556 #[test]
1557 fn test_cross_file_check_custom_anchor_match() {
1558 use crate::workspace_index::WorkspaceIndex;
1559
1560 let rule = MD051LinkFragments::new();
1561
1562 let mut workspace_index = WorkspaceIndex::new();
1564 let mut target_file_index = FileIndex::new();
1565 target_file_index.add_heading(HeadingIndex {
1566 text: "Installation Guide".to_string(),
1567 auto_anchor: "installation-guide".to_string(),
1568 custom_anchor: Some("install".to_string()),
1569 line: 1,
1570 is_setext: false,
1571 });
1572 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1573
1574 let mut current_file_index = FileIndex::new();
1576 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1577 target_path: "install.md".to_string(),
1578 fragment: "install".to_string(),
1579 line: 3,
1580 column: 5,
1581 origin: LinkOrigin::Body,
1582 });
1583
1584 let warnings = rule
1585 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1586 .unwrap();
1587
1588 assert!(warnings.is_empty());
1590 }
1591
1592 #[test]
1593 fn test_cross_file_check_target_not_in_workspace() {
1594 use crate::workspace_index::WorkspaceIndex;
1595
1596 let rule = MD051LinkFragments::new();
1597
1598 let workspace_index = WorkspaceIndex::new();
1600
1601 let mut current_file_index = FileIndex::new();
1603 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1604 target_path: "external.md".to_string(),
1605 fragment: "heading".to_string(),
1606 line: 3,
1607 column: 5,
1608 origin: LinkOrigin::Body,
1609 });
1610
1611 let warnings = rule
1612 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1613 .unwrap();
1614
1615 assert!(warnings.is_empty());
1617 }
1618
1619 #[test]
1620 fn test_wikilinks_skipped_in_check() {
1621 let rule = MD051LinkFragments::new();
1623
1624 let content = r#"# Test Document
1625
1626## Valid Heading
1627
1628[[Microsoft#Windows OS]]
1629[[SomePage#section]]
1630[[page|Display Text]]
1631[[path/to/page#section]]
1632"#;
1633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1634 let result = rule.check(&ctx).unwrap();
1635
1636 assert!(
1637 result.is_empty(),
1638 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_wikilinks_not_added_to_cross_file_index() {
1644 let rule = MD051LinkFragments::new();
1646
1647 let content = r#"# Test Document
1648
1649[[Microsoft#Windows OS]]
1650[[SomePage#section]]
1651[Regular Link](other.md#section)
1652"#;
1653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654
1655 let mut file_index = FileIndex::new();
1656 rule.contribute_to_index(&ctx, &mut file_index);
1657
1658 let cross_file_links = &file_index.cross_file_links;
1661 assert_eq!(
1662 cross_file_links.len(),
1663 1,
1664 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1665 );
1666 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1667 assert_eq!(file_index.cross_file_links[0].fragment, "section");
1668 }
1669
1670 #[test]
1671 fn test_pandoc_flavor_skips_citations() {
1672 let rule = MD051LinkFragments::new();
1676 let content = "# Test Document\n\nSee [@smith2020] for details.\n";
1677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1678 let result = rule.check(&ctx).unwrap();
1679 assert!(
1680 result.is_empty(),
1681 "MD051 should skip Pandoc citations under Pandoc flavor: {result:?}"
1682 );
1683 }
1684
1685 #[test]
1686 fn md051_pandoc_resolves_pandoc_slug_diverging_from_github() {
1687 use crate::config::MarkdownFlavor;
1694 let rule = MD051LinkFragments::new();
1695 let content = "# 5. Five Things\n\nSee [details](#5.-five-things).\n";
1696
1697 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1700 let std_result = rule.check(&ctx_std).unwrap();
1701 assert_eq!(
1702 std_result.len(),
1703 1,
1704 "Standard flavor should flag the Pandoc-style fragment: {std_result:?}"
1705 );
1706
1707 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1709 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1710 assert!(
1711 pandoc_result.is_empty(),
1712 "Pandoc flavor should resolve `#5.-five-things` against the heading slug: {pandoc_result:?}"
1713 );
1714 }
1715
1716 #[test]
1720 fn md051_pandoc_flags_missing_fragment_with_email_in_link_text() {
1721 use crate::config::MarkdownFlavor;
1722 let rule = MD051LinkFragments::new();
1723 let content = "# Title\n\n[contact user@example.com](#missing)\n";
1724
1725 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1726 let std_result = rule.check(&ctx_std).unwrap();
1727 assert_eq!(
1728 std_result.len(),
1729 1,
1730 "Standard flavor must flag the missing fragment: {std_result:?}"
1731 );
1732
1733 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1734 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1735 assert_eq!(
1736 pandoc_result.len(),
1737 1,
1738 "Pandoc flavor must also flag the missing fragment — link text with embedded email is not a citation: {pandoc_result:?}"
1739 );
1740 }
1741
1742 #[test]
1746 fn md051_pandoc_flags_missing_fragment_with_citation_in_link_text() {
1747 use crate::config::MarkdownFlavor;
1748 let rule = MD051LinkFragments::new();
1749 let content = "# Title\n\n[see @smith2020](#missing)\n";
1750
1751 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1752 let std_result = rule.check(&ctx_std).unwrap();
1753 assert_eq!(
1754 std_result.len(),
1755 1,
1756 "Standard flavor must flag the missing fragment: {std_result:?}"
1757 );
1758
1759 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1760 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1761 assert_eq!(
1762 pandoc_result.len(),
1763 1,
1764 "Pandoc flavor must flag the missing fragment — `[label](url)` is a link, not a citation: {pandoc_result:?}"
1765 );
1766 }
1767
1768 #[test]
1772 fn md051_pandoc_resolves_duplicate_heading_suffix_slug() {
1773 use crate::config::MarkdownFlavor;
1774 let rule = MD051LinkFragments::new();
1775 let content = "# A.\n\nfirst\n\n# A.\n\nsecond\n\n[first](#a.) and [second](#a.-1).\n";
1776
1777 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1778 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1779 assert!(
1780 pandoc_result.is_empty(),
1781 "Pandoc flavor should resolve `#a.` and `#a.-1` against duplicate headings: {pandoc_result:?}"
1782 );
1783
1784 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1785 let quarto_result = rule.check(&ctx_quarto).unwrap();
1786 assert!(
1787 quarto_result.is_empty(),
1788 "Quarto flavor should also resolve duplicate-heading suffix slugs: {quarto_result:?}"
1789 );
1790 }
1791
1792 #[test]
1795 fn md051_pandoc_flags_overshoot_duplicate_suffix() {
1796 use crate::config::MarkdownFlavor;
1797 let rule = MD051LinkFragments::new();
1798 let content = "# A.\n\n# A.\n\n[overshoot](#a.-2)\n";
1799
1800 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1801 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1802 assert_eq!(
1803 pandoc_result.len(),
1804 1,
1805 "Pandoc must flag `#a.-2` when only `-1` exists (two duplicates): {pandoc_result:?}"
1806 );
1807 }
1808
1809 fn front_matter_checked() -> MD051Config {
1810 MD051Config {
1811 check_frontmatter: true,
1812 ..MD051Config::default()
1813 }
1814 }
1815
1816 fn check_front_matter(content: &str, config: MD051Config) -> Vec<LintWarning> {
1817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818 MD051LinkFragments::from_config_struct(config).check(&ctx).unwrap()
1819 }
1820
1821 #[test]
1822 fn a_broken_frontmatter_fragment_is_reported_when_enabled() {
1823 let content = "---\nanchor: '#missing'\nvalid: '#title'\n---\n\n# Title\n";
1824 let result = check_front_matter(content, front_matter_checked());
1825
1826 assert_eq!(
1827 result.len(),
1828 1,
1829 "Only the unresolved fragment is reported. Got: {result:?}"
1830 );
1831 assert_eq!(
1832 result[0].message,
1833 "Link anchor '#missing' does not exist in document headings"
1834 );
1835 assert_eq!(result[0].line, 2);
1836 assert_eq!(result[0].column, 10, "The warning points at the value, not the key");
1837 assert_eq!(result[0].end_column, 18);
1838 }
1839
1840 #[test]
1841 fn frontmatter_fragments_are_not_checked_by_default() {
1842 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
1843 let result = check_front_matter(content, MD051Config::default());
1844
1845 assert!(
1846 result.is_empty(),
1847 "Frontmatter is only checked on request. Got: {result:?}"
1848 );
1849 }
1850
1851 #[test]
1852 fn an_ignored_frontmatter_field_is_not_checked() {
1853 let content = "---\nhero: '#missing'\nanchor: '#other'\n---\n\n# Title\n";
1854 let config = MD051Config {
1855 check_frontmatter: true,
1856 ignore_frontmatter_fields: vec!["Hero".to_string()],
1857 ..MD051Config::default()
1858 };
1859 let result = check_front_matter(content, config);
1860
1861 assert_eq!(
1862 result.len(),
1863 1,
1864 "The ignored field is skipped and the other is not. Got: {result:?}"
1865 );
1866 assert_eq!(result[0].line, 3);
1867 }
1868
1869 #[test]
1870 fn the_ignored_pattern_applies_to_frontmatter_fragments() {
1871 let content = "---\nnote: '#fn:1'\nanchor: '#missing'\n---\n\n# Title\n";
1872 let config = MD051Config {
1873 check_frontmatter: true,
1874 ignored_pattern: Some("^fn:".to_string()),
1875 ..MD051Config::default()
1876 };
1877 let result = check_front_matter(content, config);
1878
1879 assert_eq!(
1880 result.len(),
1881 1,
1882 "The matching fragment is skipped and the other is not. Got: {result:?}"
1883 );
1884 assert_eq!(result[0].line, 3);
1885 }
1886
1887 #[test]
1888 fn a_frontmatter_fragment_honors_ignore_case() {
1889 let content = "---\nanchor: '#Title'\n---\n\n# Title\n";
1890
1891 let permissive = check_front_matter(content, front_matter_checked());
1892 assert!(
1893 permissive.is_empty(),
1894 "The default resolves a case mismatch. Got: {permissive:?}"
1895 );
1896
1897 let strict = check_front_matter(
1898 content,
1899 MD051Config {
1900 check_frontmatter: true,
1901 ignore_case: false,
1902 ..MD051Config::default()
1903 },
1904 );
1905 assert_eq!(strict.len(), 1, "Strict matching reports it. Got: {strict:?}");
1906 }
1907
1908 #[test]
1909 fn prose_in_frontmatter_is_not_read_as_a_fragment() {
1910 let content = "---\ntitle: Node.js\ntags: ci/cd\n---\n\n# Title\n";
1911 let result = check_front_matter(content, front_matter_checked());
1912
1913 assert!(
1914 result.is_empty(),
1915 "Only path-shaped values are destinations. Got: {result:?}"
1916 );
1917 }
1918
1919 #[test]
1920 fn a_frontmatter_path_with_a_fragment_is_validated_across_files() {
1921 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1922 let source = "---\ntemplate: other.md#missing\nvalid: other.md#target\n---\n\n# Source\n";
1923
1924 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1925 let mut source_index = FileIndex::default();
1926 rule.contribute_to_index(&source_ctx, &mut source_index);
1927
1928 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1929 let mut target_index = FileIndex::default();
1930 rule.contribute_to_index(&target_ctx, &mut target_index);
1931
1932 let source_path = PathBuf::from("docs/source.md");
1933 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1934 workspace.insert_file(source_path.clone(), source_index.clone());
1935 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1936
1937 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1938
1939 assert_eq!(
1940 warnings.len(),
1941 1,
1942 "Only the unresolved fragment is reported. Got: {warnings:?}"
1943 );
1944 assert_eq!(warnings[0].message, "Link fragment 'missing' not found in 'other.md'");
1945 assert_eq!(warnings[0].line, 2);
1946 assert_eq!(warnings[0].column, 11);
1947 }
1948
1949 #[test]
1950 fn a_query_string_does_not_hide_the_target_file() {
1951 let rule = MD051LinkFragments::new();
1952 let source = "# Source\n\n- [a](other.md?raw=true#missing)\n- [b](other.md?raw=true#target)\n";
1953
1954 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1955 let mut source_index = FileIndex::default();
1956 rule.contribute_to_index(&source_ctx, &mut source_index);
1957
1958 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1959 let mut target_index = FileIndex::default();
1960 rule.contribute_to_index(&target_ctx, &mut target_index);
1961
1962 let source_path = PathBuf::from("docs/source.md");
1963 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1964 workspace.insert_file(source_path.clone(), source_index.clone());
1965 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1966
1967 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1968
1969 assert_eq!(
1970 warnings.len(),
1971 1,
1972 "The query is stripped to find the file, so both fragments resolve against it. Got: {warnings:?}"
1973 );
1974 assert_eq!(
1975 warnings[0].message,
1976 "Link fragment 'missing' not found in 'other.md?raw=true'"
1977 );
1978 assert_eq!(warnings[0].line, 3);
1979 }
1980
1981 #[test]
1982 fn a_query_string_does_not_hide_an_extensionless_target_file() {
1983 let rule = MD051LinkFragments::new();
1984 let source = "# Source\n\n- [a](other?raw=true#target)\n- [b](other#target)\n- [c](other?raw=true#absent)\n";
1985
1986 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1987 let same_document = rule.check(&source_ctx).unwrap();
1988 assert!(
1989 same_document.is_empty(),
1990 "Every fragment here belongs to another file, so none is a missing anchor of this one. Got: {same_document:?}"
1991 );
1992
1993 let mut source_index = FileIndex::default();
1994 rule.contribute_to_index(&source_ctx, &mut source_index);
1995
1996 let target_ctx = LintContext::new("# Other\n\n## Target\n", crate::config::MarkdownFlavor::Standard, None);
1997 let mut target_index = FileIndex::default();
1998 rule.contribute_to_index(&target_ctx, &mut target_index);
1999
2000 let source_path = PathBuf::from("docs/source.md");
2001 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
2002 workspace.insert_file(source_path.clone(), source_index.clone());
2003 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
2004
2005 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
2006
2007 assert_eq!(
2008 warnings.len(),
2009 1,
2010 "The query is stripped before the markdown extension is added. Got: {warnings:?}"
2011 );
2012 assert_eq!(
2013 warnings[0].message,
2014 "Link fragment 'absent' not found in 'other?raw=true'"
2015 );
2016 assert_eq!(warnings[0].line, 5);
2017 }
2018
2019 #[test]
2020 fn a_destination_that_is_only_a_query_stays_on_this_page() {
2021 let rule = MD051LinkFragments::new();
2022 let source = "# Source\n\n## Here\n\n- [a](?raw=true#here)\n- [b](?raw=true#nowhere)\n";
2023
2024 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2025 let warnings = rule.check(&source_ctx).unwrap();
2026
2027 assert_eq!(
2028 warnings.len(),
2029 1,
2030 "Only the absent anchor is reported. Got: {warnings:?}"
2031 );
2032 assert_eq!(
2033 warnings[0].message,
2034 "Link anchor '#nowhere' does not exist in document headings"
2035 );
2036 assert_eq!(warnings[0].line, 6);
2037
2038 let mut source_index = FileIndex::default();
2039 rule.contribute_to_index(&source_ctx, &mut source_index);
2040 assert!(
2041 source_index.cross_file_links.is_empty(),
2042 "A query with no path names no other file. Got: {:?}",
2043 source_index.cross_file_links
2044 );
2045 }
2046
2047 #[test]
2048 fn blockquote_syntax_inside_raw_html_does_not_create_an_anchor() {
2049 let rule = MD051LinkFragments::new();
2050 let source = "<div>\n> ## Hidden\n</div>\n\n[link](#hidden)\n";
2051 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2052
2053 let warnings = rule.check(&ctx).unwrap();
2054 assert_eq!(
2055 warnings.len(),
2056 1,
2057 "raw HTML must not satisfy the fragment: {warnings:?}"
2058 );
2059
2060 let mut file_index = FileIndex::default();
2061 rule.contribute_to_index(&ctx, &mut file_index);
2062 assert!(
2063 file_index.headings.is_empty(),
2064 "raw HTML must not enter the workspace index"
2065 );
2066 }
2067
2068 #[test]
2069 fn a_frontmatter_path_carrying_a_query_is_indexed() {
2070 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
2071 let source = "---\ntemplate: docs/other.md?raw=true#missing\n---\n\n# Source\n";
2072
2073 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2074 let mut source_index = FileIndex::default();
2075 rule.contribute_to_index(&source_ctx, &mut source_index);
2076
2077 assert_eq!(source_index.cross_file_links.len(), 1);
2078 assert_eq!(source_index.cross_file_links[0].target_path, "docs/other.md?raw=true");
2079 assert_eq!(source_index.cross_file_links[0].fragment, "missing");
2080 }
2081
2082 #[test]
2086 fn frontmatter_cross_file_paths_are_not_reported_by_default() {
2087 use crate::workspace_index::WorkspaceIndex;
2088
2089 let rule = MD051LinkFragments::new();
2090 let source = "---\ntemplate: other.md#missing\n---\n\n# Source\n";
2091
2092 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2093 let mut source_index = FileIndex::default();
2094 rule.contribute_to_index(&source_ctx, &mut source_index);
2095 assert_eq!(source_index.cross_file_links.len(), 1);
2096
2097 let mut workspace_index = WorkspaceIndex::new();
2098 let mut target = FileIndex::new();
2099 target.add_heading(HeadingIndex {
2100 text: "Present".to_string(),
2101 auto_anchor: "present".to_string(),
2102 custom_anchor: None,
2103 line: 1,
2104 is_setext: false,
2105 });
2106 workspace_index.insert_file(PathBuf::from("other.md"), target);
2107
2108 let warnings = rule
2109 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2110 .unwrap();
2111 assert!(
2112 warnings.is_empty(),
2113 "Frontmatter is only checked on request. Got: {warnings:?}"
2114 );
2115
2116 let checking = MD051LinkFragments::from_config_struct(MD051Config {
2120 check_frontmatter: true,
2121 ..Default::default()
2122 });
2123 assert_eq!(
2124 checking
2125 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2126 .unwrap()
2127 .len(),
2128 1
2129 );
2130 }
2131}