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 }
1127
1128 Ok(warnings)
1129 }
1130
1131 crate::impl_rule_config_sections!(MD051Config);
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136 use super::*;
1137 use crate::lint_context::LintContext;
1138 use std::path::PathBuf;
1139
1140 const ANCHOR_STYLE_PROBE: &str = "### Getting Started — Advanced\n\n\
1144 [python-markdown slug](#getting-started-advanced)\n\
1145 [github slug](#getting-started--advanced)\n";
1146
1147 fn flagged_fragment(rule: &dyn Rule, flavor: crate::config::MarkdownFlavor) -> String {
1148 let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
1149 let warnings = rule.check(&ctx).unwrap();
1150 assert_eq!(
1151 warnings.len(),
1152 1,
1153 "exactly one of the two links must be invalid under any style: {warnings:?}"
1154 );
1155 warnings[0].message.clone()
1156 }
1157
1158 #[test]
1162 fn test_unpinned_anchor_style_follows_the_file_flavor() {
1163 let rule_from_global = |flavor| {
1164 let mut config = crate::config::Config::default();
1165 config.global.flavor = flavor;
1166 MD051LinkFragments::from_config(&config)
1167 };
1168
1169 let standard_global = rule_from_global(crate::config::MarkdownFlavor::Standard);
1171 assert!(
1174 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1175 .contains("#getting-started-advanced'"),
1176 "a standard file must be checked against GitHub anchors"
1177 );
1178 assert!(
1181 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1182 .contains("#getting-started--advanced'"),
1183 "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
1184 );
1185
1186 let mkdocs_global = rule_from_global(crate::config::MarkdownFlavor::MkDocs);
1188 assert!(
1189 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1190 .contains("#getting-started--advanced'"),
1191 "a mkdocs file must be checked against Python-Markdown anchors"
1192 );
1193 assert!(
1194 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1195 .contains("#getting-started-advanced'"),
1196 "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
1197 );
1198 }
1199
1200 #[test]
1203 fn test_pinned_anchor_style_ignores_the_file_flavor() {
1204 let mut config = crate::config::Config::default();
1205 config.global.flavor = crate::config::MarkdownFlavor::Standard;
1206 let mut rule_config = crate::config::RuleConfig::default();
1207 rule_config
1208 .values
1209 .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
1210 config.rules.insert("MD051".to_string(), rule_config);
1211 let rule = MD051LinkFragments::from_config(&config);
1212
1213 for flavor in [
1214 crate::config::MarkdownFlavor::Standard,
1215 crate::config::MarkdownFlavor::MkDocs,
1216 crate::config::MarkdownFlavor::Kramdown,
1217 ] {
1218 assert!(
1219 flagged_fragment(rule.as_ref(), flavor).contains("#getting-started-advanced'"),
1220 "pinned github anchors must survive a {flavor:?} file"
1221 );
1222 }
1223 }
1224
1225 #[test]
1228 fn test_directly_constructed_rule_keeps_its_anchor_style() {
1229 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1230 anchor_style: AnchorStyle::PythonMarkdown,
1231 ..Default::default()
1232 });
1233 assert!(
1234 flagged_fragment(&rule, crate::config::MarkdownFlavor::Standard).contains("#getting-started--advanced'"),
1235 "an explicitly constructed Python-Markdown rule must not follow the file flavor"
1236 );
1237 }
1238
1239 #[test]
1240 fn test_quarto_cross_references() {
1241 let rule = MD051LinkFragments::new();
1242
1243 let content = r#"# Test Document
1245
1246## Figures
1247
1248See [@fig-plot] for the visualization.
1249
1250More details in [@tbl-results] and [@sec-methods].
1251
1252The equation [@eq-regression] shows the relationship.
1253
1254Reference to [@lst-code] for implementation."#;
1255 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1256 let result = rule.check(&ctx).unwrap();
1257 assert!(
1258 result.is_empty(),
1259 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
1260 result.len()
1261 );
1262
1263 let content_with_anchor = r#"# Test
1265
1266See [link](#test) for details."#;
1267 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
1268 let result_anchor = rule.check(&ctx_anchor).unwrap();
1269 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
1270
1271 let content_invalid = r#"# Test
1273
1274See [link](#nonexistent) for details."#;
1275 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
1276 let result_invalid = rule.check(&ctx_invalid).unwrap();
1277 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
1278 }
1279
1280 #[test]
1281 fn test_jsx_in_heading_anchor() {
1282 let rule = MD051LinkFragments::new();
1284
1285 let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
1287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1288 let result = rule.check(&ctx).unwrap();
1289 assert!(
1290 result.is_empty(),
1291 "JSX self-closing tag should be stripped from anchor: got {result:?}"
1292 );
1293
1294 let content2 =
1296 "### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
1297 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1298 let result2 = rule.check(&ctx2).unwrap();
1299 assert!(
1300 result2.is_empty(),
1301 "JSX tag with attributes should be stripped from anchor: got {result2:?}"
1302 );
1303
1304 let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
1306 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1307 let result3 = rule.check(&ctx3).unwrap();
1308 assert!(
1309 result3.is_empty(),
1310 "HTML tag content should be preserved in anchor: got {result3:?}"
1311 );
1312 }
1313
1314 #[test]
1316 fn test_cross_file_scope() {
1317 let rule = MD051LinkFragments::new();
1318 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1319 }
1320
1321 #[test]
1322 fn test_contribute_to_index_extracts_headings() {
1323 let rule = MD051LinkFragments::new();
1324 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
1325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1326
1327 let mut file_index = FileIndex::new();
1328 rule.contribute_to_index(&ctx, &mut file_index);
1329
1330 assert_eq!(file_index.headings.len(), 3);
1331 assert_eq!(file_index.headings[0].text, "First Heading");
1332 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
1333 assert!(file_index.headings[0].custom_anchor.is_none());
1334
1335 assert_eq!(file_index.headings[1].text, "Second");
1336 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
1337
1338 assert_eq!(file_index.headings[2].text, "Third");
1339 }
1340
1341 #[test]
1342 fn test_contribute_to_index_extracts_cross_file_links() {
1343 let rule = MD051LinkFragments::new();
1344 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
1345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1346
1347 let mut file_index = FileIndex::new();
1348 rule.contribute_to_index(&ctx, &mut file_index);
1349
1350 assert_eq!(file_index.cross_file_links.len(), 2);
1351 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1352 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
1353 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
1354 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
1355 }
1356
1357 #[test]
1361 fn test_contribute_to_index_records_setext_headings() {
1362 let rule = MD051LinkFragments::new();
1363 let content = "Setext One\n==========\n\nSetext Two\n----------\n\n### Atx Three\n";
1364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1365
1366 let mut file_index = FileIndex::new();
1367 rule.contribute_to_index(&ctx, &mut file_index);
1368
1369 let styles: Vec<(&str, bool)> = file_index
1370 .headings
1371 .iter()
1372 .map(|h| (h.text.as_str(), h.is_setext))
1373 .collect();
1374 assert_eq!(
1375 styles,
1376 vec![("Setext One", true), ("Setext Two", true), ("Atx Three", false)]
1377 );
1378 }
1379
1380 #[test]
1388 fn test_a_frontmatter_link_is_indexed_regardless_of_the_indexing_config() {
1389 let content = "---\nlink: 'other.md#nope'\n---\n\n# Real\n";
1390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391
1392 for check_frontmatter in [true, false] {
1393 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1394 check_frontmatter,
1395 ..Default::default()
1396 });
1397 let mut file_index = FileIndex::new();
1398 rule.contribute_to_index(&ctx, &mut file_index);
1399
1400 assert_eq!(
1401 file_index.cross_file_links.len(),
1402 1,
1403 "check_frontmatter = {check_frontmatter} changed what was indexed"
1404 );
1405 assert_eq!(
1406 file_index.cross_file_links[0].origin,
1407 LinkOrigin::FrontMatter {
1408 field: Some("link".to_string())
1409 },
1410 );
1411 }
1412 }
1413
1414 #[test]
1418 fn test_cross_file_check_applies_this_files_frontmatter_config() {
1419 use crate::workspace_index::WorkspaceIndex;
1420
1421 let mut workspace_index = WorkspaceIndex::new();
1422 let mut target = FileIndex::new();
1423 target.add_heading(HeadingIndex {
1424 text: "Real".to_string(),
1425 auto_anchor: "real".to_string(),
1426 custom_anchor: None,
1427 line: 1,
1428 is_setext: false,
1429 });
1430 workspace_index.insert_file(PathBuf::from("docs/other.md"), target);
1431
1432 let mut file_index = FileIndex::new();
1433 file_index.add_cross_file_link(CrossFileLinkIndex {
1434 target_path: "other.md".to_string(),
1435 fragment: "nope".to_string(),
1436 line: 2,
1437 column: 7,
1438 origin: LinkOrigin::FrontMatter {
1439 field: Some("link".to_string()),
1440 },
1441 });
1442 file_index.add_cross_file_link(CrossFileLinkIndex {
1446 target_path: "other.md".to_string(),
1447 fragment: "nope".to_string(),
1448 line: 6,
1449 column: 5,
1450 origin: LinkOrigin::Body,
1451 });
1452
1453 let count = |config: MD051Config| {
1454 MD051LinkFragments::from_config_struct(config)
1455 .cross_file_check(Path::new("docs/readme.md"), &file_index, &workspace_index)
1456 .unwrap()
1457 .len()
1458 };
1459
1460 assert_eq!(
1461 count(MD051Config {
1462 check_frontmatter: true,
1463 ..Default::default()
1464 }),
1465 2,
1466 "checking frontmatter should report both the frontmatter and body links"
1467 );
1468 assert_eq!(
1469 count(MD051Config {
1470 check_frontmatter: false,
1471 ..Default::default()
1472 }),
1473 1,
1474 "not checking frontmatter should leave only the body link"
1475 );
1476 assert_eq!(
1477 count(MD051Config {
1478 check_frontmatter: true,
1479 ignore_frontmatter_fields: vec!["LINK".to_string()],
1480 ..Default::default()
1481 }),
1482 1,
1483 "an ignored field should be matched case-insensitively"
1484 );
1485 }
1486
1487 #[test]
1488 fn test_cross_file_check_valid_fragment() {
1489 use crate::workspace_index::WorkspaceIndex;
1490
1491 let rule = MD051LinkFragments::new();
1492
1493 let mut workspace_index = WorkspaceIndex::new();
1495 let mut target_file_index = FileIndex::new();
1496 target_file_index.add_heading(HeadingIndex {
1497 text: "Installation Guide".to_string(),
1498 auto_anchor: "installation-guide".to_string(),
1499 custom_anchor: None,
1500 line: 1,
1501 is_setext: false,
1502 });
1503 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1504
1505 let mut current_file_index = FileIndex::new();
1507 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1508 target_path: "install.md".to_string(),
1509 fragment: "installation-guide".to_string(),
1510 line: 3,
1511 column: 5,
1512 origin: LinkOrigin::Body,
1513 });
1514
1515 let warnings = rule
1516 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1517 .unwrap();
1518
1519 assert!(warnings.is_empty());
1521 }
1522
1523 #[test]
1524 fn test_cross_file_check_invalid_fragment() {
1525 use crate::workspace_index::WorkspaceIndex;
1526
1527 let rule = MD051LinkFragments::new();
1528
1529 let mut workspace_index = WorkspaceIndex::new();
1531 let mut target_file_index = FileIndex::new();
1532 target_file_index.add_heading(HeadingIndex {
1533 text: "Installation Guide".to_string(),
1534 auto_anchor: "installation-guide".to_string(),
1535 custom_anchor: None,
1536 line: 1,
1537 is_setext: false,
1538 });
1539 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1540
1541 let mut current_file_index = FileIndex::new();
1543 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1544 target_path: "install.md".to_string(),
1545 fragment: "nonexistent".to_string(),
1546 line: 3,
1547 column: 5,
1548 origin: LinkOrigin::Body,
1549 });
1550
1551 let warnings = rule
1552 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1553 .unwrap();
1554
1555 assert_eq!(warnings.len(), 1);
1557 assert!(warnings[0].message.contains("nonexistent"));
1558 assert!(warnings[0].message.contains("install.md"));
1559 }
1560
1561 #[test]
1562 fn test_cross_file_check_custom_anchor_match() {
1563 use crate::workspace_index::WorkspaceIndex;
1564
1565 let rule = MD051LinkFragments::new();
1566
1567 let mut workspace_index = WorkspaceIndex::new();
1569 let mut target_file_index = FileIndex::new();
1570 target_file_index.add_heading(HeadingIndex {
1571 text: "Installation Guide".to_string(),
1572 auto_anchor: "installation-guide".to_string(),
1573 custom_anchor: Some("install".to_string()),
1574 line: 1,
1575 is_setext: false,
1576 });
1577 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1578
1579 let mut current_file_index = FileIndex::new();
1581 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1582 target_path: "install.md".to_string(),
1583 fragment: "install".to_string(),
1584 line: 3,
1585 column: 5,
1586 origin: LinkOrigin::Body,
1587 });
1588
1589 let warnings = rule
1590 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1591 .unwrap();
1592
1593 assert!(warnings.is_empty());
1595 }
1596
1597 #[test]
1598 fn test_cross_file_check_target_not_in_workspace() {
1599 use crate::workspace_index::WorkspaceIndex;
1600
1601 let rule = MD051LinkFragments::new();
1602
1603 let workspace_index = WorkspaceIndex::new();
1605
1606 let mut current_file_index = FileIndex::new();
1608 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1609 target_path: "external.md".to_string(),
1610 fragment: "heading".to_string(),
1611 line: 3,
1612 column: 5,
1613 origin: LinkOrigin::Body,
1614 });
1615
1616 let warnings = rule
1617 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1618 .unwrap();
1619
1620 assert!(warnings.is_empty());
1622 }
1623
1624 #[test]
1625 fn test_wikilinks_skipped_in_check() {
1626 let rule = MD051LinkFragments::new();
1628
1629 let content = r#"# Test Document
1630
1631## Valid Heading
1632
1633[[Microsoft#Windows OS]]
1634[[SomePage#section]]
1635[[page|Display Text]]
1636[[path/to/page#section]]
1637"#;
1638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640
1641 assert!(
1642 result.is_empty(),
1643 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1644 );
1645 }
1646
1647 #[test]
1648 fn test_wikilinks_not_added_to_cross_file_index() {
1649 let rule = MD051LinkFragments::new();
1651
1652 let content = r#"# Test Document
1653
1654[[Microsoft#Windows OS]]
1655[[SomePage#section]]
1656[Regular Link](other.md#section)
1657"#;
1658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1659
1660 let mut file_index = FileIndex::new();
1661 rule.contribute_to_index(&ctx, &mut file_index);
1662
1663 let cross_file_links = &file_index.cross_file_links;
1666 assert_eq!(
1667 cross_file_links.len(),
1668 1,
1669 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1670 );
1671 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1672 assert_eq!(file_index.cross_file_links[0].fragment, "section");
1673 }
1674
1675 #[test]
1676 fn test_pandoc_flavor_skips_citations() {
1677 let rule = MD051LinkFragments::new();
1681 let content = "# Test Document\n\nSee [@smith2020] for details.\n";
1682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1683 let result = rule.check(&ctx).unwrap();
1684 assert!(
1685 result.is_empty(),
1686 "MD051 should skip Pandoc citations under Pandoc flavor: {result:?}"
1687 );
1688 }
1689
1690 #[test]
1691 fn md051_pandoc_resolves_pandoc_slug_diverging_from_github() {
1692 use crate::config::MarkdownFlavor;
1699 let rule = MD051LinkFragments::new();
1700 let content = "# 5. Five Things\n\nSee [details](#5.-five-things).\n";
1701
1702 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1705 let std_result = rule.check(&ctx_std).unwrap();
1706 assert_eq!(
1707 std_result.len(),
1708 1,
1709 "Standard flavor should flag the Pandoc-style fragment: {std_result:?}"
1710 );
1711
1712 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1714 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1715 assert!(
1716 pandoc_result.is_empty(),
1717 "Pandoc flavor should resolve `#5.-five-things` against the heading slug: {pandoc_result:?}"
1718 );
1719 }
1720
1721 #[test]
1725 fn md051_pandoc_flags_missing_fragment_with_email_in_link_text() {
1726 use crate::config::MarkdownFlavor;
1727 let rule = MD051LinkFragments::new();
1728 let content = "# Title\n\n[contact user@example.com](#missing)\n";
1729
1730 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1731 let std_result = rule.check(&ctx_std).unwrap();
1732 assert_eq!(
1733 std_result.len(),
1734 1,
1735 "Standard flavor must flag the missing fragment: {std_result:?}"
1736 );
1737
1738 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1739 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1740 assert_eq!(
1741 pandoc_result.len(),
1742 1,
1743 "Pandoc flavor must also flag the missing fragment — link text with embedded email is not a citation: {pandoc_result:?}"
1744 );
1745 }
1746
1747 #[test]
1751 fn md051_pandoc_flags_missing_fragment_with_citation_in_link_text() {
1752 use crate::config::MarkdownFlavor;
1753 let rule = MD051LinkFragments::new();
1754 let content = "# Title\n\n[see @smith2020](#missing)\n";
1755
1756 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1757 let std_result = rule.check(&ctx_std).unwrap();
1758 assert_eq!(
1759 std_result.len(),
1760 1,
1761 "Standard flavor must flag the missing fragment: {std_result:?}"
1762 );
1763
1764 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1765 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1766 assert_eq!(
1767 pandoc_result.len(),
1768 1,
1769 "Pandoc flavor must flag the missing fragment — `[label](url)` is a link, not a citation: {pandoc_result:?}"
1770 );
1771 }
1772
1773 #[test]
1777 fn md051_pandoc_resolves_duplicate_heading_suffix_slug() {
1778 use crate::config::MarkdownFlavor;
1779 let rule = MD051LinkFragments::new();
1780 let content = "# A.\n\nfirst\n\n# A.\n\nsecond\n\n[first](#a.) and [second](#a.-1).\n";
1781
1782 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1783 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1784 assert!(
1785 pandoc_result.is_empty(),
1786 "Pandoc flavor should resolve `#a.` and `#a.-1` against duplicate headings: {pandoc_result:?}"
1787 );
1788
1789 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1790 let quarto_result = rule.check(&ctx_quarto).unwrap();
1791 assert!(
1792 quarto_result.is_empty(),
1793 "Quarto flavor should also resolve duplicate-heading suffix slugs: {quarto_result:?}"
1794 );
1795 }
1796
1797 #[test]
1800 fn md051_pandoc_flags_overshoot_duplicate_suffix() {
1801 use crate::config::MarkdownFlavor;
1802 let rule = MD051LinkFragments::new();
1803 let content = "# A.\n\n# A.\n\n[overshoot](#a.-2)\n";
1804
1805 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1806 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1807 assert_eq!(
1808 pandoc_result.len(),
1809 1,
1810 "Pandoc must flag `#a.-2` when only `-1` exists (two duplicates): {pandoc_result:?}"
1811 );
1812 }
1813
1814 fn front_matter_checked() -> MD051Config {
1815 MD051Config {
1816 check_frontmatter: true,
1817 ..MD051Config::default()
1818 }
1819 }
1820
1821 fn check_front_matter(content: &str, config: MD051Config) -> Vec<LintWarning> {
1822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1823 MD051LinkFragments::from_config_struct(config).check(&ctx).unwrap()
1824 }
1825
1826 #[test]
1827 fn a_broken_frontmatter_fragment_is_reported_when_enabled() {
1828 let content = "---\nanchor: '#missing'\nvalid: '#title'\n---\n\n# Title\n";
1829 let result = check_front_matter(content, front_matter_checked());
1830
1831 assert_eq!(
1832 result.len(),
1833 1,
1834 "Only the unresolved fragment is reported. Got: {result:?}"
1835 );
1836 assert_eq!(
1837 result[0].message,
1838 "Link anchor '#missing' does not exist in document headings"
1839 );
1840 assert_eq!(result[0].line, 2);
1841 assert_eq!(result[0].column, 10, "The warning points at the value, not the key");
1842 assert_eq!(result[0].end_column, 18);
1843 }
1844
1845 #[test]
1846 fn frontmatter_fragments_are_not_checked_by_default() {
1847 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
1848 let result = check_front_matter(content, MD051Config::default());
1849
1850 assert!(
1851 result.is_empty(),
1852 "Frontmatter is only checked on request. Got: {result:?}"
1853 );
1854 }
1855
1856 #[test]
1857 fn an_ignored_frontmatter_field_is_not_checked() {
1858 let content = "---\nhero: '#missing'\nanchor: '#other'\n---\n\n# Title\n";
1859 let config = MD051Config {
1860 check_frontmatter: true,
1861 ignore_frontmatter_fields: vec!["Hero".to_string()],
1862 ..MD051Config::default()
1863 };
1864 let result = check_front_matter(content, config);
1865
1866 assert_eq!(
1867 result.len(),
1868 1,
1869 "The ignored field is skipped and the other is not. Got: {result:?}"
1870 );
1871 assert_eq!(result[0].line, 3);
1872 }
1873
1874 #[test]
1875 fn the_ignored_pattern_applies_to_frontmatter_fragments() {
1876 let content = "---\nnote: '#fn:1'\nanchor: '#missing'\n---\n\n# Title\n";
1877 let config = MD051Config {
1878 check_frontmatter: true,
1879 ignored_pattern: Some("^fn:".to_string()),
1880 ..MD051Config::default()
1881 };
1882 let result = check_front_matter(content, config);
1883
1884 assert_eq!(
1885 result.len(),
1886 1,
1887 "The matching fragment is skipped and the other is not. Got: {result:?}"
1888 );
1889 assert_eq!(result[0].line, 3);
1890 }
1891
1892 #[test]
1893 fn a_frontmatter_fragment_honors_ignore_case() {
1894 let content = "---\nanchor: '#Title'\n---\n\n# Title\n";
1895
1896 let permissive = check_front_matter(content, front_matter_checked());
1897 assert!(
1898 permissive.is_empty(),
1899 "The default resolves a case mismatch. Got: {permissive:?}"
1900 );
1901
1902 let strict = check_front_matter(
1903 content,
1904 MD051Config {
1905 check_frontmatter: true,
1906 ignore_case: false,
1907 ..MD051Config::default()
1908 },
1909 );
1910 assert_eq!(strict.len(), 1, "Strict matching reports it. Got: {strict:?}");
1911 }
1912
1913 #[test]
1914 fn prose_in_frontmatter_is_not_read_as_a_fragment() {
1915 let content = "---\ntitle: Node.js\ntags: ci/cd\n---\n\n# Title\n";
1916 let result = check_front_matter(content, front_matter_checked());
1917
1918 assert!(
1919 result.is_empty(),
1920 "Only path-shaped values are destinations. Got: {result:?}"
1921 );
1922 }
1923
1924 #[test]
1925 fn a_frontmatter_path_with_a_fragment_is_validated_across_files() {
1926 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1927 let source = "---\ntemplate: other.md#missing\nvalid: other.md#target\n---\n\n# Source\n";
1928
1929 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1930 let mut source_index = FileIndex::default();
1931 rule.contribute_to_index(&source_ctx, &mut source_index);
1932
1933 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1934 let mut target_index = FileIndex::default();
1935 rule.contribute_to_index(&target_ctx, &mut target_index);
1936
1937 let source_path = PathBuf::from("docs/source.md");
1938 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1939 workspace.insert_file(source_path.clone(), source_index.clone());
1940 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1941
1942 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1943
1944 assert_eq!(
1945 warnings.len(),
1946 1,
1947 "Only the unresolved fragment is reported. Got: {warnings:?}"
1948 );
1949 assert_eq!(warnings[0].message, "Link fragment 'missing' not found in 'other.md'");
1950 assert_eq!(warnings[0].line, 2);
1951 assert_eq!(warnings[0].column, 11);
1952 }
1953
1954 #[test]
1955 fn a_query_string_does_not_hide_the_target_file() {
1956 let rule = MD051LinkFragments::new();
1957 let source = "# Source\n\n- [a](other.md?raw=true#missing)\n- [b](other.md?raw=true#target)\n";
1958
1959 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1960 let mut source_index = FileIndex::default();
1961 rule.contribute_to_index(&source_ctx, &mut source_index);
1962
1963 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1964 let mut target_index = FileIndex::default();
1965 rule.contribute_to_index(&target_ctx, &mut target_index);
1966
1967 let source_path = PathBuf::from("docs/source.md");
1968 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1969 workspace.insert_file(source_path.clone(), source_index.clone());
1970 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1971
1972 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1973
1974 assert_eq!(
1975 warnings.len(),
1976 1,
1977 "The query is stripped to find the file, so both fragments resolve against it. Got: {warnings:?}"
1978 );
1979 assert_eq!(
1980 warnings[0].message,
1981 "Link fragment 'missing' not found in 'other.md?raw=true'"
1982 );
1983 assert_eq!(warnings[0].line, 3);
1984 }
1985
1986 #[test]
1987 fn a_query_string_does_not_hide_an_extensionless_target_file() {
1988 let rule = MD051LinkFragments::new();
1989 let source = "# Source\n\n- [a](other?raw=true#target)\n- [b](other#target)\n- [c](other?raw=true#absent)\n";
1990
1991 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1992 let same_document = rule.check(&source_ctx).unwrap();
1993 assert!(
1994 same_document.is_empty(),
1995 "Every fragment here belongs to another file, so none is a missing anchor of this one. Got: {same_document:?}"
1996 );
1997
1998 let mut source_index = FileIndex::default();
1999 rule.contribute_to_index(&source_ctx, &mut source_index);
2000
2001 let target_ctx = LintContext::new("# Other\n\n## Target\n", crate::config::MarkdownFlavor::Standard, None);
2002 let mut target_index = FileIndex::default();
2003 rule.contribute_to_index(&target_ctx, &mut target_index);
2004
2005 let source_path = PathBuf::from("docs/source.md");
2006 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
2007 workspace.insert_file(source_path.clone(), source_index.clone());
2008 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
2009
2010 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
2011
2012 assert_eq!(
2013 warnings.len(),
2014 1,
2015 "The query is stripped before the markdown extension is added. Got: {warnings:?}"
2016 );
2017 assert_eq!(
2018 warnings[0].message,
2019 "Link fragment 'absent' not found in 'other?raw=true'"
2020 );
2021 assert_eq!(warnings[0].line, 5);
2022 }
2023
2024 #[test]
2025 fn a_destination_that_is_only_a_query_stays_on_this_page() {
2026 let rule = MD051LinkFragments::new();
2027 let source = "# Source\n\n## Here\n\n- [a](?raw=true#here)\n- [b](?raw=true#nowhere)\n";
2028
2029 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2030 let warnings = rule.check(&source_ctx).unwrap();
2031
2032 assert_eq!(
2033 warnings.len(),
2034 1,
2035 "Only the absent anchor is reported. Got: {warnings:?}"
2036 );
2037 assert_eq!(
2038 warnings[0].message,
2039 "Link anchor '#nowhere' does not exist in document headings"
2040 );
2041 assert_eq!(warnings[0].line, 6);
2042
2043 let mut source_index = FileIndex::default();
2044 rule.contribute_to_index(&source_ctx, &mut source_index);
2045 assert!(
2046 source_index.cross_file_links.is_empty(),
2047 "A query with no path names no other file. Got: {:?}",
2048 source_index.cross_file_links
2049 );
2050 }
2051
2052 #[test]
2053 fn blockquote_syntax_inside_raw_html_does_not_create_an_anchor() {
2054 let rule = MD051LinkFragments::new();
2055 let source = "<div>\n> ## Hidden\n</div>\n\n[link](#hidden)\n";
2056 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2057
2058 let warnings = rule.check(&ctx).unwrap();
2059 assert_eq!(
2060 warnings.len(),
2061 1,
2062 "raw HTML must not satisfy the fragment: {warnings:?}"
2063 );
2064
2065 let mut file_index = FileIndex::default();
2066 rule.contribute_to_index(&ctx, &mut file_index);
2067 assert!(
2068 file_index.headings.is_empty(),
2069 "raw HTML must not enter the workspace index"
2070 );
2071 }
2072
2073 #[test]
2074 fn a_frontmatter_path_carrying_a_query_is_indexed() {
2075 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
2076 let source = "---\ntemplate: docs/other.md?raw=true#missing\n---\n\n# Source\n";
2077
2078 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2079 let mut source_index = FileIndex::default();
2080 rule.contribute_to_index(&source_ctx, &mut source_index);
2081
2082 assert_eq!(source_index.cross_file_links.len(), 1);
2083 assert_eq!(source_index.cross_file_links[0].target_path, "docs/other.md?raw=true");
2084 assert_eq!(source_index.cross_file_links[0].fragment, "missing");
2085 }
2086
2087 #[test]
2091 fn frontmatter_cross_file_paths_are_not_reported_by_default() {
2092 use crate::workspace_index::WorkspaceIndex;
2093
2094 let rule = MD051LinkFragments::new();
2095 let source = "---\ntemplate: other.md#missing\n---\n\n# Source\n";
2096
2097 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2098 let mut source_index = FileIndex::default();
2099 rule.contribute_to_index(&source_ctx, &mut source_index);
2100 assert_eq!(source_index.cross_file_links.len(), 1);
2101
2102 let mut workspace_index = WorkspaceIndex::new();
2103 let mut target = FileIndex::new();
2104 target.add_heading(HeadingIndex {
2105 text: "Present".to_string(),
2106 auto_anchor: "present".to_string(),
2107 custom_anchor: None,
2108 line: 1,
2109 is_setext: false,
2110 });
2111 workspace_index.insert_file(PathBuf::from("other.md"), target);
2112
2113 let warnings = rule
2114 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2115 .unwrap();
2116 assert!(
2117 warnings.is_empty(),
2118 "Frontmatter is only checked on request. Got: {warnings:?}"
2119 );
2120
2121 let checking = MD051LinkFragments::from_config_struct(MD051Config {
2125 check_frontmatter: true,
2126 ..Default::default()
2127 });
2128 assert_eq!(
2129 checking
2130 .cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
2131 .unwrap()
2132 .len(),
2133 1
2134 );
2135 }
2136}