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.slug_text);
449
450 Self::insert_deduplicated_fragment(
451 fragment,
452 &mut fragment_counts,
453 &mut markdown_headings,
454 track_exact.then_some(&mut markdown_headings_exact),
455 use_underscore_dedup,
456 );
457 }
458 }
459
460 AnchorSets {
461 markdown_headings,
462 markdown_headings_exact,
463 html_anchors,
464 html_anchors_exact,
465 }
466 }
467
468 #[inline]
470 fn is_external_url_fast(url: &str) -> bool {
471 url.starts_with("http://")
473 || url.starts_with("https://")
474 || url.starts_with("ftp://")
475 || url.starts_with("mailto:")
476 || url.starts_with("tel:")
477 || url.starts_with("//")
478 }
479
480 #[inline]
494 fn is_extensionless_path(path_part: &str) -> bool {
495 if path_part.is_empty() || path_part.contains('.') || path_part.contains('&') || path_part.contains('=') {
497 return false;
498 }
499
500 let mut has_alphanumeric = false;
502 for c in path_part.chars() {
503 if c.is_alphanumeric() {
504 has_alphanumeric = true;
505 } else if !matches!(c, '/' | '\\' | '-' | '_') {
506 return false;
508 }
509 }
510
511 has_alphanumeric
513 }
514
515 #[inline]
517 fn is_cross_file_link(url: &str) -> bool {
518 if let Some(fragment_pos) = url.find('#') {
519 let path_part = &url[..fragment_pos];
520
521 if path_part.is_empty() {
523 return false;
524 }
525
526 if let Some(tag_start) = path_part.find("{%")
532 && path_part[tag_start + 2..].contains("%}")
533 {
534 return true;
535 }
536 if let Some(var_start) = path_part.find("{{")
537 && path_part[var_start + 2..].contains("}}")
538 {
539 return true;
540 }
541
542 if path_part.starts_with('/') {
545 return true;
546 }
547
548 let path_part = path_part.split('?').next().unwrap_or(path_part);
551
552 if path_part.is_empty() {
554 return false;
555 }
556
557 let has_extension = path_part.contains('.')
563 && (
564 {
566 if let Some(after_dot) = path_part.strip_prefix('.') {
568 let dots_count = path_part.matches('.').count();
569 if dots_count == 1 {
570 !after_dot.is_empty() && after_dot.len() <= 10 &&
573 after_dot.chars().all(|c| c.is_ascii_alphanumeric())
574 } else {
575 path_part.split('.').next_back().is_some_and(|ext| {
577 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
578 })
579 }
580 } else {
581 path_part.split('.').next_back().is_some_and(|ext| {
583 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
584 })
585 }
586 } ||
587 path_part.contains('/') || path_part.contains('\\') ||
589 path_part.starts_with("./") || path_part.starts_with("../")
591 );
592
593 let is_extensionless = Self::is_extensionless_path(path_part);
596
597 has_extension || is_extensionless
598 } else {
599 false
600 }
601 }
602
603 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
608 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
609 }
610
611 fn front_matter_links(&self, ctx: &crate::lint_context::LintContext) -> Vec<frontmatter_values::FrontMatterLink> {
614 if !self.checks_front_matter_of(ctx) {
615 return Vec::new();
616 }
617 frontmatter_values::link_destinations(ctx)
618 .into_iter()
619 .filter(|link| !link.field_is_in(&self.ignored_front_matter_fields))
620 .collect()
621 }
622
623 fn reports_link_from(&self, origin: &LinkOrigin) -> bool {
630 match origin {
631 LinkOrigin::Body => true,
632 LinkOrigin::FrontMatter { field } => {
633 self.config.check_frontmatter
634 && !field
635 .as_ref()
636 .is_some_and(|field| self.ignored_front_matter_fields.contains(field))
637 }
638 }
639 }
640
641 fn fragment_is_exempt(&self, ctx: &crate::lint_context::LintContext, fragment: &str) -> bool {
644 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
649 && (fragment.starts_with("fn:")
650 || fragment.starts_with("fnref:")
651 || (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
652 {
653 return true;
654 }
655
656 self.ignored_pattern_regex
658 .as_ref()
659 .is_some_and(|re| re.is_match(fragment))
660 }
661
662 fn fragment_resolves(&self, fragment: &str, anchors: &AnchorSets) -> bool {
666 if self.config.ignore_case {
667 let lower = fragment.to_lowercase();
668 anchors.html_anchors.contains(&lower) || anchors.markdown_headings.contains(&lower)
669 } else {
670 anchors.html_anchors_exact.contains(fragment) || anchors.markdown_headings_exact.contains(fragment)
671 }
672 }
673
674 fn check_front_matter(
681 &self,
682 ctx: &crate::lint_context::LintContext,
683 links: &[frontmatter_values::FrontMatterLink],
684 anchors: &AnchorSets,
685 warnings: &mut Vec<LintWarning>,
686 ) {
687 for link in links {
688 let line = ctx.lines[link.line - 1].content(ctx.content);
689 let Some(fragment) = line[link.range.clone()].strip_prefix('#') else {
690 continue;
691 };
692 if fragment.is_empty() {
693 continue;
694 }
695
696 if ctx.flavor.is_pandoc_compatible() && ctx.has_pandoc_slug(fragment) {
699 continue;
700 }
701
702 if self.fragment_is_exempt(ctx, fragment) || self.fragment_resolves(fragment, anchors) {
703 continue;
704 }
705
706 let column = byte_to_char_count(line, link.range.start);
707 warnings.push(LintWarning {
708 rule_name: Some(self.name().to_string()),
709 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
710 line: link.line,
711 column,
712 end_line: link.line,
713 end_column: column + 1 + fragment.chars().count(),
714 severity: Severity::Error,
715 fix: None,
716 });
717 }
718 }
719}
720
721impl Rule for MD051LinkFragments {
722 fn name(&self) -> &'static str {
723 "MD051"
724 }
725
726 fn description(&self) -> &'static str {
727 "Link fragments should reference valid headings"
728 }
729
730 fn fix_capability(&self) -> FixCapability {
731 FixCapability::Unfixable
732 }
733
734 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
735 if !ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx) {
739 return true;
740 }
741 !ctx.has_char('#')
743 }
744
745 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
746 let mut warnings = Vec::new();
747
748 if ctx.content.is_empty() || self.should_skip(ctx) {
749 return Ok(warnings);
750 }
751
752 let front_matter_links = self.front_matter_links(ctx);
753 if ctx.links().is_empty() && front_matter_links.is_empty() {
754 return Ok(warnings);
755 }
756
757 let anchors = self.extract_headings_from_context(ctx);
758
759 for link in ctx.links() {
760 if link.is_reference {
761 continue;
762 }
763
764 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
766 continue;
767 }
768
769 if matches!(link.link_type, LinkType::WikiLink { .. }) {
771 continue;
772 }
773
774 if ctx.is_in_jinja_range(link.byte_offset) {
776 continue;
777 }
778
779 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
782 continue;
783 }
784
785 if ctx.is_in_shortcode(link.byte_offset) {
788 continue;
789 }
790
791 let url = &link.url;
792
793 if !url.contains('#') || Self::is_external_url_fast(url) {
795 continue;
796 }
797
798 if url.contains("{{#") && url.contains("}}") {
801 continue;
802 }
803
804 if ctx.flavor.is_pandoc_compatible()
810 && let Some(frag) = url.strip_prefix('#')
811 && ctx.has_pandoc_slug(frag)
812 {
813 continue;
814 }
815
816 if url.starts_with('@') {
820 continue;
821 }
822
823 if Self::is_cross_file_link(url) {
825 continue;
826 }
827
828 let Some(fragment_pos) = url.find('#') else {
829 continue;
830 };
831
832 let fragment = &url[fragment_pos + 1..];
833
834 if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
836 continue;
837 }
838
839 if fragment.is_empty() {
840 continue;
841 }
842
843 if self.fragment_is_exempt(ctx, fragment) {
844 continue;
845 }
846
847 if !self.fragment_resolves(fragment, &anchors) {
848 warnings.push(LintWarning {
849 rule_name: Some(self.name().to_string()),
850 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
851 line: link.line,
852 column: link.start_col + 1,
853 end_line: link.end_line,
854 end_column: link.end_col + 1,
855 severity: Severity::Error,
856 fix: None,
857 });
858 }
859 }
860
861 self.check_front_matter(ctx, &front_matter_links, &anchors, &mut warnings);
862
863 Ok(warnings)
864 }
865
866 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
867 Ok(ctx.content.to_string())
870 }
871
872 fn as_any(&self) -> &dyn std::any::Any {
873 self
874 }
875
876 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
877 where
878 Self: Sized,
879 {
880 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD051Config>(config);
881
882 let explicit_style_present = config
887 .rules
888 .get("MD051")
889 .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
890 if !explicit_style_present {
891 rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
892 }
893
894 Box::new(MD051LinkFragments::build(
895 rule_config,
896 config.withheld_rule_values.contains("MD051"),
897 explicit_style_present,
898 ))
899 }
900
901 fn category(&self) -> RuleCategory {
902 RuleCategory::Link
903 }
904
905 fn skippable_by_category(&self) -> bool {
906 !self.config.check_frontmatter
909 }
910
911 fn cross_file_scope(&self) -> CrossFileScope {
912 CrossFileScope::Workspace
913 }
914
915 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
916 let mut fragment_counts = HashMap::new();
917 let anchor_style = self.anchor_style(ctx);
918 let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
919
920 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
922 if line_info.in_front_matter {
923 continue;
924 }
925
926 if line_info.in_code_block {
928 continue;
929 }
930
931 let content = line_info.content(ctx.content);
932
933 for_each_html_anchor_target(ctx, line_info, |id| file_index.add_html_anchor(id));
934
935 let parsed_heading = ctx.heading_on_line(line_idx + 1);
938 if parsed_heading.is_none() && content.contains('{') && content.contains('#') {
939 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
940 if let Some(id_match) = caps.get(1) {
941 file_index.add_attribute_anchor(id_match.as_str());
942 }
943 }
944 }
945
946 if let Some(parsed) = parsed_heading {
948 let heading = parsed.heading;
949 let fragment = anchor_style.generate_fragment(&heading.slug_text);
950
951 Self::add_heading_to_index(
952 &fragment,
953 &heading.text,
954 heading.custom_id.clone(),
955 line_idx + 1,
956 parsed.is_setext(),
957 &mut fragment_counts,
958 file_index,
959 use_underscore_dedup,
960 );
961
962 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
967 && let Some(caps) = MD_SETTING_PATTERN.captures(content)
968 && let Some(name) = caps.get(1)
969 {
970 file_index.add_html_anchor(name.as_str());
971 }
972 }
973 }
974
975 for link in ctx.links() {
977 if link.is_reference {
978 continue;
979 }
980
981 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
983 continue;
984 }
985
986 if matches!(link.link_type, LinkType::WikiLink { .. }) {
989 continue;
990 }
991
992 let url = &link.url;
993
994 if Self::is_external_url_fast(url) {
996 continue;
997 }
998
999 if Self::is_cross_file_link(url)
1001 && let Some(fragment_pos) = url.find('#')
1002 {
1003 let path_part = &url[..fragment_pos];
1004 let fragment = &url[fragment_pos + 1..];
1005
1006 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1008 continue;
1009 }
1010
1011 file_index.add_cross_file_link(CrossFileLinkIndex {
1012 target_path: path_part.to_string(),
1013 fragment: fragment.to_string(),
1014 line: link.line,
1015 column: link.start_col + 1,
1016 origin: LinkOrigin::Body,
1017 });
1018 }
1019 }
1020
1021 for link in frontmatter_values::link_destinations(ctx) {
1028 let line = ctx.lines[link.line - 1].content(ctx.content);
1029 let value = &line[link.range.clone()];
1030
1031 if Self::is_external_url_fast(value) || !Self::is_cross_file_link(value) {
1032 continue;
1033 }
1034
1035 let Some(fragment_pos) = value.find('#') else {
1036 continue;
1037 };
1038 let path_part = &value[..fragment_pos];
1039 let fragment = &value[fragment_pos + 1..];
1040
1041 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1043 continue;
1044 }
1045
1046 file_index.add_cross_file_link(CrossFileLinkIndex {
1047 target_path: path_part.to_string(),
1048 fragment: fragment.to_string(),
1049 line: link.line,
1050 column: byte_to_char_count(line, link.range.start),
1051 origin: LinkOrigin::FrontMatter { field: link.field },
1052 });
1053 }
1054 }
1055
1056 fn cross_file_check(
1057 &self,
1058 file_path: &Path,
1059 file_index: &FileIndex,
1060 workspace_index: &crate::workspace_index::WorkspaceIndex,
1061 ) -> LintResult {
1062 let mut warnings = Vec::new();
1063
1064 let ignored_pattern = self.ignored_pattern_regex.as_ref();
1065 let ignore_case = self.config.ignore_case;
1066
1067 for cross_link in &file_index.cross_file_links {
1069 if cross_link.fragment.is_empty() {
1071 continue;
1072 }
1073
1074 if !self.reports_link_from(&cross_link.origin) {
1077 continue;
1078 }
1079
1080 if ignored_pattern.is_some_and(|re| re.is_match(&cross_link.fragment)) {
1082 continue;
1083 }
1084
1085 let target_paths_to_try =
1088 crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path);
1089
1090 let mut target_file_index = None;
1092
1093 for target_path in &target_paths_to_try {
1094 if let Some(index) = workspace_index.get_file(target_path) {
1095 target_file_index = Some(index);
1096 break;
1097 }
1098 }
1099
1100 if let Some(target_file_index) = target_file_index {
1101 if !target_file_index.has_anchor_with_case(&cross_link.fragment, ignore_case) {
1103 warnings.push(LintWarning {
1104 rule_name: Some(self.name().to_string()),
1105 line: cross_link.line,
1106 column: cross_link.column,
1107 end_line: cross_link.line,
1108 end_column: cross_link.column
1109 + cross_link.target_path.chars().count()
1110 + 1
1111 + cross_link.fragment.chars().count(),
1112 message: format!(
1113 "Link fragment '{}' not found in '{}'",
1114 cross_link.fragment, cross_link.target_path
1115 ),
1116 severity: Severity::Error,
1117 fix: None,
1118 });
1119 }
1120 }
1121 }
1123
1124 Ok(warnings)
1125 }
1126
1127 crate::impl_rule_config_sections!(MD051Config);
1128}
1129
1130#[cfg(test)]
1131mod tests {
1132 use super::*;
1133 use crate::lint_context::LintContext;
1134 use std::path::PathBuf;
1135
1136 const ANCHOR_STYLE_PROBE: &str = "### Getting Started — Advanced\n\n\
1140 [python-markdown slug](#getting-started-advanced)\n\
1141 [github slug](#getting-started--advanced)\n";
1142
1143 fn flagged_fragment(rule: &dyn Rule, flavor: crate::config::MarkdownFlavor) -> String {
1144 let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
1145 let warnings = rule.check(&ctx).unwrap();
1146 assert_eq!(
1147 warnings.len(),
1148 1,
1149 "exactly one of the two links must be invalid under any style: {warnings:?}"
1150 );
1151 warnings[0].message.clone()
1152 }
1153
1154 #[test]
1158 fn test_unpinned_anchor_style_follows_the_file_flavor() {
1159 let rule_from_global = |flavor| {
1160 let mut config = crate::config::Config::default();
1161 config.global.flavor = flavor;
1162 MD051LinkFragments::from_config(&config)
1163 };
1164
1165 let standard_global = rule_from_global(crate::config::MarkdownFlavor::Standard);
1167 assert!(
1170 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1171 .contains("#getting-started-advanced'"),
1172 "a standard file must be checked against GitHub anchors"
1173 );
1174 assert!(
1177 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1178 .contains("#getting-started--advanced'"),
1179 "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
1180 );
1181
1182 let mkdocs_global = rule_from_global(crate::config::MarkdownFlavor::MkDocs);
1184 assert!(
1185 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1186 .contains("#getting-started--advanced'"),
1187 "a mkdocs file must be checked against Python-Markdown anchors"
1188 );
1189 assert!(
1190 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1191 .contains("#getting-started-advanced'"),
1192 "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
1193 );
1194 }
1195
1196 #[test]
1199 fn test_pinned_anchor_style_ignores_the_file_flavor() {
1200 let mut config = crate::config::Config::default();
1201 config.global.flavor = crate::config::MarkdownFlavor::Standard;
1202 let mut rule_config = crate::config::RuleConfig::default();
1203 rule_config
1204 .values
1205 .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
1206 config.rules.insert("MD051".to_string(), rule_config);
1207 let rule = MD051LinkFragments::from_config(&config);
1208
1209 for flavor in [
1210 crate::config::MarkdownFlavor::Standard,
1211 crate::config::MarkdownFlavor::MkDocs,
1212 crate::config::MarkdownFlavor::Kramdown,
1213 ] {
1214 assert!(
1215 flagged_fragment(rule.as_ref(), flavor).contains("#getting-started-advanced'"),
1216 "pinned github anchors must survive a {flavor:?} file"
1217 );
1218 }
1219 }
1220
1221 #[test]
1224 fn test_directly_constructed_rule_keeps_its_anchor_style() {
1225 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1226 anchor_style: AnchorStyle::PythonMarkdown,
1227 ..Default::default()
1228 });
1229 assert!(
1230 flagged_fragment(&rule, crate::config::MarkdownFlavor::Standard).contains("#getting-started--advanced'"),
1231 "an explicitly constructed Python-Markdown rule must not follow the file flavor"
1232 );
1233 }
1234
1235 #[test]
1236 fn test_quarto_cross_references() {
1237 let rule = MD051LinkFragments::new();
1238
1239 let content = r#"# Test Document
1241
1242## Figures
1243
1244See [@fig-plot] for the visualization.
1245
1246More details in [@tbl-results] and [@sec-methods].
1247
1248The equation [@eq-regression] shows the relationship.
1249
1250Reference to [@lst-code] for implementation."#;
1251 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1252 let result = rule.check(&ctx).unwrap();
1253 assert!(
1254 result.is_empty(),
1255 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
1256 result.len()
1257 );
1258
1259 let content_with_anchor = r#"# Test
1261
1262See [link](#test) for details."#;
1263 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
1264 let result_anchor = rule.check(&ctx_anchor).unwrap();
1265 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
1266
1267 let content_invalid = r#"# Test
1269
1270See [link](#nonexistent) for details."#;
1271 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
1272 let result_invalid = rule.check(&ctx_invalid).unwrap();
1273 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
1274 }
1275
1276 #[test]
1277 fn test_jsx_in_heading_anchor() {
1278 let rule = MD051LinkFragments::new();
1280
1281 let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284 let result = rule.check(&ctx).unwrap();
1285 assert!(
1286 result.is_empty(),
1287 "JSX self-closing tag should be stripped from anchor: got {result:?}"
1288 );
1289
1290 let content2 =
1292 "### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
1293 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1294 let result2 = rule.check(&ctx2).unwrap();
1295 assert!(
1296 result2.is_empty(),
1297 "JSX tag with attributes should be stripped from anchor: got {result2:?}"
1298 );
1299
1300 let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
1302 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1303 let result3 = rule.check(&ctx3).unwrap();
1304 assert!(
1305 result3.is_empty(),
1306 "HTML tag content should be preserved in anchor: got {result3:?}"
1307 );
1308 }
1309
1310 #[test]
1312 fn test_cross_file_scope() {
1313 let rule = MD051LinkFragments::new();
1314 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1315 }
1316
1317 #[test]
1318 fn test_contribute_to_index_extracts_headings() {
1319 let rule = MD051LinkFragments::new();
1320 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322
1323 let mut file_index = FileIndex::new();
1324 rule.contribute_to_index(&ctx, &mut file_index);
1325
1326 assert_eq!(file_index.headings.len(), 3);
1327 assert_eq!(file_index.headings[0].text, "First Heading");
1328 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
1329 assert!(file_index.headings[0].custom_anchor.is_none());
1330
1331 assert_eq!(file_index.headings[1].text, "Second");
1332 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
1333
1334 assert_eq!(file_index.headings[2].text, "Third");
1335 }
1336
1337 #[test]
1338 fn test_contribute_to_index_extracts_cross_file_links() {
1339 let rule = MD051LinkFragments::new();
1340 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
1341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1342
1343 let mut file_index = FileIndex::new();
1344 rule.contribute_to_index(&ctx, &mut file_index);
1345
1346 assert_eq!(file_index.cross_file_links.len(), 2);
1347 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1348 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
1349 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
1350 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
1351 }
1352
1353 #[test]
1357 fn test_contribute_to_index_records_setext_headings() {
1358 let rule = MD051LinkFragments::new();
1359 let content = "Setext One\n==========\n\nSetext Two\n----------\n\n### Atx Three\n";
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361
1362 let mut file_index = FileIndex::new();
1363 rule.contribute_to_index(&ctx, &mut file_index);
1364
1365 let styles: Vec<(&str, bool)> = file_index
1366 .headings
1367 .iter()
1368 .map(|h| (h.text.as_str(), h.is_setext))
1369 .collect();
1370 assert_eq!(
1371 styles,
1372 vec![("Setext One", true), ("Setext Two", true), ("Atx Three", false)]
1373 );
1374 }
1375
1376 #[test]
1384 fn test_a_frontmatter_link_is_indexed_regardless_of_the_indexing_config() {
1385 let content = "---\nlink: 'other.md#nope'\n---\n\n# Real\n";
1386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387
1388 for check_frontmatter in [true, false] {
1389 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1390 check_frontmatter,
1391 ..Default::default()
1392 });
1393 let mut file_index = FileIndex::new();
1394 rule.contribute_to_index(&ctx, &mut file_index);
1395
1396 assert_eq!(
1397 file_index.cross_file_links.len(),
1398 1,
1399 "check_frontmatter = {check_frontmatter} changed what was indexed"
1400 );
1401 assert_eq!(
1402 file_index.cross_file_links[0].origin,
1403 LinkOrigin::FrontMatter {
1404 field: Some("link".to_string())
1405 },
1406 );
1407 }
1408 }
1409
1410 #[test]
1414 fn test_cross_file_check_applies_this_files_frontmatter_config() {
1415 use crate::workspace_index::WorkspaceIndex;
1416
1417 let mut workspace_index = WorkspaceIndex::new();
1418 let mut target = FileIndex::new();
1419 target.add_heading(HeadingIndex {
1420 text: "Real".to_string(),
1421 auto_anchor: "real".to_string(),
1422 custom_anchor: None,
1423 line: 1,
1424 is_setext: false,
1425 });
1426 workspace_index.insert_file(PathBuf::from("docs/other.md"), target);
1427
1428 let mut file_index = FileIndex::new();
1429 file_index.add_cross_file_link(CrossFileLinkIndex {
1430 target_path: "other.md".to_string(),
1431 fragment: "nope".to_string(),
1432 line: 2,
1433 column: 7,
1434 origin: LinkOrigin::FrontMatter {
1435 field: Some("link".to_string()),
1436 },
1437 });
1438 file_index.add_cross_file_link(CrossFileLinkIndex {
1442 target_path: "other.md".to_string(),
1443 fragment: "nope".to_string(),
1444 line: 6,
1445 column: 5,
1446 origin: LinkOrigin::Body,
1447 });
1448
1449 let count = |config: MD051Config| {
1450 MD051LinkFragments::from_config_struct(config)
1451 .cross_file_check(Path::new("docs/readme.md"), &file_index, &workspace_index)
1452 .unwrap()
1453 .len()
1454 };
1455
1456 assert_eq!(
1457 count(MD051Config {
1458 check_frontmatter: true,
1459 ..Default::default()
1460 }),
1461 2,
1462 "checking frontmatter should report both the frontmatter and body links"
1463 );
1464 assert_eq!(
1465 count(MD051Config {
1466 check_frontmatter: false,
1467 ..Default::default()
1468 }),
1469 1,
1470 "not checking frontmatter should leave only the body link"
1471 );
1472 assert_eq!(
1473 count(MD051Config {
1474 check_frontmatter: true,
1475 ignore_frontmatter_fields: vec!["LINK".to_string()],
1476 ..Default::default()
1477 }),
1478 1,
1479 "an ignored field should be matched case-insensitively"
1480 );
1481 }
1482
1483 #[test]
1484 fn test_cross_file_check_valid_fragment() {
1485 use crate::workspace_index::WorkspaceIndex;
1486
1487 let rule = MD051LinkFragments::new();
1488
1489 let mut workspace_index = WorkspaceIndex::new();
1491 let mut target_file_index = FileIndex::new();
1492 target_file_index.add_heading(HeadingIndex {
1493 text: "Installation Guide".to_string(),
1494 auto_anchor: "installation-guide".to_string(),
1495 custom_anchor: None,
1496 line: 1,
1497 is_setext: false,
1498 });
1499 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1500
1501 let mut current_file_index = FileIndex::new();
1503 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1504 target_path: "install.md".to_string(),
1505 fragment: "installation-guide".to_string(),
1506 line: 3,
1507 column: 5,
1508 origin: LinkOrigin::Body,
1509 });
1510
1511 let warnings = rule
1512 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1513 .unwrap();
1514
1515 assert!(warnings.is_empty());
1517 }
1518
1519 #[test]
1520 fn test_cross_file_check_invalid_fragment() {
1521 use crate::workspace_index::WorkspaceIndex;
1522
1523 let rule = MD051LinkFragments::new();
1524
1525 let mut workspace_index = WorkspaceIndex::new();
1527 let mut target_file_index = FileIndex::new();
1528 target_file_index.add_heading(HeadingIndex {
1529 text: "Installation Guide".to_string(),
1530 auto_anchor: "installation-guide".to_string(),
1531 custom_anchor: None,
1532 line: 1,
1533 is_setext: false,
1534 });
1535 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1536
1537 let mut current_file_index = FileIndex::new();
1539 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1540 target_path: "install.md".to_string(),
1541 fragment: "nonexistent".to_string(),
1542 line: 3,
1543 column: 5,
1544 origin: LinkOrigin::Body,
1545 });
1546
1547 let warnings = rule
1548 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1549 .unwrap();
1550
1551 assert_eq!(warnings.len(), 1);
1553 assert!(warnings[0].message.contains("nonexistent"));
1554 assert!(warnings[0].message.contains("install.md"));
1555 }
1556
1557 #[test]
1558 fn test_cross_file_check_custom_anchor_match() {
1559 use crate::workspace_index::WorkspaceIndex;
1560
1561 let rule = MD051LinkFragments::new();
1562
1563 let mut workspace_index = WorkspaceIndex::new();
1565 let mut target_file_index = FileIndex::new();
1566 target_file_index.add_heading(HeadingIndex {
1567 text: "Installation Guide".to_string(),
1568 auto_anchor: "installation-guide".to_string(),
1569 custom_anchor: Some("install".to_string()),
1570 line: 1,
1571 is_setext: false,
1572 });
1573 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1574
1575 let mut current_file_index = FileIndex::new();
1577 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1578 target_path: "install.md".to_string(),
1579 fragment: "install".to_string(),
1580 line: 3,
1581 column: 5,
1582 origin: LinkOrigin::Body,
1583 });
1584
1585 let warnings = rule
1586 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1587 .unwrap();
1588
1589 assert!(warnings.is_empty());
1591 }
1592
1593 #[test]
1594 fn test_cross_file_check_target_not_in_workspace() {
1595 use crate::workspace_index::WorkspaceIndex;
1596
1597 let rule = MD051LinkFragments::new();
1598
1599 let workspace_index = WorkspaceIndex::new();
1601
1602 let mut current_file_index = FileIndex::new();
1604 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1605 target_path: "external.md".to_string(),
1606 fragment: "heading".to_string(),
1607 line: 3,
1608 column: 5,
1609 origin: LinkOrigin::Body,
1610 });
1611
1612 let warnings = rule
1613 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1614 .unwrap();
1615
1616 assert!(warnings.is_empty());
1618 }
1619
1620 #[test]
1621 fn test_wikilinks_skipped_in_check() {
1622 let rule = MD051LinkFragments::new();
1624
1625 let content = r#"# Test Document
1626
1627## Valid Heading
1628
1629[[Microsoft#Windows OS]]
1630[[SomePage#section]]
1631[[page|Display Text]]
1632[[path/to/page#section]]
1633"#;
1634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1635 let result = rule.check(&ctx).unwrap();
1636
1637 assert!(
1638 result.is_empty(),
1639 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1640 );
1641 }
1642
1643 #[test]
1644 fn test_wikilinks_not_added_to_cross_file_index() {
1645 let rule = MD051LinkFragments::new();
1647
1648 let content = r#"# Test Document
1649
1650[[Microsoft#Windows OS]]
1651[[SomePage#section]]
1652[Regular Link](other.md#section)
1653"#;
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655
1656 let mut file_index = FileIndex::new();
1657 rule.contribute_to_index(&ctx, &mut file_index);
1658
1659 let cross_file_links = &file_index.cross_file_links;
1662 assert_eq!(
1663 cross_file_links.len(),
1664 1,
1665 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1666 );
1667 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1668 assert_eq!(file_index.cross_file_links[0].fragment, "section");
1669 }
1670
1671 #[test]
1672 fn test_pandoc_flavor_skips_citations() {
1673 let rule = MD051LinkFragments::new();
1677 let content = "# Test Document\n\nSee [@smith2020] for details.\n";
1678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1679 let result = rule.check(&ctx).unwrap();
1680 assert!(
1681 result.is_empty(),
1682 "MD051 should skip Pandoc citations under Pandoc flavor: {result:?}"
1683 );
1684 }
1685
1686 #[test]
1687 fn md051_pandoc_resolves_pandoc_slug_diverging_from_github() {
1688 use crate::config::MarkdownFlavor;
1695 let rule = MD051LinkFragments::new();
1696 let content = "# 5. Five Things\n\nSee [details](#5.-five-things).\n";
1697
1698 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1701 let std_result = rule.check(&ctx_std).unwrap();
1702 assert_eq!(
1703 std_result.len(),
1704 1,
1705 "Standard flavor should flag the Pandoc-style fragment: {std_result:?}"
1706 );
1707
1708 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1710 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1711 assert!(
1712 pandoc_result.is_empty(),
1713 "Pandoc flavor should resolve `#5.-five-things` against the heading slug: {pandoc_result:?}"
1714 );
1715 }
1716
1717 #[test]
1721 fn md051_pandoc_flags_missing_fragment_with_email_in_link_text() {
1722 use crate::config::MarkdownFlavor;
1723 let rule = MD051LinkFragments::new();
1724 let content = "# Title\n\n[contact user@example.com](#missing)\n";
1725
1726 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 must flag the missing fragment: {std_result:?}"
1732 );
1733
1734 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1735 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1736 assert_eq!(
1737 pandoc_result.len(),
1738 1,
1739 "Pandoc flavor must also flag the missing fragment — link text with embedded email is not a citation: {pandoc_result:?}"
1740 );
1741 }
1742
1743 #[test]
1747 fn md051_pandoc_flags_missing_fragment_with_citation_in_link_text() {
1748 use crate::config::MarkdownFlavor;
1749 let rule = MD051LinkFragments::new();
1750 let content = "# Title\n\n[see @smith2020](#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 flag the missing fragment — `[label](url)` is a link, not a citation: {pandoc_result:?}"
1766 );
1767 }
1768
1769 #[test]
1773 fn md051_pandoc_resolves_duplicate_heading_suffix_slug() {
1774 use crate::config::MarkdownFlavor;
1775 let rule = MD051LinkFragments::new();
1776 let content = "# A.\n\nfirst\n\n# A.\n\nsecond\n\n[first](#a.) and [second](#a.-1).\n";
1777
1778 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1779 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1780 assert!(
1781 pandoc_result.is_empty(),
1782 "Pandoc flavor should resolve `#a.` and `#a.-1` against duplicate headings: {pandoc_result:?}"
1783 );
1784
1785 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1786 let quarto_result = rule.check(&ctx_quarto).unwrap();
1787 assert!(
1788 quarto_result.is_empty(),
1789 "Quarto flavor should also resolve duplicate-heading suffix slugs: {quarto_result:?}"
1790 );
1791 }
1792
1793 #[test]
1796 fn md051_pandoc_flags_overshoot_duplicate_suffix() {
1797 use crate::config::MarkdownFlavor;
1798 let rule = MD051LinkFragments::new();
1799 let content = "# A.\n\n# A.\n\n[overshoot](#a.-2)\n";
1800
1801 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1802 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1803 assert_eq!(
1804 pandoc_result.len(),
1805 1,
1806 "Pandoc must flag `#a.-2` when only `-1` exists (two duplicates): {pandoc_result:?}"
1807 );
1808 }
1809
1810 fn front_matter_checked() -> MD051Config {
1811 MD051Config {
1812 check_frontmatter: true,
1813 ..MD051Config::default()
1814 }
1815 }
1816
1817 fn check_front_matter(content: &str, config: MD051Config) -> Vec<LintWarning> {
1818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819 MD051LinkFragments::from_config_struct(config).check(&ctx).unwrap()
1820 }
1821
1822 #[test]
1823 fn a_broken_frontmatter_fragment_is_reported_when_enabled() {
1824 let content = "---\nanchor: '#missing'\nvalid: '#title'\n---\n\n# Title\n";
1825 let result = check_front_matter(content, front_matter_checked());
1826
1827 assert_eq!(
1828 result.len(),
1829 1,
1830 "Only the unresolved fragment is reported. Got: {result:?}"
1831 );
1832 assert_eq!(
1833 result[0].message,
1834 "Link anchor '#missing' does not exist in document headings"
1835 );
1836 assert_eq!(result[0].line, 2);
1837 assert_eq!(result[0].column, 10, "The warning points at the value, not the key");
1838 assert_eq!(result[0].end_column, 18);
1839 }
1840
1841 #[test]
1842 fn frontmatter_fragments_are_not_checked_by_default() {
1843 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
1844 let result = check_front_matter(content, MD051Config::default());
1845
1846 assert!(
1847 result.is_empty(),
1848 "Frontmatter is only checked on request. Got: {result:?}"
1849 );
1850 }
1851
1852 #[test]
1853 fn an_ignored_frontmatter_field_is_not_checked() {
1854 let content = "---\nhero: '#missing'\nanchor: '#other'\n---\n\n# Title\n";
1855 let config = MD051Config {
1856 check_frontmatter: true,
1857 ignore_frontmatter_fields: vec!["Hero".to_string()],
1858 ..MD051Config::default()
1859 };
1860 let result = check_front_matter(content, config);
1861
1862 assert_eq!(
1863 result.len(),
1864 1,
1865 "The ignored field is skipped and the other is not. Got: {result:?}"
1866 );
1867 assert_eq!(result[0].line, 3);
1868 }
1869
1870 #[test]
1871 fn the_ignored_pattern_applies_to_frontmatter_fragments() {
1872 let content = "---\nnote: '#fn:1'\nanchor: '#missing'\n---\n\n# Title\n";
1873 let config = MD051Config {
1874 check_frontmatter: true,
1875 ignored_pattern: Some("^fn:".to_string()),
1876 ..MD051Config::default()
1877 };
1878 let result = check_front_matter(content, config);
1879
1880 assert_eq!(
1881 result.len(),
1882 1,
1883 "The matching fragment is skipped and the other is not. Got: {result:?}"
1884 );
1885 assert_eq!(result[0].line, 3);
1886 }
1887
1888 #[test]
1889 fn a_frontmatter_fragment_honors_ignore_case() {
1890 let content = "---\nanchor: '#Title'\n---\n\n# Title\n";
1891
1892 let permissive = check_front_matter(content, front_matter_checked());
1893 assert!(
1894 permissive.is_empty(),
1895 "The default resolves a case mismatch. Got: {permissive:?}"
1896 );
1897
1898 let strict = check_front_matter(
1899 content,
1900 MD051Config {
1901 check_frontmatter: true,
1902 ignore_case: false,
1903 ..MD051Config::default()
1904 },
1905 );
1906 assert_eq!(strict.len(), 1, "Strict matching reports it. Got: {strict:?}");
1907 }
1908
1909 #[test]
1910 fn prose_in_frontmatter_is_not_read_as_a_fragment() {
1911 let content = "---\ntitle: Node.js\ntags: ci/cd\n---\n\n# Title\n";
1912 let result = check_front_matter(content, front_matter_checked());
1913
1914 assert!(
1915 result.is_empty(),
1916 "Only path-shaped values are destinations. Got: {result:?}"
1917 );
1918 }
1919
1920 #[test]
1921 fn a_frontmatter_path_with_a_fragment_is_validated_across_files() {
1922 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1923 let source = "---\ntemplate: other.md#missing\nvalid: other.md#target\n---\n\n# Source\n";
1924
1925 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1926 let mut source_index = FileIndex::default();
1927 rule.contribute_to_index(&source_ctx, &mut source_index);
1928
1929 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1930 let mut target_index = FileIndex::default();
1931 rule.contribute_to_index(&target_ctx, &mut target_index);
1932
1933 let source_path = PathBuf::from("docs/source.md");
1934 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1935 workspace.insert_file(source_path.clone(), source_index.clone());
1936 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1937
1938 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1939
1940 assert_eq!(
1941 warnings.len(),
1942 1,
1943 "Only the unresolved fragment is reported. Got: {warnings:?}"
1944 );
1945 assert_eq!(warnings[0].message, "Link fragment 'missing' not found in 'other.md'");
1946 assert_eq!(warnings[0].line, 2);
1947 assert_eq!(warnings[0].column, 11);
1948 }
1949
1950 #[test]
1951 fn a_query_string_does_not_hide_the_target_file() {
1952 let rule = MD051LinkFragments::new();
1953 let source = "# Source\n\n- [a](other.md?raw=true#missing)\n- [b](other.md?raw=true#target)\n";
1954
1955 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1956 let mut source_index = FileIndex::default();
1957 rule.contribute_to_index(&source_ctx, &mut source_index);
1958
1959 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1960 let mut target_index = FileIndex::default();
1961 rule.contribute_to_index(&target_ctx, &mut target_index);
1962
1963 let source_path = PathBuf::from("docs/source.md");
1964 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1965 workspace.insert_file(source_path.clone(), source_index.clone());
1966 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1967
1968 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1969
1970 assert_eq!(
1971 warnings.len(),
1972 1,
1973 "The query is stripped to find the file, so both fragments resolve against it. Got: {warnings:?}"
1974 );
1975 assert_eq!(
1976 warnings[0].message,
1977 "Link fragment 'missing' not found in 'other.md?raw=true'"
1978 );
1979 assert_eq!(warnings[0].line, 3);
1980 }
1981
1982 #[test]
1983 fn a_query_string_does_not_hide_an_extensionless_target_file() {
1984 let rule = MD051LinkFragments::new();
1985 let source = "# Source\n\n- [a](other?raw=true#target)\n- [b](other#target)\n- [c](other?raw=true#absent)\n";
1986
1987 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1988 let same_document = rule.check(&source_ctx).unwrap();
1989 assert!(
1990 same_document.is_empty(),
1991 "Every fragment here belongs to another file, so none is a missing anchor of this one. Got: {same_document:?}"
1992 );
1993
1994 let mut source_index = FileIndex::default();
1995 rule.contribute_to_index(&source_ctx, &mut source_index);
1996
1997 let target_ctx = LintContext::new("# Other\n\n## Target\n", crate::config::MarkdownFlavor::Standard, None);
1998 let mut target_index = FileIndex::default();
1999 rule.contribute_to_index(&target_ctx, &mut target_index);
2000
2001 let source_path = PathBuf::from("docs/source.md");
2002 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
2003 workspace.insert_file(source_path.clone(), source_index.clone());
2004 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
2005
2006 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
2007
2008 assert_eq!(
2009 warnings.len(),
2010 1,
2011 "The query is stripped before the markdown extension is added. Got: {warnings:?}"
2012 );
2013 assert_eq!(
2014 warnings[0].message,
2015 "Link fragment 'absent' not found in 'other?raw=true'"
2016 );
2017 assert_eq!(warnings[0].line, 5);
2018 }
2019
2020 #[test]
2021 fn a_destination_that_is_only_a_query_stays_on_this_page() {
2022 let rule = MD051LinkFragments::new();
2023 let source = "# Source\n\n## Here\n\n- [a](?raw=true#here)\n- [b](?raw=true#nowhere)\n";
2024
2025 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2026 let warnings = rule.check(&source_ctx).unwrap();
2027
2028 assert_eq!(
2029 warnings.len(),
2030 1,
2031 "Only the absent anchor is reported. Got: {warnings:?}"
2032 );
2033 assert_eq!(
2034 warnings[0].message,
2035 "Link anchor '#nowhere' does not exist in document headings"
2036 );
2037 assert_eq!(warnings[0].line, 6);
2038
2039 let mut source_index = FileIndex::default();
2040 rule.contribute_to_index(&source_ctx, &mut source_index);
2041 assert!(
2042 source_index.cross_file_links.is_empty(),
2043 "A query with no path names no other file. Got: {:?}",
2044 source_index.cross_file_links
2045 );
2046 }
2047
2048 #[test]
2049 fn blockquote_syntax_inside_raw_html_does_not_create_an_anchor() {
2050 let rule = MD051LinkFragments::new();
2051 let source = "<div>\n> ## Hidden\n</div>\n\n[link](#hidden)\n";
2052 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2053
2054 let warnings = rule.check(&ctx).unwrap();
2055 assert_eq!(
2056 warnings.len(),
2057 1,
2058 "raw HTML must not satisfy the fragment: {warnings:?}"
2059 );
2060
2061 let mut file_index = FileIndex::default();
2062 rule.contribute_to_index(&ctx, &mut file_index);
2063 assert!(
2064 file_index.headings.is_empty(),
2065 "raw HTML must not enter the workspace index"
2066 );
2067 }
2068
2069 #[test]
2070 fn a_frontmatter_path_carrying_a_query_is_indexed() {
2071 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
2072 let source = "---\ntemplate: docs/other.md?raw=true#missing\n---\n\n# Source\n";
2073
2074 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2075 let mut source_index = FileIndex::default();
2076 rule.contribute_to_index(&source_ctx, &mut source_index);
2077
2078 assert_eq!(source_index.cross_file_links.len(), 1);
2079 assert_eq!(source_index.cross_file_links[0].target_path, "docs/other.md?raw=true");
2080 assert_eq!(source_index.cross_file_links[0].fragment, "missing");
2081 }
2082
2083 #[test]
2087 fn frontmatter_cross_file_paths_are_not_reported_by_default() {
2088 use crate::workspace_index::WorkspaceIndex;
2089
2090 let rule = MD051LinkFragments::new();
2091 let source = "---\ntemplate: other.md#missing\n---\n\n# Source\n";
2092
2093 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2094 let mut source_index = FileIndex::default();
2095 rule.contribute_to_index(&source_ctx, &mut source_index);
2096 assert_eq!(source_index.cross_file_links.len(), 1);
2097
2098 let mut workspace_index = WorkspaceIndex::new();
2099 let mut target = FileIndex::new();
2100 target.add_heading(HeadingIndex {
2101 text: "Present".to_string(),
2102 auto_anchor: "present".to_string(),
2103 custom_anchor: None,
2104 line: 1,
2105 is_setext: false,
2106 });
2107 workspace_index.insert_file(PathBuf::from("other.md"), target);
2108
2109 let warnings = rule
2110 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2111 .unwrap();
2112 assert!(
2113 warnings.is_empty(),
2114 "Frontmatter is only checked on request. Got: {warnings:?}"
2115 );
2116
2117 let checking = MD051LinkFragments::from_config_struct(MD051Config {
2121 check_frontmatter: true,
2122 ..Default::default()
2123 });
2124 assert_eq!(
2125 checking
2126 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2127 .unwrap()
2128 .len(),
2129 1
2130 );
2131 }
2132}