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)]
317 fn add_heading_to_index(
318 fragment: &str,
319 text: &str,
320 custom_anchor: Option<String>,
321 line: usize,
322 text_lines: usize,
323 is_setext: bool,
324 fragment_counts: &mut HashMap<String, usize>,
325 file_index: &mut FileIndex,
326 use_underscore_dedup: bool,
327 ) {
328 if fragment.is_empty() {
329 if !use_underscore_dedup {
330 return;
331 }
332 let count = fragment_counts.entry(fragment.to_string()).or_insert(0);
334 *count += 1;
335 file_index.add_heading(HeadingIndex {
336 text: text.to_string(),
337 auto_anchor: format!("_{count}"),
338 custom_anchor,
339 line,
340 text_lines,
341 is_setext,
342 });
343 return;
344 }
345 if let Some(count) = fragment_counts.get_mut(fragment) {
346 let suffix = *count;
347 *count += 1;
348 let (primary, alias) = if use_underscore_dedup {
349 (format!("{fragment}_{suffix}"), Some(format!("{fragment}-{suffix}")))
351 } else {
352 (format!("{fragment}-{suffix}"), None)
354 };
355 file_index.add_heading(HeadingIndex {
356 text: text.to_string(),
357 auto_anchor: primary,
358 custom_anchor,
359 line,
360 text_lines,
361 is_setext,
362 });
363 if let Some(alias_anchor) = alias {
364 let heading_idx = file_index.headings.len() - 1;
365 file_index.add_anchor_alias(&alias_anchor, heading_idx);
366 }
367 } else {
368 fragment_counts.insert(fragment.to_string(), 1);
369 file_index.add_heading(HeadingIndex {
370 text: text.to_string(),
371 auto_anchor: fragment.to_string(),
372 custom_anchor,
373 line,
374 text_lines,
375 is_setext,
376 });
377 }
378 }
379
380 fn extract_headings_from_context(&self, ctx: &crate::lint_context::LintContext) -> AnchorSets {
387 let track_exact = !self.config.ignore_case;
388 let mut markdown_headings = HashSet::with_capacity(32);
389 let mut markdown_headings_exact = if track_exact {
390 HashSet::with_capacity(32)
391 } else {
392 HashSet::new()
393 };
394 let mut html_anchors = HashSet::with_capacity(16);
395 let mut html_anchors_exact = if track_exact {
396 HashSet::with_capacity(16)
397 } else {
398 HashSet::new()
399 };
400 let mut fragment_counts = std::collections::HashMap::new();
401 let anchor_style = self.anchor_style(ctx);
402 let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
403
404 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
405 if line_info.in_front_matter {
406 continue;
407 }
408
409 if line_info.in_code_block {
411 continue;
412 }
413
414 let content = line_info.content(ctx.content);
415
416 for_each_html_anchor_target(ctx, line_info, |id| {
417 html_anchors.insert(id.to_lowercase());
418 if track_exact {
419 html_anchors_exact.insert(id.to_string());
420 }
421 });
422
423 let parsed_heading = ctx.heading_on_line(line_idx + 1);
428 if parsed_heading.is_none()
429 && !line_info.is_setext_heading_text
430 && content.contains('{')
431 && content.contains('#')
432 {
433 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
434 if let Some(id_match) = caps.get(1) {
435 let id = id_match.as_str();
436 markdown_headings.insert(id.to_lowercase());
437 if track_exact {
438 markdown_headings_exact.insert(id.to_string());
439 }
440 }
441 }
442 }
443
444 if let Some(parsed) = parsed_heading {
446 let heading = parsed.heading;
447 if let Some(custom_id) = &heading.custom_id {
449 markdown_headings.insert(custom_id.to_lowercase());
450 if track_exact {
451 markdown_headings_exact.insert(custom_id.clone());
452 }
453 }
454
455 let fragment = anchor_style.generate_fragment(&heading.slug_text);
460
461 Self::insert_deduplicated_fragment(
462 fragment,
463 &mut fragment_counts,
464 &mut markdown_headings,
465 track_exact.then_some(&mut markdown_headings_exact),
466 use_underscore_dedup,
467 );
468 }
469 }
470
471 AnchorSets {
472 markdown_headings,
473 markdown_headings_exact,
474 html_anchors,
475 html_anchors_exact,
476 }
477 }
478
479 #[inline]
481 fn is_external_url_fast(url: &str) -> bool {
482 url.starts_with("http://")
484 || url.starts_with("https://")
485 || url.starts_with("ftp://")
486 || url.starts_with("mailto:")
487 || url.starts_with("tel:")
488 || url.starts_with("//")
489 }
490
491 #[inline]
505 fn is_extensionless_path(path_part: &str) -> bool {
506 if path_part.is_empty() || path_part.contains('.') || path_part.contains('&') || path_part.contains('=') {
508 return false;
509 }
510
511 let mut has_alphanumeric = false;
513 for c in path_part.chars() {
514 if c.is_alphanumeric() {
515 has_alphanumeric = true;
516 } else if !matches!(c, '/' | '\\' | '-' | '_') {
517 return false;
519 }
520 }
521
522 has_alphanumeric
524 }
525
526 #[inline]
528 fn is_cross_file_link(url: &str) -> bool {
529 if let Some(fragment_pos) = url.find('#') {
530 let path_part = &url[..fragment_pos];
531
532 if path_part.is_empty() {
534 return false;
535 }
536
537 if let Some(tag_start) = path_part.find("{%")
543 && path_part[tag_start + 2..].contains("%}")
544 {
545 return true;
546 }
547 if let Some(var_start) = path_part.find("{{")
548 && path_part[var_start + 2..].contains("}}")
549 {
550 return true;
551 }
552
553 if path_part.starts_with('/') {
556 return true;
557 }
558
559 let path_part = path_part.split('?').next().unwrap_or(path_part);
562
563 if path_part.is_empty() {
565 return false;
566 }
567
568 let has_extension = path_part.contains('.')
574 && (
575 {
577 if let Some(after_dot) = path_part.strip_prefix('.') {
579 let dots_count = path_part.matches('.').count();
580 if dots_count == 1 {
581 !after_dot.is_empty() && after_dot.len() <= 10 &&
584 after_dot.chars().all(|c| c.is_ascii_alphanumeric())
585 } else {
586 path_part.split('.').next_back().is_some_and(|ext| {
588 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
589 })
590 }
591 } else {
592 path_part.split('.').next_back().is_some_and(|ext| {
594 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
595 })
596 }
597 } ||
598 path_part.contains('/') || path_part.contains('\\') ||
600 path_part.starts_with("./") || path_part.starts_with("../")
602 );
603
604 let is_extensionless = Self::is_extensionless_path(path_part);
607
608 has_extension || is_extensionless
609 } else {
610 false
611 }
612 }
613
614 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
619 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
620 }
621
622 fn front_matter_links(&self, ctx: &crate::lint_context::LintContext) -> Vec<frontmatter_values::FrontMatterLink> {
625 if !self.checks_front_matter_of(ctx) {
626 return Vec::new();
627 }
628 frontmatter_values::link_destinations(ctx)
629 .into_iter()
630 .filter(|link| !link.field_is_in(&self.ignored_front_matter_fields))
631 .collect()
632 }
633
634 fn reports_link_from(&self, origin: &LinkOrigin) -> bool {
641 match origin {
642 LinkOrigin::Body => true,
643 LinkOrigin::FrontMatter { field } => {
644 self.config.check_frontmatter
645 && !field
646 .as_ref()
647 .is_some_and(|field| self.ignored_front_matter_fields.contains(field))
648 }
649 }
650 }
651
652 fn fragment_is_exempt(&self, ctx: &crate::lint_context::LintContext, fragment: &str) -> bool {
655 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
660 && (fragment.starts_with("fn:")
661 || fragment.starts_with("fnref:")
662 || (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
663 {
664 return true;
665 }
666
667 self.ignored_pattern_regex
669 .as_ref()
670 .is_some_and(|re| re.is_match(fragment))
671 }
672
673 fn fragment_resolves(&self, fragment: &str, anchors: &AnchorSets) -> bool {
677 if self.config.ignore_case {
678 let lower = fragment.to_lowercase();
679 anchors.html_anchors.contains(&lower) || anchors.markdown_headings.contains(&lower)
680 } else {
681 anchors.html_anchors_exact.contains(fragment) || anchors.markdown_headings_exact.contains(fragment)
682 }
683 }
684
685 fn check_front_matter(
692 &self,
693 ctx: &crate::lint_context::LintContext,
694 links: &[frontmatter_values::FrontMatterLink],
695 anchors: &AnchorSets,
696 warnings: &mut Vec<LintWarning>,
697 ) {
698 for link in links {
699 let line = ctx.lines[link.line - 1].content(ctx.content);
700 let Some(fragment) = line[link.range.clone()].strip_prefix('#') else {
701 continue;
702 };
703 if fragment.is_empty() {
704 continue;
705 }
706
707 if ctx.flavor.is_pandoc_compatible() && ctx.has_pandoc_slug(fragment) {
710 continue;
711 }
712
713 if self.fragment_is_exempt(ctx, fragment) || self.fragment_resolves(fragment, anchors) {
714 continue;
715 }
716
717 let column = byte_to_char_count(line, link.range.start);
718 warnings.push(LintWarning {
719 rule_name: Some(self.name().to_string()),
720 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
721 line: link.line,
722 column,
723 end_line: link.line,
724 end_column: column + 1 + fragment.chars().count(),
725 severity: Severity::Error,
726 fix: None,
727 });
728 }
729 }
730}
731
732impl Rule for MD051LinkFragments {
733 fn name(&self) -> &'static str {
734 "MD051"
735 }
736
737 fn description(&self) -> &'static str {
738 "Link fragments should reference valid headings"
739 }
740
741 fn fix_capability(&self) -> FixCapability {
742 FixCapability::Unfixable
743 }
744
745 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
746 if !ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx) {
750 return true;
751 }
752 !ctx.has_char('#')
754 }
755
756 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
757 let mut warnings = Vec::new();
758
759 if ctx.content.is_empty() || self.should_skip(ctx) {
760 return Ok(warnings);
761 }
762
763 let front_matter_links = self.front_matter_links(ctx);
764 if ctx.links().is_empty() && front_matter_links.is_empty() {
765 return Ok(warnings);
766 }
767
768 let anchors = self.extract_headings_from_context(ctx);
769
770 for link in ctx.links() {
771 if link.is_reference {
772 continue;
773 }
774
775 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
777 continue;
778 }
779
780 if matches!(link.link_type, LinkType::WikiLink { .. }) {
782 continue;
783 }
784
785 if ctx.is_in_jinja_range(link.byte_offset) {
787 continue;
788 }
789
790 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
793 continue;
794 }
795
796 if ctx.is_in_shortcode(link.byte_offset) {
799 continue;
800 }
801
802 let url = &link.url;
803
804 if !url.contains('#') || Self::is_external_url_fast(url) {
806 continue;
807 }
808
809 if url.contains("{{#") && url.contains("}}") {
812 continue;
813 }
814
815 if ctx.flavor.is_pandoc_compatible()
821 && let Some(frag) = url.strip_prefix('#')
822 && ctx.has_pandoc_slug(frag)
823 {
824 continue;
825 }
826
827 if url.starts_with('@') {
831 continue;
832 }
833
834 if Self::is_cross_file_link(url) {
836 continue;
837 }
838
839 let Some(fragment_pos) = url.find('#') else {
840 continue;
841 };
842
843 let fragment = &url[fragment_pos + 1..];
844
845 if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
847 continue;
848 }
849
850 if fragment.is_empty() {
851 continue;
852 }
853
854 if self.fragment_is_exempt(ctx, fragment) {
855 continue;
856 }
857
858 if !self.fragment_resolves(fragment, &anchors) {
859 warnings.push(LintWarning {
860 rule_name: Some(self.name().to_string()),
861 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
862 line: link.line,
863 column: link.start_col + 1,
864 end_line: link.end_line,
865 end_column: link.end_col + 1,
866 severity: Severity::Error,
867 fix: None,
868 });
869 }
870 }
871
872 self.check_front_matter(ctx, &front_matter_links, &anchors, &mut warnings);
873
874 Ok(warnings)
875 }
876
877 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
878 Ok(ctx.content.to_string())
881 }
882
883 fn as_any(&self) -> &dyn std::any::Any {
884 self
885 }
886
887 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
888 where
889 Self: Sized,
890 {
891 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD051Config>(config);
892
893 let explicit_style_present = config
898 .rules
899 .get("MD051")
900 .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
901 if !explicit_style_present {
902 rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
903 }
904
905 Box::new(MD051LinkFragments::build(
906 rule_config,
907 config.withheld_rule_values.contains("MD051"),
908 explicit_style_present,
909 ))
910 }
911
912 fn category(&self) -> RuleCategory {
913 RuleCategory::Link
914 }
915
916 fn skippable_by_category(&self) -> bool {
917 !self.config.check_frontmatter
920 }
921
922 fn cross_file_scope(&self) -> CrossFileScope {
923 CrossFileScope::Workspace
924 }
925
926 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
927 let mut fragment_counts = HashMap::new();
928 let anchor_style = self.anchor_style(ctx);
929 let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
930
931 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
933 if line_info.in_front_matter {
934 continue;
935 }
936
937 if line_info.in_code_block {
939 continue;
940 }
941
942 let content = line_info.content(ctx.content);
943
944 for_each_html_anchor_target(ctx, line_info, |id| file_index.add_html_anchor(id));
945
946 let parsed_heading = ctx.heading_on_line(line_idx + 1);
951 if parsed_heading.is_none()
952 && !line_info.is_setext_heading_text
953 && content.contains('{')
954 && content.contains('#')
955 {
956 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
957 if let Some(id_match) = caps.get(1) {
958 file_index.add_attribute_anchor(id_match.as_str());
959 }
960 }
961 }
962
963 if let Some(parsed) = parsed_heading {
965 let heading = parsed.heading;
966 let fragment = anchor_style.generate_fragment(&heading.slug_text);
967
968 Self::add_heading_to_index(
969 &fragment,
970 &heading.text,
971 heading.custom_id.clone(),
972 line_idx + 1,
973 heading.text_lines,
974 parsed.is_setext(),
975 &mut fragment_counts,
976 file_index,
977 use_underscore_dedup,
978 );
979
980 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
985 && let Some(caps) = MD_SETTING_PATTERN.captures(content)
986 && let Some(name) = caps.get(1)
987 {
988 file_index.add_html_anchor(name.as_str());
989 }
990 }
991 }
992
993 for link in ctx.links() {
995 if link.is_reference {
996 continue;
997 }
998
999 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
1001 continue;
1002 }
1003
1004 if matches!(link.link_type, LinkType::WikiLink { .. }) {
1007 continue;
1008 }
1009
1010 let url = &link.url;
1011
1012 if Self::is_external_url_fast(url) {
1014 continue;
1015 }
1016
1017 if Self::is_cross_file_link(url)
1019 && let Some(fragment_pos) = url.find('#')
1020 {
1021 let path_part = &url[..fragment_pos];
1022 let fragment = &url[fragment_pos + 1..];
1023
1024 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1026 continue;
1027 }
1028
1029 file_index.add_cross_file_link(CrossFileLinkIndex {
1030 target_path: path_part.to_string(),
1031 fragment: fragment.to_string(),
1032 line: link.line,
1033 column: link.start_col + 1,
1034 origin: LinkOrigin::Body,
1035 });
1036 }
1037 }
1038
1039 for link in frontmatter_values::link_destinations(ctx) {
1046 let line = ctx.lines[link.line - 1].content(ctx.content);
1047 let value = &line[link.range.clone()];
1048
1049 if Self::is_external_url_fast(value) || !Self::is_cross_file_link(value) {
1050 continue;
1051 }
1052
1053 let Some(fragment_pos) = value.find('#') else {
1054 continue;
1055 };
1056 let path_part = &value[..fragment_pos];
1057 let fragment = &value[fragment_pos + 1..];
1058
1059 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1061 continue;
1062 }
1063
1064 file_index.add_cross_file_link(CrossFileLinkIndex {
1065 target_path: path_part.to_string(),
1066 fragment: fragment.to_string(),
1067 line: link.line,
1068 column: byte_to_char_count(line, link.range.start),
1069 origin: LinkOrigin::FrontMatter { field: link.field },
1070 });
1071 }
1072 }
1073
1074 fn cross_file_check(
1075 &self,
1076 file_path: &Path,
1077 file_index: &FileIndex,
1078 workspace_index: &crate::workspace_index::WorkspaceIndex,
1079 ) -> LintResult {
1080 let mut warnings = Vec::new();
1081
1082 let ignored_pattern = self.ignored_pattern_regex.as_ref();
1083 let ignore_case = self.config.ignore_case;
1084
1085 for cross_link in &file_index.cross_file_links {
1087 if cross_link.fragment.is_empty() {
1089 continue;
1090 }
1091
1092 if !self.reports_link_from(&cross_link.origin) {
1095 continue;
1096 }
1097
1098 if ignored_pattern.is_some_and(|re| re.is_match(&cross_link.fragment)) {
1100 continue;
1101 }
1102
1103 let target_paths_to_try =
1106 crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path);
1107
1108 let mut target_file_index = None;
1110
1111 for target_path in &target_paths_to_try {
1112 if let Some(index) = workspace_index.get_file(target_path) {
1113 target_file_index = Some(index);
1114 break;
1115 }
1116 }
1117
1118 if let Some(target_file_index) = target_file_index {
1119 if !target_file_index.has_anchor_with_case(&cross_link.fragment, ignore_case) {
1121 warnings.push(LintWarning {
1122 rule_name: Some(self.name().to_string()),
1123 line: cross_link.line,
1124 column: cross_link.column,
1125 end_line: cross_link.line,
1126 end_column: cross_link.column
1127 + cross_link.target_path.chars().count()
1128 + 1
1129 + cross_link.fragment.chars().count(),
1130 message: format!(
1131 "Link fragment '{}' not found in '{}'",
1132 cross_link.fragment, cross_link.target_path
1133 ),
1134 severity: Severity::Error,
1135 fix: None,
1136 });
1137 }
1138 }
1139 }
1145
1146 Ok(warnings)
1147 }
1148
1149 crate::impl_rule_config_sections!(MD051Config);
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154 use super::*;
1155 use crate::lint_context::LintContext;
1156 use std::path::PathBuf;
1157
1158 const ANCHOR_STYLE_PROBE: &str = "### Getting Started — Advanced\n\n\
1162 [python-markdown slug](#getting-started-advanced)\n\
1163 [github slug](#getting-started--advanced)\n";
1164
1165 fn flagged_fragment(rule: &dyn Rule, flavor: crate::config::MarkdownFlavor) -> String {
1166 let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
1167 let warnings = rule.check(&ctx).unwrap();
1168 assert_eq!(
1169 warnings.len(),
1170 1,
1171 "exactly one of the two links must be invalid under any style: {warnings:?}"
1172 );
1173 warnings[0].message.clone()
1174 }
1175
1176 #[test]
1180 fn test_unpinned_anchor_style_follows_the_file_flavor() {
1181 let rule_from_global = |flavor| {
1182 let mut config = crate::config::Config::default();
1183 config.global.flavor = flavor;
1184 MD051LinkFragments::from_config(&config)
1185 };
1186
1187 let standard_global = rule_from_global(crate::config::MarkdownFlavor::Standard);
1189 assert!(
1192 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1193 .contains("#getting-started-advanced'"),
1194 "a standard file must be checked against GitHub anchors"
1195 );
1196 assert!(
1199 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1200 .contains("#getting-started--advanced'"),
1201 "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
1202 );
1203
1204 let mkdocs_global = rule_from_global(crate::config::MarkdownFlavor::MkDocs);
1206 assert!(
1207 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1208 .contains("#getting-started--advanced'"),
1209 "a mkdocs file must be checked against Python-Markdown anchors"
1210 );
1211 assert!(
1212 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1213 .contains("#getting-started-advanced'"),
1214 "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
1215 );
1216 }
1217
1218 #[test]
1221 fn test_pinned_anchor_style_ignores_the_file_flavor() {
1222 let mut config = crate::config::Config::default();
1223 config.global.flavor = crate::config::MarkdownFlavor::Standard;
1224 let mut rule_config = crate::config::RuleConfig::default();
1225 rule_config
1226 .values
1227 .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
1228 config.rules.insert("MD051".to_string(), rule_config);
1229 let rule = MD051LinkFragments::from_config(&config);
1230
1231 for flavor in [
1232 crate::config::MarkdownFlavor::Standard,
1233 crate::config::MarkdownFlavor::MkDocs,
1234 crate::config::MarkdownFlavor::Kramdown,
1235 ] {
1236 assert!(
1237 flagged_fragment(rule.as_ref(), flavor).contains("#getting-started-advanced'"),
1238 "pinned github anchors must survive a {flavor:?} file"
1239 );
1240 }
1241 }
1242
1243 #[test]
1246 fn test_directly_constructed_rule_keeps_its_anchor_style() {
1247 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1248 anchor_style: AnchorStyle::PythonMarkdown,
1249 ..Default::default()
1250 });
1251 assert!(
1252 flagged_fragment(&rule, crate::config::MarkdownFlavor::Standard).contains("#getting-started--advanced'"),
1253 "an explicitly constructed Python-Markdown rule must not follow the file flavor"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_quarto_cross_references() {
1259 let rule = MD051LinkFragments::new();
1260
1261 let content = r#"# Test Document
1263
1264## Figures
1265
1266See [@fig-plot] for the visualization.
1267
1268More details in [@tbl-results] and [@sec-methods].
1269
1270The equation [@eq-regression] shows the relationship.
1271
1272Reference to [@lst-code] for implementation."#;
1273 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1274 let result = rule.check(&ctx).unwrap();
1275 assert!(
1276 result.is_empty(),
1277 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
1278 result.len()
1279 );
1280
1281 let content_with_anchor = r#"# Test
1283
1284See [link](#test) for details."#;
1285 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
1286 let result_anchor = rule.check(&ctx_anchor).unwrap();
1287 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
1288
1289 let content_invalid = r#"# Test
1291
1292See [link](#nonexistent) for details."#;
1293 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
1294 let result_invalid = rule.check(&ctx_invalid).unwrap();
1295 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
1296 }
1297
1298 #[test]
1299 fn test_jsx_in_heading_anchor() {
1300 let rule = MD051LinkFragments::new();
1302
1303 let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
1305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306 let result = rule.check(&ctx).unwrap();
1307 assert!(
1308 result.is_empty(),
1309 "JSX self-closing tag should be stripped from anchor: got {result:?}"
1310 );
1311
1312 let content2 =
1314 "### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
1315 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1316 let result2 = rule.check(&ctx2).unwrap();
1317 assert!(
1318 result2.is_empty(),
1319 "JSX tag with attributes should be stripped from anchor: got {result2:?}"
1320 );
1321
1322 let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
1324 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1325 let result3 = rule.check(&ctx3).unwrap();
1326 assert!(
1327 result3.is_empty(),
1328 "HTML tag content should be preserved in anchor: got {result3:?}"
1329 );
1330 }
1331
1332 #[test]
1334 fn test_cross_file_scope() {
1335 let rule = MD051LinkFragments::new();
1336 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1337 }
1338
1339 #[test]
1340 fn test_contribute_to_index_extracts_headings() {
1341 let rule = MD051LinkFragments::new();
1342 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344
1345 let mut file_index = FileIndex::new();
1346 rule.contribute_to_index(&ctx, &mut file_index);
1347
1348 assert_eq!(file_index.headings.len(), 3);
1349 assert_eq!(file_index.headings[0].text, "First Heading");
1350 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
1351 assert!(file_index.headings[0].custom_anchor.is_none());
1352
1353 assert_eq!(file_index.headings[1].text, "Second");
1354 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
1355
1356 assert_eq!(file_index.headings[2].text, "Third");
1357 }
1358
1359 #[test]
1360 fn test_contribute_to_index_extracts_cross_file_links() {
1361 let rule = MD051LinkFragments::new();
1362 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
1363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1364
1365 let mut file_index = FileIndex::new();
1366 rule.contribute_to_index(&ctx, &mut file_index);
1367
1368 assert_eq!(file_index.cross_file_links.len(), 2);
1369 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1370 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
1371 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
1372 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
1373 }
1374
1375 #[test]
1379 fn test_contribute_to_index_records_setext_headings() {
1380 let rule = MD051LinkFragments::new();
1381 let content = "Setext One\n==========\n\nSetext Two\n----------\n\n### Atx Three\n";
1382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1383
1384 let mut file_index = FileIndex::new();
1385 rule.contribute_to_index(&ctx, &mut file_index);
1386
1387 let styles: Vec<(&str, bool)> = file_index
1388 .headings
1389 .iter()
1390 .map(|h| (h.text.as_str(), h.is_setext))
1391 .collect();
1392 assert_eq!(
1393 styles,
1394 vec![("Setext One", true), ("Setext Two", true), ("Atx Three", false)]
1395 );
1396 }
1397
1398 #[test]
1406 fn test_a_frontmatter_link_is_indexed_regardless_of_the_indexing_config() {
1407 let content = "---\nlink: 'other.md#nope'\n---\n\n# Real\n";
1408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1409
1410 for check_frontmatter in [true, false] {
1411 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1412 check_frontmatter,
1413 ..Default::default()
1414 });
1415 let mut file_index = FileIndex::new();
1416 rule.contribute_to_index(&ctx, &mut file_index);
1417
1418 assert_eq!(
1419 file_index.cross_file_links.len(),
1420 1,
1421 "check_frontmatter = {check_frontmatter} changed what was indexed"
1422 );
1423 assert_eq!(
1424 file_index.cross_file_links[0].origin,
1425 LinkOrigin::FrontMatter {
1426 field: Some("link".to_string())
1427 },
1428 );
1429 }
1430 }
1431
1432 #[test]
1436 fn test_cross_file_check_applies_this_files_frontmatter_config() {
1437 use crate::workspace_index::WorkspaceIndex;
1438
1439 let mut workspace_index = WorkspaceIndex::new();
1440 let mut target = FileIndex::new();
1441 target.add_heading(HeadingIndex {
1442 text: "Real".to_string(),
1443 auto_anchor: "real".to_string(),
1444 custom_anchor: None,
1445 line: 1,
1446 text_lines: 1,
1447 is_setext: false,
1448 });
1449 workspace_index.insert_file(PathBuf::from("docs/other.md"), target);
1450
1451 let mut file_index = FileIndex::new();
1452 file_index.add_cross_file_link(CrossFileLinkIndex {
1453 target_path: "other.md".to_string(),
1454 fragment: "nope".to_string(),
1455 line: 2,
1456 column: 7,
1457 origin: LinkOrigin::FrontMatter {
1458 field: Some("link".to_string()),
1459 },
1460 });
1461 file_index.add_cross_file_link(CrossFileLinkIndex {
1465 target_path: "other.md".to_string(),
1466 fragment: "nope".to_string(),
1467 line: 6,
1468 column: 5,
1469 origin: LinkOrigin::Body,
1470 });
1471
1472 let count = |config: MD051Config| {
1473 MD051LinkFragments::from_config_struct(config)
1474 .cross_file_check(Path::new("docs/readme.md"), &file_index, &workspace_index)
1475 .unwrap()
1476 .len()
1477 };
1478
1479 assert_eq!(
1480 count(MD051Config {
1481 check_frontmatter: true,
1482 ..Default::default()
1483 }),
1484 2,
1485 "checking frontmatter should report both the frontmatter and body links"
1486 );
1487 assert_eq!(
1488 count(MD051Config {
1489 check_frontmatter: false,
1490 ..Default::default()
1491 }),
1492 1,
1493 "not checking frontmatter should leave only the body link"
1494 );
1495 assert_eq!(
1496 count(MD051Config {
1497 check_frontmatter: true,
1498 ignore_frontmatter_fields: vec!["LINK".to_string()],
1499 ..Default::default()
1500 }),
1501 1,
1502 "an ignored field should be matched case-insensitively"
1503 );
1504 }
1505
1506 #[test]
1507 fn test_cross_file_check_valid_fragment() {
1508 use crate::workspace_index::WorkspaceIndex;
1509
1510 let rule = MD051LinkFragments::new();
1511
1512 let mut workspace_index = WorkspaceIndex::new();
1514 let mut target_file_index = FileIndex::new();
1515 target_file_index.add_heading(HeadingIndex {
1516 text: "Installation Guide".to_string(),
1517 auto_anchor: "installation-guide".to_string(),
1518 custom_anchor: None,
1519 line: 1,
1520 text_lines: 1,
1521 is_setext: false,
1522 });
1523 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1524
1525 let mut current_file_index = FileIndex::new();
1527 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1528 target_path: "install.md".to_string(),
1529 fragment: "installation-guide".to_string(),
1530 line: 3,
1531 column: 5,
1532 origin: LinkOrigin::Body,
1533 });
1534
1535 let warnings = rule
1536 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1537 .unwrap();
1538
1539 assert!(warnings.is_empty());
1541 }
1542
1543 #[test]
1544 fn test_cross_file_check_invalid_fragment() {
1545 use crate::workspace_index::WorkspaceIndex;
1546
1547 let rule = MD051LinkFragments::new();
1548
1549 let mut workspace_index = WorkspaceIndex::new();
1551 let mut target_file_index = FileIndex::new();
1552 target_file_index.add_heading(HeadingIndex {
1553 text: "Installation Guide".to_string(),
1554 auto_anchor: "installation-guide".to_string(),
1555 custom_anchor: None,
1556 line: 1,
1557 text_lines: 1,
1558 is_setext: false,
1559 });
1560 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1561
1562 let mut current_file_index = FileIndex::new();
1564 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1565 target_path: "install.md".to_string(),
1566 fragment: "nonexistent".to_string(),
1567 line: 3,
1568 column: 5,
1569 origin: LinkOrigin::Body,
1570 });
1571
1572 let warnings = rule
1573 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1574 .unwrap();
1575
1576 assert_eq!(warnings.len(), 1);
1578 assert!(warnings[0].message.contains("nonexistent"));
1579 assert!(warnings[0].message.contains("install.md"));
1580 }
1581
1582 #[test]
1583 fn test_cross_file_check_custom_anchor_match() {
1584 use crate::workspace_index::WorkspaceIndex;
1585
1586 let rule = MD051LinkFragments::new();
1587
1588 let mut workspace_index = WorkspaceIndex::new();
1590 let mut target_file_index = FileIndex::new();
1591 target_file_index.add_heading(HeadingIndex {
1592 text: "Installation Guide".to_string(),
1593 auto_anchor: "installation-guide".to_string(),
1594 custom_anchor: Some("install".to_string()),
1595 line: 1,
1596 text_lines: 1,
1597 is_setext: false,
1598 });
1599 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1600
1601 let mut current_file_index = FileIndex::new();
1603 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1604 target_path: "install.md".to_string(),
1605 fragment: "install".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_cross_file_check_target_not_in_workspace() {
1621 use crate::workspace_index::WorkspaceIndex;
1622
1623 let rule = MD051LinkFragments::new();
1624
1625 let workspace_index = WorkspaceIndex::new();
1627
1628 let mut current_file_index = FileIndex::new();
1630 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1631 target_path: "external.md".to_string(),
1632 fragment: "heading".to_string(),
1633 line: 3,
1634 column: 5,
1635 origin: LinkOrigin::Body,
1636 });
1637
1638 let warnings = rule
1639 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1640 .unwrap();
1641
1642 assert!(warnings.is_empty());
1644 }
1645
1646 #[test]
1647 fn test_wikilinks_skipped_in_check() {
1648 let rule = MD051LinkFragments::new();
1650
1651 let content = r#"# Test Document
1652
1653## Valid Heading
1654
1655[[Microsoft#Windows OS]]
1656[[SomePage#section]]
1657[[page|Display Text]]
1658[[path/to/page#section]]
1659"#;
1660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1661 let result = rule.check(&ctx).unwrap();
1662
1663 assert!(
1664 result.is_empty(),
1665 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_wikilinks_not_added_to_cross_file_index() {
1671 let rule = MD051LinkFragments::new();
1673
1674 let content = r#"# Test Document
1675
1676[[Microsoft#Windows OS]]
1677[[SomePage#section]]
1678[Regular Link](other.md#section)
1679"#;
1680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681
1682 let mut file_index = FileIndex::new();
1683 rule.contribute_to_index(&ctx, &mut file_index);
1684
1685 let cross_file_links = &file_index.cross_file_links;
1688 assert_eq!(
1689 cross_file_links.len(),
1690 1,
1691 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1692 );
1693 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1694 assert_eq!(file_index.cross_file_links[0].fragment, "section");
1695 }
1696
1697 #[test]
1698 fn test_pandoc_flavor_skips_citations() {
1699 let rule = MD051LinkFragments::new();
1703 let content = "# Test Document\n\nSee [@smith2020] for details.\n";
1704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1705 let result = rule.check(&ctx).unwrap();
1706 assert!(
1707 result.is_empty(),
1708 "MD051 should skip Pandoc citations under Pandoc flavor: {result:?}"
1709 );
1710 }
1711
1712 #[test]
1713 fn md051_pandoc_resolves_pandoc_slug_diverging_from_github() {
1714 use crate::config::MarkdownFlavor;
1721 let rule = MD051LinkFragments::new();
1722 let content = "# 5. Five Things\n\nSee [details](#5.-five-things).\n";
1723
1724 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1727 let std_result = rule.check(&ctx_std).unwrap();
1728 assert_eq!(
1729 std_result.len(),
1730 1,
1731 "Standard flavor should flag the Pandoc-style fragment: {std_result:?}"
1732 );
1733
1734 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1736 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1737 assert!(
1738 pandoc_result.is_empty(),
1739 "Pandoc flavor should resolve `#5.-five-things` against the heading slug: {pandoc_result:?}"
1740 );
1741 }
1742
1743 #[test]
1747 fn md051_pandoc_flags_missing_fragment_with_email_in_link_text() {
1748 use crate::config::MarkdownFlavor;
1749 let rule = MD051LinkFragments::new();
1750 let content = "# Title\n\n[contact user@example.com](#missing)\n";
1751
1752 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1753 let std_result = rule.check(&ctx_std).unwrap();
1754 assert_eq!(
1755 std_result.len(),
1756 1,
1757 "Standard flavor must flag the missing fragment: {std_result:?}"
1758 );
1759
1760 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1761 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1762 assert_eq!(
1763 pandoc_result.len(),
1764 1,
1765 "Pandoc flavor must also flag the missing fragment — link text with embedded email is not a citation: {pandoc_result:?}"
1766 );
1767 }
1768
1769 #[test]
1773 fn md051_pandoc_flags_missing_fragment_with_citation_in_link_text() {
1774 use crate::config::MarkdownFlavor;
1775 let rule = MD051LinkFragments::new();
1776 let content = "# Title\n\n[see @smith2020](#missing)\n";
1777
1778 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1779 let std_result = rule.check(&ctx_std).unwrap();
1780 assert_eq!(
1781 std_result.len(),
1782 1,
1783 "Standard flavor must flag the missing fragment: {std_result:?}"
1784 );
1785
1786 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1787 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1788 assert_eq!(
1789 pandoc_result.len(),
1790 1,
1791 "Pandoc flavor must flag the missing fragment — `[label](url)` is a link, not a citation: {pandoc_result:?}"
1792 );
1793 }
1794
1795 #[test]
1799 fn md051_pandoc_resolves_duplicate_heading_suffix_slug() {
1800 use crate::config::MarkdownFlavor;
1801 let rule = MD051LinkFragments::new();
1802 let content = "# A.\n\nfirst\n\n# A.\n\nsecond\n\n[first](#a.) and [second](#a.-1).\n";
1803
1804 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1805 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1806 assert!(
1807 pandoc_result.is_empty(),
1808 "Pandoc flavor should resolve `#a.` and `#a.-1` against duplicate headings: {pandoc_result:?}"
1809 );
1810
1811 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1812 let quarto_result = rule.check(&ctx_quarto).unwrap();
1813 assert!(
1814 quarto_result.is_empty(),
1815 "Quarto flavor should also resolve duplicate-heading suffix slugs: {quarto_result:?}"
1816 );
1817 }
1818
1819 #[test]
1822 fn md051_pandoc_flags_overshoot_duplicate_suffix() {
1823 use crate::config::MarkdownFlavor;
1824 let rule = MD051LinkFragments::new();
1825 let content = "# A.\n\n# A.\n\n[overshoot](#a.-2)\n";
1826
1827 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1828 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1829 assert_eq!(
1830 pandoc_result.len(),
1831 1,
1832 "Pandoc must flag `#a.-2` when only `-1` exists (two duplicates): {pandoc_result:?}"
1833 );
1834 }
1835
1836 fn front_matter_checked() -> MD051Config {
1837 MD051Config {
1838 check_frontmatter: true,
1839 ..MD051Config::default()
1840 }
1841 }
1842
1843 fn check_front_matter(content: &str, config: MD051Config) -> Vec<LintWarning> {
1844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1845 MD051LinkFragments::from_config_struct(config).check(&ctx).unwrap()
1846 }
1847
1848 #[test]
1849 fn a_broken_frontmatter_fragment_is_reported_when_enabled() {
1850 let content = "---\nanchor: '#missing'\nvalid: '#title'\n---\n\n# Title\n";
1851 let result = check_front_matter(content, front_matter_checked());
1852
1853 assert_eq!(
1854 result.len(),
1855 1,
1856 "Only the unresolved fragment is reported. Got: {result:?}"
1857 );
1858 assert_eq!(
1859 result[0].message,
1860 "Link anchor '#missing' does not exist in document headings"
1861 );
1862 assert_eq!(result[0].line, 2);
1863 assert_eq!(result[0].column, 10, "The warning points at the value, not the key");
1864 assert_eq!(result[0].end_column, 18);
1865 }
1866
1867 #[test]
1868 fn frontmatter_fragments_are_not_checked_by_default() {
1869 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
1870 let result = check_front_matter(content, MD051Config::default());
1871
1872 assert!(
1873 result.is_empty(),
1874 "Frontmatter is only checked on request. Got: {result:?}"
1875 );
1876 }
1877
1878 #[test]
1879 fn an_ignored_frontmatter_field_is_not_checked() {
1880 let content = "---\nhero: '#missing'\nanchor: '#other'\n---\n\n# Title\n";
1881 let config = MD051Config {
1882 check_frontmatter: true,
1883 ignore_frontmatter_fields: vec!["Hero".to_string()],
1884 ..MD051Config::default()
1885 };
1886 let result = check_front_matter(content, config);
1887
1888 assert_eq!(
1889 result.len(),
1890 1,
1891 "The ignored field is skipped and the other is not. Got: {result:?}"
1892 );
1893 assert_eq!(result[0].line, 3);
1894 }
1895
1896 #[test]
1897 fn the_ignored_pattern_applies_to_frontmatter_fragments() {
1898 let content = "---\nnote: '#fn:1'\nanchor: '#missing'\n---\n\n# Title\n";
1899 let config = MD051Config {
1900 check_frontmatter: true,
1901 ignored_pattern: Some("^fn:".to_string()),
1902 ..MD051Config::default()
1903 };
1904 let result = check_front_matter(content, config);
1905
1906 assert_eq!(
1907 result.len(),
1908 1,
1909 "The matching fragment is skipped and the other is not. Got: {result:?}"
1910 );
1911 assert_eq!(result[0].line, 3);
1912 }
1913
1914 #[test]
1915 fn a_frontmatter_fragment_honors_ignore_case() {
1916 let content = "---\nanchor: '#Title'\n---\n\n# Title\n";
1917
1918 let permissive = check_front_matter(content, front_matter_checked());
1919 assert!(
1920 permissive.is_empty(),
1921 "The default resolves a case mismatch. Got: {permissive:?}"
1922 );
1923
1924 let strict = check_front_matter(
1925 content,
1926 MD051Config {
1927 check_frontmatter: true,
1928 ignore_case: false,
1929 ..MD051Config::default()
1930 },
1931 );
1932 assert_eq!(strict.len(), 1, "Strict matching reports it. Got: {strict:?}");
1933 }
1934
1935 #[test]
1936 fn prose_in_frontmatter_is_not_read_as_a_fragment() {
1937 let content = "---\ntitle: Node.js\ntags: ci/cd\n---\n\n# Title\n";
1938 let result = check_front_matter(content, front_matter_checked());
1939
1940 assert!(
1941 result.is_empty(),
1942 "Only path-shaped values are destinations. Got: {result:?}"
1943 );
1944 }
1945
1946 #[test]
1947 fn a_frontmatter_path_with_a_fragment_is_validated_across_files() {
1948 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1949 let source = "---\ntemplate: other.md#missing\nvalid: other.md#target\n---\n\n# Source\n";
1950
1951 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1952 let mut source_index = FileIndex::default();
1953 rule.contribute_to_index(&source_ctx, &mut source_index);
1954
1955 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1956 let mut target_index = FileIndex::default();
1957 rule.contribute_to_index(&target_ctx, &mut target_index);
1958
1959 let source_path = PathBuf::from("docs/source.md");
1960 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1961 workspace.insert_file(source_path.clone(), source_index.clone());
1962 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1963
1964 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1965
1966 assert_eq!(
1967 warnings.len(),
1968 1,
1969 "Only the unresolved fragment is reported. Got: {warnings:?}"
1970 );
1971 assert_eq!(warnings[0].message, "Link fragment 'missing' not found in 'other.md'");
1972 assert_eq!(warnings[0].line, 2);
1973 assert_eq!(warnings[0].column, 11);
1974 }
1975
1976 #[test]
1977 fn a_query_string_does_not_hide_the_target_file() {
1978 let rule = MD051LinkFragments::new();
1979 let source = "# Source\n\n- [a](other.md?raw=true#missing)\n- [b](other.md?raw=true#target)\n";
1980
1981 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1982 let mut source_index = FileIndex::default();
1983 rule.contribute_to_index(&source_ctx, &mut source_index);
1984
1985 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1986 let mut target_index = FileIndex::default();
1987 rule.contribute_to_index(&target_ctx, &mut target_index);
1988
1989 let source_path = PathBuf::from("docs/source.md");
1990 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1991 workspace.insert_file(source_path.clone(), source_index.clone());
1992 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1993
1994 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1995
1996 assert_eq!(
1997 warnings.len(),
1998 1,
1999 "The query is stripped to find the file, so both fragments resolve against it. Got: {warnings:?}"
2000 );
2001 assert_eq!(
2002 warnings[0].message,
2003 "Link fragment 'missing' not found in 'other.md?raw=true'"
2004 );
2005 assert_eq!(warnings[0].line, 3);
2006 }
2007
2008 #[test]
2009 fn a_query_string_does_not_hide_an_extensionless_target_file() {
2010 let rule = MD051LinkFragments::new();
2011 let source = "# Source\n\n- [a](other?raw=true#target)\n- [b](other#target)\n- [c](other?raw=true#absent)\n";
2012
2013 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2014 let same_document = rule.check(&source_ctx).unwrap();
2015 assert!(
2016 same_document.is_empty(),
2017 "Every fragment here belongs to another file, so none is a missing anchor of this one. Got: {same_document:?}"
2018 );
2019
2020 let mut source_index = FileIndex::default();
2021 rule.contribute_to_index(&source_ctx, &mut source_index);
2022
2023 let target_ctx = LintContext::new("# Other\n\n## Target\n", crate::config::MarkdownFlavor::Standard, None);
2024 let mut target_index = FileIndex::default();
2025 rule.contribute_to_index(&target_ctx, &mut target_index);
2026
2027 let source_path = PathBuf::from("docs/source.md");
2028 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
2029 workspace.insert_file(source_path.clone(), source_index.clone());
2030 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
2031
2032 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
2033
2034 assert_eq!(
2035 warnings.len(),
2036 1,
2037 "The query is stripped before the markdown extension is added. Got: {warnings:?}"
2038 );
2039 assert_eq!(
2040 warnings[0].message,
2041 "Link fragment 'absent' not found in 'other?raw=true'"
2042 );
2043 assert_eq!(warnings[0].line, 5);
2044 }
2045
2046 #[test]
2047 fn a_destination_that_is_only_a_query_stays_on_this_page() {
2048 let rule = MD051LinkFragments::new();
2049 let source = "# Source\n\n## Here\n\n- [a](?raw=true#here)\n- [b](?raw=true#nowhere)\n";
2050
2051 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2052 let warnings = rule.check(&source_ctx).unwrap();
2053
2054 assert_eq!(
2055 warnings.len(),
2056 1,
2057 "Only the absent anchor is reported. Got: {warnings:?}"
2058 );
2059 assert_eq!(
2060 warnings[0].message,
2061 "Link anchor '#nowhere' does not exist in document headings"
2062 );
2063 assert_eq!(warnings[0].line, 6);
2064
2065 let mut source_index = FileIndex::default();
2066 rule.contribute_to_index(&source_ctx, &mut source_index);
2067 assert!(
2068 source_index.cross_file_links.is_empty(),
2069 "A query with no path names no other file. Got: {:?}",
2070 source_index.cross_file_links
2071 );
2072 }
2073
2074 #[test]
2075 fn blockquote_syntax_inside_raw_html_does_not_create_an_anchor() {
2076 let rule = MD051LinkFragments::new();
2077 let source = "<div>\n> ## Hidden\n</div>\n\n[link](#hidden)\n";
2078 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2079
2080 let warnings = rule.check(&ctx).unwrap();
2081 assert_eq!(
2082 warnings.len(),
2083 1,
2084 "raw HTML must not satisfy the fragment: {warnings:?}"
2085 );
2086
2087 let mut file_index = FileIndex::default();
2088 rule.contribute_to_index(&ctx, &mut file_index);
2089 assert!(
2090 file_index.headings.is_empty(),
2091 "raw HTML must not enter the workspace index"
2092 );
2093 }
2094
2095 #[test]
2096 fn a_frontmatter_path_carrying_a_query_is_indexed() {
2097 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
2098 let source = "---\ntemplate: docs/other.md?raw=true#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
2104 assert_eq!(source_index.cross_file_links.len(), 1);
2105 assert_eq!(source_index.cross_file_links[0].target_path, "docs/other.md?raw=true");
2106 assert_eq!(source_index.cross_file_links[0].fragment, "missing");
2107 }
2108
2109 #[test]
2113 fn frontmatter_cross_file_paths_are_not_reported_by_default() {
2114 use crate::workspace_index::WorkspaceIndex;
2115
2116 let rule = MD051LinkFragments::new();
2117 let source = "---\ntemplate: other.md#missing\n---\n\n# Source\n";
2118
2119 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2120 let mut source_index = FileIndex::default();
2121 rule.contribute_to_index(&source_ctx, &mut source_index);
2122 assert_eq!(source_index.cross_file_links.len(), 1);
2123
2124 let mut workspace_index = WorkspaceIndex::new();
2125 let mut target = FileIndex::new();
2126 target.add_heading(HeadingIndex {
2127 text: "Present".to_string(),
2128 auto_anchor: "present".to_string(),
2129 custom_anchor: None,
2130 line: 1,
2131 text_lines: 1,
2132 is_setext: false,
2133 });
2134 workspace_index.insert_file(PathBuf::from("other.md"), target);
2135
2136 let warnings = rule
2137 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2138 .unwrap();
2139 assert!(
2140 warnings.is_empty(),
2141 "Frontmatter is only checked on request. Got: {warnings:?}"
2142 );
2143
2144 let checking = MD051LinkFragments::from_config_struct(MD051Config {
2148 check_frontmatter: true,
2149 ..Default::default()
2150 });
2151 assert_eq!(
2152 checking
2153 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2154 .unwrap()
2155 .len(),
2156 1
2157 );
2158 }
2159}