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::rules::{LinkResolution, LinkResolver};
5use crate::utils::anchor_styles::AnchorStyle;
6use crate::utils::frontmatter_values;
7use crate::utils::header_id_utils::{HTML_BLOCK_OPEN_TAG, HTML_OPEN_TAG, html_tag_attribute, is_backslash_escaped};
8use crate::utils::range_utils::byte_to_char_count;
9use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex, LinkOrigin};
10use pulldown_cmark::LinkType;
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use std::path::Path;
15use std::sync::LazyLock;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19#[serde(rename_all = "kebab-case")]
20pub struct MD051Config {
21 #[serde(default, alias = "anchor_style")]
23 pub anchor_style: AnchorStyle,
24
25 #[serde(default = "default_ignore_case", alias = "ignore_case")]
31 pub ignore_case: bool,
32
33 #[serde(default, alias = "ignored_pattern")]
37 pub ignored_pattern: Option<String>,
38
39 #[serde(default)]
58 pub check_frontmatter: bool,
59
60 #[serde(default)]
64 pub ignore_frontmatter_fields: Vec<String>,
65}
66
67fn default_ignore_case() -> bool {
68 true
69}
70
71impl Default for MD051Config {
72 fn default() -> Self {
73 Self {
74 anchor_style: AnchorStyle::default(),
75 ignore_case: true,
76 ignored_pattern: None,
77 check_frontmatter: false,
78 ignore_frontmatter_fields: Vec::new(),
79 }
80 }
81}
82
83impl RuleConfig for MD051Config {
84 const RULE_NAME: &'static str = "MD051";
85}
86
87fn for_each_html_anchor_target(ctx: &LintContext, line_info: &LineInfo, mut record: impl FnMut(&str)) {
98 let content = line_info.content(ctx.content);
99 if !content.contains('<') {
100 return;
101 }
102
103 let escapes_apply = !line_info.in_html_block;
104 let open_tags: &Regex = if line_info.in_html_block {
105 &HTML_BLOCK_OPEN_TAG
106 } else {
107 &HTML_OPEN_TAG
108 };
109 let mut pos = 0;
110 while let Some(tag) = open_tags.captures_at(content, pos) {
111 let whole = tag.get(0).unwrap();
112 let byte_pos = line_info.byte_offset + whole.start();
113 if ctx.is_in_code_span_byte(byte_pos)
114 || ctx.is_in_html_comment(byte_pos)
115 || ctx.image_containing(byte_pos).is_some()
116 || (escapes_apply && is_backslash_escaped(content, whole.start()))
117 {
118 pos = whole.start() + 1;
120 continue;
121 }
122 pos = whole.end();
123
124 if let Some(id) = html_tag_attribute(whole.as_str(), "id") {
125 record(id);
126 }
127 if tag[1].eq_ignore_ascii_case("a")
128 && let Some(name) = html_tag_attribute(whole.as_str(), "name")
129 {
130 record(name);
131 }
132 }
133}
134
135static ATTR_ANCHOR_PATTERN: LazyLock<Regex> =
139 LazyLock::new(|| Regex::new(r#"\{\s*#([a-zA-Z0-9_][a-zA-Z0-9_-]*)[^}]*\}"#).unwrap());
140
141static MD_SETTING_PATTERN: LazyLock<Regex> =
144 LazyLock::new(|| Regex::new(r"<!--\s*md:setting\s+([^\s]+)\s*-->").unwrap());
145
146#[derive(Clone)]
153pub struct MD051LinkFragments {
154 config: MD051Config,
155 ignored_pattern_regex: Option<Regex>,
159 ignored_front_matter_fields: HashSet<String>,
161 anchor_style_pinned: bool,
165 link_resolver: LinkResolver,
168}
169
170struct AnchorSets {
175 markdown_headings: HashSet<String>,
176 markdown_headings_exact: HashSet<String>,
177 html_anchors: HashSet<String>,
178 html_anchors_exact: HashSet<String>,
179}
180
181impl Default for MD051LinkFragments {
182 fn default() -> Self {
183 Self::new()
184 }
185}
186
187impl MD051LinkFragments {
188 pub fn new() -> Self {
189 Self::from_config_struct(MD051Config::default())
190 }
191
192 pub fn with_anchor_style(style: AnchorStyle) -> Self {
194 Self::from_config_struct(MD051Config {
195 anchor_style: style,
196 ..MD051Config::default()
197 })
198 }
199
200 pub fn from_config_struct(config: MD051Config) -> Self {
206 Self::from_config_struct_from(config, false)
207 }
208
209 fn from_config_struct_from(config: MD051Config, values_withheld: bool) -> Self {
212 Self::build(config, values_withheld, true)
213 }
214
215 fn build(config: MD051Config, values_withheld: bool, anchor_style_pinned: bool) -> Self {
219 let ignored_pattern_regex = config.ignored_pattern.as_deref().and_then(|pattern| {
220 crate::rule_config_serde::compile_config_regex(pattern, "MD051", "ignored-pattern", values_withheld)
221 });
222 let ignored_front_matter_fields = config
223 .ignore_frontmatter_fields
224 .iter()
225 .map(|field| field.to_lowercase())
226 .collect();
227 Self {
228 config,
229 ignored_pattern_regex,
230 ignored_front_matter_fields,
231 anchor_style_pinned,
232 link_resolver: LinkResolver::default(),
233 }
234 }
235
236 fn anchor_style(&self, ctx: &crate::lint_context::LintContext) -> AnchorStyle {
243 if self.anchor_style_pinned {
244 self.config.anchor_style
245 } else {
246 AnchorStyle::for_flavor(ctx.flavor)
247 }
248 }
249
250 fn insert_deduplicated_fragment(
258 fragment: String,
259 fragment_counts: &mut HashMap<String, usize>,
260 markdown_headings: &mut HashSet<String>,
261 mut markdown_headings_exact: Option<&mut HashSet<String>>,
262 use_underscore_dedup: bool,
263 ) {
264 let mut also_insert_exact = |form: &str| {
270 if let Some(set) = markdown_headings_exact.as_deref_mut() {
271 set.insert(form.to_string());
272 }
273 };
274
275 if fragment.is_empty() {
276 if !use_underscore_dedup {
277 return;
278 }
279 let count = fragment_counts.entry(fragment).or_insert(0);
281 *count += 1;
282 let formed = format!("_{count}");
283 also_insert_exact(&formed);
284 markdown_headings.insert(formed);
285 return;
286 }
287 if let Some(count) = fragment_counts.get_mut(&fragment) {
288 let suffix = *count;
289 *count += 1;
290 if use_underscore_dedup {
291 let underscore_form = format!("{fragment}_{suffix}");
293 also_insert_exact(&underscore_form);
294 markdown_headings.insert(underscore_form);
295 let dash_form = format!("{fragment}-{suffix}");
297 also_insert_exact(&dash_form);
298 markdown_headings.insert(dash_form);
299 } else {
300 let form = format!("{fragment}-{suffix}");
302 also_insert_exact(&form);
303 markdown_headings.insert(form);
304 }
305 } else {
306 fragment_counts.insert(fragment.clone(), 1);
307 also_insert_exact(&fragment);
308 markdown_headings.insert(fragment);
309 }
310 }
311
312 #[allow(clippy::too_many_arguments)]
322 fn add_heading_to_index(
323 fragment: &str,
324 text: &str,
325 custom_anchor: Option<String>,
326 line: usize,
327 text_lines: usize,
328 is_setext: bool,
329 fragment_counts: &mut HashMap<String, usize>,
330 file_index: &mut FileIndex,
331 use_underscore_dedup: bool,
332 ) {
333 if fragment.is_empty() {
334 if !use_underscore_dedup {
335 return;
336 }
337 let count = fragment_counts.entry(fragment.to_string()).or_insert(0);
339 *count += 1;
340 file_index.add_heading(HeadingIndex {
341 text: text.to_string(),
342 auto_anchor: format!("_{count}"),
343 custom_anchor,
344 line,
345 text_lines,
346 is_setext,
347 });
348 return;
349 }
350 if let Some(count) = fragment_counts.get_mut(fragment) {
351 let suffix = *count;
352 *count += 1;
353 let (primary, alias) = if use_underscore_dedup {
354 (format!("{fragment}_{suffix}"), Some(format!("{fragment}-{suffix}")))
356 } else {
357 (format!("{fragment}-{suffix}"), None)
359 };
360 file_index.add_heading(HeadingIndex {
361 text: text.to_string(),
362 auto_anchor: primary,
363 custom_anchor,
364 line,
365 text_lines,
366 is_setext,
367 });
368 if let Some(alias_anchor) = alias {
369 let heading_idx = file_index.headings.len() - 1;
370 file_index.add_anchor_alias(&alias_anchor, heading_idx);
371 }
372 } else {
373 fragment_counts.insert(fragment.to_string(), 1);
374 file_index.add_heading(HeadingIndex {
375 text: text.to_string(),
376 auto_anchor: fragment.to_string(),
377 custom_anchor,
378 line,
379 text_lines,
380 is_setext,
381 });
382 }
383 }
384
385 fn extract_headings_from_context(&self, ctx: &crate::lint_context::LintContext) -> AnchorSets {
392 let track_exact = !self.config.ignore_case;
393 let mut markdown_headings = HashSet::with_capacity(32);
394 let mut markdown_headings_exact = if track_exact {
395 HashSet::with_capacity(32)
396 } else {
397 HashSet::new()
398 };
399 let mut html_anchors = HashSet::with_capacity(16);
400 let mut html_anchors_exact = if track_exact {
401 HashSet::with_capacity(16)
402 } else {
403 HashSet::new()
404 };
405 let mut fragment_counts = std::collections::HashMap::new();
406 let anchor_style = self.anchor_style(ctx);
407 let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
408
409 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
410 if line_info.in_front_matter {
411 continue;
412 }
413
414 if line_info.in_code_block {
416 continue;
417 }
418
419 let content = line_info.content(ctx.content);
420
421 for_each_html_anchor_target(ctx, line_info, |id| {
422 html_anchors.insert(id.to_lowercase());
423 if track_exact {
424 html_anchors_exact.insert(id.to_string());
425 }
426 });
427
428 let parsed_heading = ctx.heading_on_line(line_idx + 1);
433 if parsed_heading.is_none()
434 && !line_info.is_setext_heading_text
435 && content.contains('{')
436 && content.contains('#')
437 {
438 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
439 if let Some(id_match) = caps.get(1) {
440 let id = id_match.as_str();
441 markdown_headings.insert(id.to_lowercase());
442 if track_exact {
443 markdown_headings_exact.insert(id.to_string());
444 }
445 }
446 }
447 }
448
449 if let Some(parsed) = parsed_heading {
451 let heading = parsed.heading;
452 if let Some(custom_id) = &heading.custom_id {
454 markdown_headings.insert(custom_id.to_lowercase());
455 if track_exact {
456 markdown_headings_exact.insert(custom_id.clone());
457 }
458 }
459
460 let fragment = anchor_style.generate_fragment(&heading.slug_text);
465
466 Self::insert_deduplicated_fragment(
467 fragment,
468 &mut fragment_counts,
469 &mut markdown_headings,
470 track_exact.then_some(&mut markdown_headings_exact),
471 use_underscore_dedup,
472 );
473 }
474 }
475
476 AnchorSets {
477 markdown_headings,
478 markdown_headings_exact,
479 html_anchors,
480 html_anchors_exact,
481 }
482 }
483
484 #[inline]
486 fn is_external_url_fast(url: &str) -> bool {
487 url.starts_with("http://")
489 || url.starts_with("https://")
490 || url.starts_with("ftp://")
491 || url.starts_with("mailto:")
492 || url.starts_with("tel:")
493 || url.starts_with("//")
494 }
495
496 fn cross_file_parts(url: &str) -> Option<(&str, &str)> {
507 let fragment_pos = url.find('#')?;
508 if crate::workspace_index::strip_query_and_fragment(url).is_empty() || Self::is_external_url_fast(url) {
509 return None;
510 }
511 Some((&url[..fragment_pos], &url[fragment_pos + 1..]))
512 }
513
514 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
519 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
520 }
521
522 fn front_matter_links(&self, ctx: &crate::lint_context::LintContext) -> Vec<frontmatter_values::FrontMatterLink> {
525 if !self.checks_front_matter_of(ctx) {
526 return Vec::new();
527 }
528 frontmatter_values::link_destinations(ctx)
529 .into_iter()
530 .filter(|link| !link.field_is_in(&self.ignored_front_matter_fields))
531 .collect()
532 }
533
534 fn reports_link_from(&self, origin: &LinkOrigin) -> bool {
541 match origin {
542 LinkOrigin::Body => true,
543 LinkOrigin::FrontMatter { field } => {
544 self.config.check_frontmatter
545 && !field
546 .as_ref()
547 .is_some_and(|field| self.ignored_front_matter_fields.contains(field))
548 }
549 }
550 }
551
552 fn fragment_is_exempt(&self, ctx: &crate::lint_context::LintContext, fragment: &str) -> bool {
555 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
560 && (fragment.starts_with("fn:")
561 || fragment.starts_with("fnref:")
562 || (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
563 {
564 return true;
565 }
566
567 self.ignored_pattern_regex
569 .as_ref()
570 .is_some_and(|re| re.is_match(fragment))
571 }
572
573 fn fragment_resolves(&self, fragment: &str, anchors: &AnchorSets) -> bool {
577 if self.config.ignore_case {
578 let lower = fragment.to_lowercase();
579 anchors.html_anchors.contains(&lower) || anchors.markdown_headings.contains(&lower)
580 } else {
581 anchors.html_anchors_exact.contains(fragment) || anchors.markdown_headings_exact.contains(fragment)
582 }
583 }
584
585 fn check_front_matter(
592 &self,
593 ctx: &crate::lint_context::LintContext,
594 links: &[frontmatter_values::FrontMatterLink],
595 anchors: &AnchorSets,
596 warnings: &mut Vec<LintWarning>,
597 ) {
598 for link in links {
599 let line = ctx.lines[link.line - 1].content(ctx.content);
600 let Some(fragment) = line[link.range.clone()].strip_prefix('#') else {
601 continue;
602 };
603 if fragment.is_empty() {
604 continue;
605 }
606
607 if ctx.flavor.is_pandoc_compatible() && ctx.has_pandoc_slug(fragment) {
610 continue;
611 }
612
613 if self.fragment_is_exempt(ctx, fragment) || self.fragment_resolves(fragment, anchors) {
614 continue;
615 }
616
617 let column = byte_to_char_count(line, link.range.start);
618 warnings.push(LintWarning {
619 rule_name: Some(self.name().to_string()),
620 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
621 line: link.line,
622 column,
623 end_line: link.line,
624 end_column: column + 1 + fragment.chars().count(),
625 severity: Severity::Error,
626 fix: None,
627 });
628 }
629 }
630}
631
632impl Rule for MD051LinkFragments {
633 fn name(&self) -> &'static str {
634 "MD051"
635 }
636
637 fn description(&self) -> &'static str {
638 "Link fragments should reference valid headings"
639 }
640
641 fn fix_capability(&self) -> FixCapability {
642 FixCapability::Unfixable
643 }
644
645 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
646 if !ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx) {
650 return true;
651 }
652 !ctx.has_char('#')
654 }
655
656 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
657 let mut warnings = Vec::new();
658
659 if ctx.content.is_empty() || self.should_skip(ctx) {
660 return Ok(warnings);
661 }
662
663 let front_matter_links = self.front_matter_links(ctx);
664 if ctx.links().is_empty() && front_matter_links.is_empty() {
665 return Ok(warnings);
666 }
667
668 let anchors = self.extract_headings_from_context(ctx);
669
670 for link in ctx.links() {
671 if link.is_reference {
672 continue;
673 }
674
675 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
677 continue;
678 }
679
680 if matches!(link.link_type, LinkType::WikiLink { .. }) {
682 continue;
683 }
684
685 if ctx.is_in_jinja_range(link.byte_offset) {
687 continue;
688 }
689
690 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
693 continue;
694 }
695
696 if ctx.is_in_shortcode(link.byte_offset) {
699 continue;
700 }
701
702 let url = &link.url;
703
704 if !url.contains('#') || Self::is_external_url_fast(url) {
706 continue;
707 }
708
709 if url.contains("{{#") && url.contains("}}") {
712 continue;
713 }
714
715 if ctx.flavor.is_pandoc_compatible()
721 && let Some(frag) = url.strip_prefix('#')
722 && ctx.has_pandoc_slug(frag)
723 {
724 continue;
725 }
726
727 if url.starts_with('@') {
731 continue;
732 }
733
734 if !crate::workspace_index::strip_query_and_fragment(url).is_empty() {
737 continue;
738 }
739
740 let Some(fragment_pos) = url.find('#') else {
741 continue;
742 };
743
744 let fragment = &url[fragment_pos + 1..];
745
746 if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
748 continue;
749 }
750
751 if fragment.is_empty() {
752 continue;
753 }
754
755 if self.fragment_is_exempt(ctx, fragment) {
756 continue;
757 }
758
759 if !self.fragment_resolves(fragment, &anchors) {
760 warnings.push(LintWarning {
761 rule_name: Some(self.name().to_string()),
762 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
763 line: link.line,
764 column: link.start_col + 1,
765 end_line: link.end_line,
766 end_column: link.end_col + 1,
767 severity: Severity::Error,
768 fix: None,
769 });
770 }
771 }
772
773 self.check_front_matter(ctx, &front_matter_links, &anchors, &mut warnings);
774
775 Ok(warnings)
776 }
777
778 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
779 Ok(ctx.content.to_string())
782 }
783
784 fn as_any(&self) -> &dyn std::any::Any {
785 self
786 }
787
788 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
789 where
790 Self: Sized,
791 {
792 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD051Config>(config);
793
794 let explicit_style_present = config
799 .rules
800 .get("MD051")
801 .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
802 if !explicit_style_present {
803 rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
804 }
805
806 let mut rule = MD051LinkFragments::build(
807 rule_config,
808 config.withheld_rule_values.contains("MD051"),
809 explicit_style_present,
810 );
811 rule.link_resolver = LinkResolver::from_config(config);
812 Box::new(rule)
813 }
814
815 fn category(&self) -> RuleCategory {
816 RuleCategory::Link
817 }
818
819 fn skippable_by_category(&self) -> bool {
820 !self.config.check_frontmatter
823 }
824
825 fn cross_file_scope(&self) -> CrossFileScope {
826 CrossFileScope::Workspace
827 }
828
829 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
830 let mut fragment_counts = HashMap::new();
831 let anchor_style = self.anchor_style(ctx);
832 let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
833
834 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
836 if line_info.in_front_matter {
837 continue;
838 }
839
840 if line_info.in_code_block {
842 continue;
843 }
844
845 let content = line_info.content(ctx.content);
846
847 for_each_html_anchor_target(ctx, line_info, |id| file_index.add_html_anchor(id));
848
849 let parsed_heading = ctx.heading_on_line(line_idx + 1);
854 if parsed_heading.is_none()
855 && !line_info.is_setext_heading_text
856 && content.contains('{')
857 && content.contains('#')
858 {
859 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
860 if let Some(id_match) = caps.get(1) {
861 file_index.add_attribute_anchor(id_match.as_str());
862 }
863 }
864 }
865
866 if let Some(parsed) = parsed_heading {
868 let heading = parsed.heading;
869 let fragment = anchor_style.generate_fragment(&heading.slug_text);
870
871 Self::add_heading_to_index(
872 &fragment,
873 &heading.text,
874 heading.custom_id.clone(),
875 line_idx + 1,
876 heading.text_lines,
877 parsed.is_setext(),
878 &mut fragment_counts,
879 file_index,
880 use_underscore_dedup,
881 );
882
883 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
888 && let Some(caps) = MD_SETTING_PATTERN.captures(content)
889 && let Some(name) = caps.get(1)
890 {
891 file_index.add_html_anchor(name.as_str());
892 }
893 }
894 }
895
896 for link in ctx.links() {
898 if link.is_reference {
899 continue;
900 }
901
902 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
904 continue;
905 }
906
907 if matches!(link.link_type, LinkType::WikiLink { .. }) {
910 continue;
911 }
912
913 if let Some((path_part, fragment)) = Self::cross_file_parts(&link.url) {
914 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
916 continue;
917 }
918
919 file_index.add_cross_file_link(CrossFileLinkIndex {
920 target_path: path_part.to_string(),
921 fragment: fragment.to_string(),
922 line: link.line,
923 column: link.start_col + 1,
924 origin: LinkOrigin::Body,
925 });
926 }
927 }
928
929 for link in frontmatter_values::link_destinations(ctx) {
936 let line = ctx.lines[link.line - 1].content(ctx.content);
937 let value = &line[link.range.clone()];
938
939 let Some((path_part, fragment)) = Self::cross_file_parts(value) else {
940 continue;
941 };
942
943 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
945 continue;
946 }
947
948 file_index.add_cross_file_link(CrossFileLinkIndex {
949 target_path: path_part.to_string(),
950 fragment: fragment.to_string(),
951 line: link.line,
952 column: byte_to_char_count(line, link.range.start),
953 origin: LinkOrigin::FrontMatter { field: link.field },
954 });
955 }
956 }
957
958 fn cross_file_check(
959 &self,
960 file_path: &Path,
961 file_index: &FileIndex,
962 workspace_index: &crate::workspace_index::WorkspaceIndex,
963 ) -> LintResult {
964 let mut warnings = Vec::new();
965
966 let ignored_pattern = self.ignored_pattern_regex.as_ref();
967 let ignore_case = self.config.ignore_case;
968 let mut document_links = None;
972 let policy = workspace_index.link_target_policy();
973
974 for cross_link in &file_index.cross_file_links {
976 if cross_link.fragment.is_empty() {
978 continue;
979 }
980
981 if !self.reports_link_from(&cross_link.origin) {
984 continue;
985 }
986
987 if ignored_pattern.is_some_and(|re| re.is_match(&cross_link.fragment)) {
989 continue;
990 }
991
992 let document_links = document_links.get_or_insert_with(|| self.link_resolver.for_document(file_path));
995 let target_file_index = match document_links.resolve(&cross_link.target_path, policy) {
996 LinkResolution::Target(target) => workspace_index.get_resolved_file(&target).or_else(|| {
1001 let identity = target.canonicalize().ok()?;
1002 crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path)
1003 .iter()
1004 .filter(|candidate| candidate.canonicalize().is_ok_and(|path| path == identity))
1005 .find_map(|candidate| workspace_index.get_resolved_file(candidate))
1006 }),
1007 LinkResolution::Missing => {
1014 crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path)
1015 .iter()
1016 .filter_map(|candidate| Some((candidate, workspace_index.get_file(candidate)?)))
1017 .find(|(candidate, _)| matches!(candidate.try_exists(), Ok(false)))
1018 .map(|(_, index)| index)
1019 }
1020 LinkResolution::Unchecked => {
1023 crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path)
1024 .iter()
1025 .find_map(|target| workspace_index.get_file(target))
1026 }
1027 };
1028
1029 if let Some(target_file_index) = target_file_index {
1030 if !target_file_index.has_anchor_with_case(&cross_link.fragment, ignore_case) {
1032 warnings.push(LintWarning {
1033 rule_name: Some(self.name().to_string()),
1034 line: cross_link.line,
1035 column: cross_link.column,
1036 end_line: cross_link.line,
1037 end_column: cross_link.column
1038 + cross_link.target_path.chars().count()
1039 + 1
1040 + cross_link.fragment.chars().count(),
1041 message: format!(
1042 "Link fragment '{}' not found in '{}'",
1043 cross_link.fragment, cross_link.target_path
1044 ),
1045 severity: Severity::Error,
1046 fix: None,
1047 });
1048 }
1049 }
1050 }
1056
1057 Ok(warnings)
1058 }
1059
1060 crate::impl_rule_config_sections!(MD051Config);
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065 use super::*;
1066 use crate::lint_context::LintContext;
1067 use std::path::PathBuf;
1068
1069 fn files_on_disk(files: &[&str]) -> (tempfile::TempDir, PathBuf) {
1073 let dir = tempfile::tempdir().unwrap();
1074 let root = dir.path().canonicalize().unwrap();
1075 for file in files {
1076 let path = root.join(file);
1077 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1078 std::fs::write(&path, "").unwrap();
1079 }
1080 (dir, root)
1081 }
1082
1083 const ANCHOR_STYLE_PROBE: &str = "### Getting Started — Advanced\n\n\
1087 [python-markdown slug](#getting-started-advanced)\n\
1088 [github slug](#getting-started--advanced)\n";
1089
1090 fn flagged_fragment(rule: &dyn Rule, flavor: crate::config::MarkdownFlavor) -> String {
1091 let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
1092 let warnings = rule.check(&ctx).unwrap();
1093 assert_eq!(
1094 warnings.len(),
1095 1,
1096 "exactly one of the two links must be invalid under any style: {warnings:?}"
1097 );
1098 warnings[0].message.clone()
1099 }
1100
1101 #[test]
1105 fn test_unpinned_anchor_style_follows_the_file_flavor() {
1106 let rule_from_global = |flavor| {
1107 let mut config = crate::config::Config::default();
1108 config.global.flavor = flavor;
1109 MD051LinkFragments::from_config(&config)
1110 };
1111
1112 let standard_global = rule_from_global(crate::config::MarkdownFlavor::Standard);
1114 assert!(
1117 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1118 .contains("#getting-started-advanced'"),
1119 "a standard file must be checked against GitHub anchors"
1120 );
1121 assert!(
1124 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1125 .contains("#getting-started--advanced'"),
1126 "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
1127 );
1128
1129 let mkdocs_global = rule_from_global(crate::config::MarkdownFlavor::MkDocs);
1131 assert!(
1132 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
1133 .contains("#getting-started--advanced'"),
1134 "a mkdocs file must be checked against Python-Markdown anchors"
1135 );
1136 assert!(
1137 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::Standard)
1138 .contains("#getting-started-advanced'"),
1139 "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
1140 );
1141 }
1142
1143 #[test]
1146 fn test_pinned_anchor_style_ignores_the_file_flavor() {
1147 let mut config = crate::config::Config::default();
1148 config.global.flavor = crate::config::MarkdownFlavor::Standard;
1149 let mut rule_config = crate::config::RuleConfig::default();
1150 rule_config
1151 .values
1152 .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
1153 config.rules.insert("MD051".to_string(), rule_config);
1154 let rule = MD051LinkFragments::from_config(&config);
1155
1156 for flavor in [
1157 crate::config::MarkdownFlavor::Standard,
1158 crate::config::MarkdownFlavor::MkDocs,
1159 crate::config::MarkdownFlavor::Kramdown,
1160 ] {
1161 assert!(
1162 flagged_fragment(rule.as_ref(), flavor).contains("#getting-started-advanced'"),
1163 "pinned github anchors must survive a {flavor:?} file"
1164 );
1165 }
1166 }
1167
1168 #[test]
1171 fn test_directly_constructed_rule_keeps_its_anchor_style() {
1172 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1173 anchor_style: AnchorStyle::PythonMarkdown,
1174 ..Default::default()
1175 });
1176 assert!(
1177 flagged_fragment(&rule, crate::config::MarkdownFlavor::Standard).contains("#getting-started--advanced'"),
1178 "an explicitly constructed Python-Markdown rule must not follow the file flavor"
1179 );
1180 }
1181
1182 #[test]
1183 fn test_quarto_cross_references() {
1184 let rule = MD051LinkFragments::new();
1185
1186 let content = r#"# Test Document
1188
1189## Figures
1190
1191See [@fig-plot] for the visualization.
1192
1193More details in [@tbl-results] and [@sec-methods].
1194
1195The equation [@eq-regression] shows the relationship.
1196
1197Reference to [@lst-code] for implementation."#;
1198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1199 let result = rule.check(&ctx).unwrap();
1200 assert!(
1201 result.is_empty(),
1202 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
1203 result.len()
1204 );
1205
1206 let content_with_anchor = r#"# Test
1208
1209See [link](#test) for details."#;
1210 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
1211 let result_anchor = rule.check(&ctx_anchor).unwrap();
1212 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
1213
1214 let content_invalid = r#"# Test
1216
1217See [link](#nonexistent) for details."#;
1218 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
1219 let result_invalid = rule.check(&ctx_invalid).unwrap();
1220 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
1221 }
1222
1223 #[test]
1224 fn test_jsx_in_heading_anchor() {
1225 let rule = MD051LinkFragments::new();
1227
1228 let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
1230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1231 let result = rule.check(&ctx).unwrap();
1232 assert!(
1233 result.is_empty(),
1234 "JSX self-closing tag should be stripped from anchor: got {result:?}"
1235 );
1236
1237 let content2 =
1239 "### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
1240 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1241 let result2 = rule.check(&ctx2).unwrap();
1242 assert!(
1243 result2.is_empty(),
1244 "JSX tag with attributes should be stripped from anchor: got {result2:?}"
1245 );
1246
1247 let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
1249 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1250 let result3 = rule.check(&ctx3).unwrap();
1251 assert!(
1252 result3.is_empty(),
1253 "HTML tag content should be preserved in anchor: got {result3:?}"
1254 );
1255 }
1256
1257 #[test]
1259 fn test_cross_file_scope() {
1260 let rule = MD051LinkFragments::new();
1261 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1262 }
1263
1264 #[test]
1265 fn test_contribute_to_index_extracts_headings() {
1266 let rule = MD051LinkFragments::new();
1267 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
1268 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1269
1270 let mut file_index = FileIndex::new();
1271 rule.contribute_to_index(&ctx, &mut file_index);
1272
1273 assert_eq!(file_index.headings.len(), 3);
1274 assert_eq!(file_index.headings[0].text, "First Heading");
1275 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
1276 assert!(file_index.headings[0].custom_anchor.is_none());
1277
1278 assert_eq!(file_index.headings[1].text, "Second");
1279 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
1280
1281 assert_eq!(file_index.headings[2].text, "Third");
1282 }
1283
1284 #[test]
1285 fn test_contribute_to_index_extracts_cross_file_links() {
1286 let rule = MD051LinkFragments::new();
1287 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
1288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289
1290 let mut file_index = FileIndex::new();
1291 rule.contribute_to_index(&ctx, &mut file_index);
1292
1293 assert_eq!(file_index.cross_file_links.len(), 2);
1294 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1295 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
1296 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
1297 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
1298 }
1299
1300 #[test]
1304 fn test_contribute_to_index_records_setext_headings() {
1305 let rule = MD051LinkFragments::new();
1306 let content = "Setext One\n==========\n\nSetext Two\n----------\n\n### Atx Three\n";
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308
1309 let mut file_index = FileIndex::new();
1310 rule.contribute_to_index(&ctx, &mut file_index);
1311
1312 let styles: Vec<(&str, bool)> = file_index
1313 .headings
1314 .iter()
1315 .map(|h| (h.text.as_str(), h.is_setext))
1316 .collect();
1317 assert_eq!(
1318 styles,
1319 vec![("Setext One", true), ("Setext Two", true), ("Atx Three", false)]
1320 );
1321 }
1322
1323 #[test]
1331 fn test_a_frontmatter_link_is_indexed_regardless_of_the_indexing_config() {
1332 let content = "---\nlink: 'other.md#nope'\n---\n\n# Real\n";
1333 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334
1335 for check_frontmatter in [true, false] {
1336 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1337 check_frontmatter,
1338 ..Default::default()
1339 });
1340 let mut file_index = FileIndex::new();
1341 rule.contribute_to_index(&ctx, &mut file_index);
1342
1343 assert_eq!(
1344 file_index.cross_file_links.len(),
1345 1,
1346 "check_frontmatter = {check_frontmatter} changed what was indexed"
1347 );
1348 assert_eq!(
1349 file_index.cross_file_links[0].origin,
1350 LinkOrigin::FrontMatter {
1351 field: Some("link".to_string())
1352 },
1353 );
1354 }
1355 }
1356
1357 #[test]
1361 fn test_cross_file_check_applies_this_files_frontmatter_config() {
1362 use crate::workspace_index::WorkspaceIndex;
1363
1364 let mut workspace_index = WorkspaceIndex::new();
1365 let mut target = FileIndex::new();
1366 target.add_heading(HeadingIndex {
1367 text: "Real".to_string(),
1368 auto_anchor: "real".to_string(),
1369 custom_anchor: None,
1370 line: 1,
1371 text_lines: 1,
1372 is_setext: false,
1373 });
1374 let (_dir, root) = files_on_disk(&["docs/readme.md", "docs/other.md"]);
1375 workspace_index.insert_file(root.join("docs/other.md"), target);
1376
1377 let mut file_index = FileIndex::new();
1378 file_index.add_cross_file_link(CrossFileLinkIndex {
1379 target_path: "other.md".to_string(),
1380 fragment: "nope".to_string(),
1381 line: 2,
1382 column: 7,
1383 origin: LinkOrigin::FrontMatter {
1384 field: Some("link".to_string()),
1385 },
1386 });
1387 file_index.add_cross_file_link(CrossFileLinkIndex {
1391 target_path: "other.md".to_string(),
1392 fragment: "nope".to_string(),
1393 line: 6,
1394 column: 5,
1395 origin: LinkOrigin::Body,
1396 });
1397
1398 let count = |config: MD051Config| {
1399 MD051LinkFragments::from_config_struct(config)
1400 .cross_file_check(&root.join("docs/readme.md"), &file_index, &workspace_index)
1401 .unwrap()
1402 .len()
1403 };
1404
1405 assert_eq!(
1406 count(MD051Config {
1407 check_frontmatter: true,
1408 ..Default::default()
1409 }),
1410 2,
1411 "checking frontmatter should report both the frontmatter and body links"
1412 );
1413 assert_eq!(
1414 count(MD051Config {
1415 check_frontmatter: false,
1416 ..Default::default()
1417 }),
1418 1,
1419 "not checking frontmatter should leave only the body link"
1420 );
1421 assert_eq!(
1422 count(MD051Config {
1423 check_frontmatter: true,
1424 ignore_frontmatter_fields: vec!["LINK".to_string()],
1425 ..Default::default()
1426 }),
1427 1,
1428 "an ignored field should be matched case-insensitively"
1429 );
1430 }
1431
1432 #[test]
1433 fn test_cross_file_check_valid_fragment() {
1434 use crate::workspace_index::WorkspaceIndex;
1435
1436 let rule = MD051LinkFragments::new();
1437
1438 let mut workspace_index = WorkspaceIndex::new();
1440 let mut target_file_index = FileIndex::new();
1441 target_file_index.add_heading(HeadingIndex {
1442 text: "Installation Guide".to_string(),
1443 auto_anchor: "installation-guide".to_string(),
1444 custom_anchor: None,
1445 line: 1,
1446 text_lines: 1,
1447 is_setext: false,
1448 });
1449 let (_dir, root) = files_on_disk(&["docs/readme.md", "docs/install.md"]);
1450 workspace_index.insert_file(root.join("docs/install.md"), target_file_index);
1451
1452 let mut current_file_index = FileIndex::new();
1454 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1455 target_path: "install.md".to_string(),
1456 fragment: "installation-guide".to_string(),
1457 line: 3,
1458 column: 5,
1459 origin: LinkOrigin::Body,
1460 });
1461
1462 let warnings = rule
1463 .cross_file_check(&root.join("docs/readme.md"), ¤t_file_index, &workspace_index)
1464 .unwrap();
1465
1466 assert!(warnings.is_empty());
1468 }
1469
1470 #[test]
1471 fn test_cross_file_check_invalid_fragment() {
1472 use crate::workspace_index::WorkspaceIndex;
1473
1474 let rule = MD051LinkFragments::new();
1475
1476 let mut workspace_index = WorkspaceIndex::new();
1478 let mut target_file_index = FileIndex::new();
1479 target_file_index.add_heading(HeadingIndex {
1480 text: "Installation Guide".to_string(),
1481 auto_anchor: "installation-guide".to_string(),
1482 custom_anchor: None,
1483 line: 1,
1484 text_lines: 1,
1485 is_setext: false,
1486 });
1487 let (_dir, root) = files_on_disk(&["docs/readme.md", "docs/install.md"]);
1488 workspace_index.insert_file(root.join("docs/install.md"), target_file_index);
1489
1490 let mut current_file_index = FileIndex::new();
1492 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1493 target_path: "install.md".to_string(),
1494 fragment: "nonexistent".to_string(),
1495 line: 3,
1496 column: 5,
1497 origin: LinkOrigin::Body,
1498 });
1499
1500 let warnings = rule
1501 .cross_file_check(&root.join("docs/readme.md"), ¤t_file_index, &workspace_index)
1502 .unwrap();
1503
1504 assert_eq!(warnings.len(), 1);
1506 assert!(warnings[0].message.contains("nonexistent"));
1507 assert!(warnings[0].message.contains("install.md"));
1508 }
1509
1510 #[test]
1511 fn test_cross_file_check_custom_anchor_match() {
1512 use crate::workspace_index::WorkspaceIndex;
1513
1514 let rule = MD051LinkFragments::new();
1515
1516 let mut workspace_index = WorkspaceIndex::new();
1518 let mut target_file_index = FileIndex::new();
1519 target_file_index.add_heading(HeadingIndex {
1520 text: "Installation Guide".to_string(),
1521 auto_anchor: "installation-guide".to_string(),
1522 custom_anchor: Some("install".to_string()),
1523 line: 1,
1524 text_lines: 1,
1525 is_setext: false,
1526 });
1527 let (_dir, root) = files_on_disk(&["docs/readme.md", "docs/install.md"]);
1528 workspace_index.insert_file(root.join("docs/install.md"), target_file_index);
1529
1530 let mut current_file_index = FileIndex::new();
1532 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1533 target_path: "install.md".to_string(),
1534 fragment: "install".to_string(),
1535 line: 3,
1536 column: 5,
1537 origin: LinkOrigin::Body,
1538 });
1539
1540 let warnings = rule
1541 .cross_file_check(&root.join("docs/readme.md"), ¤t_file_index, &workspace_index)
1542 .unwrap();
1543
1544 assert!(warnings.is_empty());
1546 }
1547
1548 #[test]
1549 fn test_cross_file_check_target_not_in_workspace() {
1550 use crate::workspace_index::WorkspaceIndex;
1551
1552 let rule = MD051LinkFragments::new();
1553
1554 let (_dir, root) = files_on_disk(&["docs/readme.md", "docs/external.md"]);
1556 let workspace_index = WorkspaceIndex::new();
1557
1558 let mut current_file_index = FileIndex::new();
1560 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1561 target_path: "external.md".to_string(),
1562 fragment: "heading".to_string(),
1563 line: 3,
1564 column: 5,
1565 origin: LinkOrigin::Body,
1566 });
1567
1568 let warnings = rule
1569 .cross_file_check(&root.join("docs/readme.md"), ¤t_file_index, &workspace_index)
1570 .unwrap();
1571
1572 assert!(warnings.is_empty());
1574 }
1575
1576 fn heading_index(anchor: &str) -> FileIndex {
1577 let mut index = FileIndex::new();
1578 index.add_heading(HeadingIndex {
1579 text: anchor.to_string(),
1580 auto_anchor: anchor.to_string(),
1581 custom_anchor: None,
1582 line: 1,
1583 text_lines: 1,
1584 is_setext: false,
1585 });
1586 index
1587 }
1588
1589 fn body_link(target_path: &str, fragment: &str) -> FileIndex {
1590 let mut index = FileIndex::new();
1591 index.add_cross_file_link(CrossFileLinkIndex {
1592 target_path: target_path.to_string(),
1593 fragment: fragment.to_string(),
1594 line: 1,
1595 column: 1,
1596 origin: LinkOrigin::Body,
1597 });
1598 index
1599 }
1600
1601 #[cfg(unix)]
1604 #[test]
1605 fn a_target_indexed_under_a_symlinked_spelling_is_checked() {
1606 use crate::workspace_index::WorkspaceIndex;
1607
1608 let (_dir, root) = files_on_disk(&["real/readme.md", "real/install.md"]);
1609 let alias = root.join("alias");
1610 std::os::unix::fs::symlink(root.join("real"), &alias).unwrap();
1611
1612 let mut workspace_index = WorkspaceIndex::new();
1613 workspace_index.insert_file(alias.join("install.md"), heading_index("setup"));
1614
1615 let rule = MD051LinkFragments::new();
1616 let check = |fragment: &str| {
1617 rule.cross_file_check(
1618 &alias.join("readme.md"),
1619 &body_link("install.md", fragment),
1620 &workspace_index,
1621 )
1622 .unwrap()
1623 };
1624 assert!(check("setup").is_empty());
1625 assert_eq!(check("absent").len(), 1);
1626 }
1627
1628 #[cfg(unix)]
1631 #[test]
1632 fn a_symlinked_source_is_not_answered_by_a_namesake_beside_the_link() {
1633 use crate::workspace_index::WorkspaceIndex;
1634
1635 let (_dir, root) = files_on_disk(&["real/readme.md", "real/install.md", "links/install.md"]);
1636 std::os::unix::fs::symlink(root.join("real/readme.md"), root.join("links/readme.md")).unwrap();
1637
1638 let mut workspace_index = WorkspaceIndex::new();
1639 workspace_index.insert_file(root.join("real/install.md"), heading_index("setup"));
1640 workspace_index.insert_file(root.join("links/install.md"), heading_index("other"));
1641
1642 let warnings = MD051LinkFragments::new()
1643 .cross_file_check(
1644 &root.join("links/readme.md"),
1645 &body_link("install.md", "other"),
1646 &workspace_index,
1647 )
1648 .unwrap();
1649 assert_eq!(
1650 warnings.len(),
1651 1,
1652 "#other is only in the namesake beside the link, got {warnings:?}"
1653 );
1654 }
1655
1656 #[test]
1660 fn a_target_open_only_in_the_editor_is_checked() {
1661 use crate::workspace_index::WorkspaceIndex;
1662
1663 let (_dir, root) = files_on_disk(&["readme.md"]);
1664 let mut workspace_index = WorkspaceIndex::new();
1665 workspace_index.insert_file(root.join("unsaved.md"), heading_index("setup"));
1666
1667 let rule = MD051LinkFragments::new();
1668 let source = root.join("readme.md");
1669 let broken = rule
1670 .cross_file_check(&source, &body_link("unsaved.md", "missing"), &workspace_index)
1671 .unwrap();
1672 assert_eq!(broken.len(), 1, "#missing is not in the open document, got {broken:?}");
1673 let valid = rule
1674 .cross_file_check(&source, &body_link("unsaved.md", "setup"), &workspace_index)
1675 .unwrap();
1676 assert!(valid.is_empty(), "#setup is in the open document, got {valid:?}");
1677 }
1678
1679 #[test]
1680 fn an_index_entry_behind_a_file_in_another_case_is_not_checked() {
1681 use crate::workspace_index::WorkspaceIndex;
1682
1683 let (_dir, root) = files_on_disk(&["readme.md", "Guide.md"]);
1684 let mut workspace_index = WorkspaceIndex::new();
1685 workspace_index.insert_file(root.join("guide.md"), heading_index("setup"));
1686
1687 let rule = MD051LinkFragments::new();
1688 let warnings = rule
1689 .cross_file_check(
1690 &root.join("readme.md"),
1691 &body_link("guide.md", "missing"),
1692 &workspace_index,
1693 )
1694 .unwrap();
1695 if root.join("guide.md").exists() {
1699 assert!(warnings.is_empty(), "Guide.md answers the spelling, got {warnings:?}");
1700 } else {
1701 assert_eq!(
1702 warnings.len(),
1703 1,
1704 "#missing is not in the open document, got {warnings:?}"
1705 );
1706 }
1707 }
1708
1709 #[test]
1710 fn test_wikilinks_skipped_in_check() {
1711 let rule = MD051LinkFragments::new();
1713
1714 let content = r#"# Test Document
1715
1716## Valid Heading
1717
1718[[Microsoft#Windows OS]]
1719[[SomePage#section]]
1720[[page|Display Text]]
1721[[path/to/page#section]]
1722"#;
1723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1724 let result = rule.check(&ctx).unwrap();
1725
1726 assert!(
1727 result.is_empty(),
1728 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1729 );
1730 }
1731
1732 #[test]
1733 fn test_wikilinks_not_added_to_cross_file_index() {
1734 let rule = MD051LinkFragments::new();
1736
1737 let content = r#"# Test Document
1738
1739[[Microsoft#Windows OS]]
1740[[SomePage#section]]
1741[Regular Link](other.md#section)
1742"#;
1743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1744
1745 let mut file_index = FileIndex::new();
1746 rule.contribute_to_index(&ctx, &mut file_index);
1747
1748 let cross_file_links = &file_index.cross_file_links;
1751 assert_eq!(
1752 cross_file_links.len(),
1753 1,
1754 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1755 );
1756 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1757 assert_eq!(file_index.cross_file_links[0].fragment, "section");
1758 }
1759
1760 #[test]
1761 fn test_pandoc_flavor_skips_citations() {
1762 let rule = MD051LinkFragments::new();
1766 let content = "# Test Document\n\nSee [@smith2020] for details.\n";
1767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1768 let result = rule.check(&ctx).unwrap();
1769 assert!(
1770 result.is_empty(),
1771 "MD051 should skip Pandoc citations under Pandoc flavor: {result:?}"
1772 );
1773 }
1774
1775 #[test]
1776 fn md051_pandoc_resolves_pandoc_slug_diverging_from_github() {
1777 use crate::config::MarkdownFlavor;
1784 let rule = MD051LinkFragments::new();
1785 let content = "# 5. Five Things\n\nSee [details](#5.-five-things).\n";
1786
1787 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1790 let std_result = rule.check(&ctx_std).unwrap();
1791 assert_eq!(
1792 std_result.len(),
1793 1,
1794 "Standard flavor should flag the Pandoc-style fragment: {std_result:?}"
1795 );
1796
1797 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1799 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1800 assert!(
1801 pandoc_result.is_empty(),
1802 "Pandoc flavor should resolve `#5.-five-things` against the heading slug: {pandoc_result:?}"
1803 );
1804 }
1805
1806 #[test]
1810 fn md051_pandoc_flags_missing_fragment_with_email_in_link_text() {
1811 use crate::config::MarkdownFlavor;
1812 let rule = MD051LinkFragments::new();
1813 let content = "# Title\n\n[contact user@example.com](#missing)\n";
1814
1815 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1816 let std_result = rule.check(&ctx_std).unwrap();
1817 assert_eq!(
1818 std_result.len(),
1819 1,
1820 "Standard flavor must flag the missing fragment: {std_result:?}"
1821 );
1822
1823 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1824 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1825 assert_eq!(
1826 pandoc_result.len(),
1827 1,
1828 "Pandoc flavor must also flag the missing fragment — link text with embedded email is not a citation: {pandoc_result:?}"
1829 );
1830 }
1831
1832 #[test]
1836 fn md051_pandoc_flags_missing_fragment_with_citation_in_link_text() {
1837 use crate::config::MarkdownFlavor;
1838 let rule = MD051LinkFragments::new();
1839 let content = "# Title\n\n[see @smith2020](#missing)\n";
1840
1841 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1842 let std_result = rule.check(&ctx_std).unwrap();
1843 assert_eq!(
1844 std_result.len(),
1845 1,
1846 "Standard flavor must flag the missing fragment: {std_result:?}"
1847 );
1848
1849 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1850 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1851 assert_eq!(
1852 pandoc_result.len(),
1853 1,
1854 "Pandoc flavor must flag the missing fragment — `[label](url)` is a link, not a citation: {pandoc_result:?}"
1855 );
1856 }
1857
1858 #[test]
1862 fn md051_pandoc_resolves_duplicate_heading_suffix_slug() {
1863 use crate::config::MarkdownFlavor;
1864 let rule = MD051LinkFragments::new();
1865 let content = "# A.\n\nfirst\n\n# A.\n\nsecond\n\n[first](#a.) and [second](#a.-1).\n";
1866
1867 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1868 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1869 assert!(
1870 pandoc_result.is_empty(),
1871 "Pandoc flavor should resolve `#a.` and `#a.-1` against duplicate headings: {pandoc_result:?}"
1872 );
1873
1874 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1875 let quarto_result = rule.check(&ctx_quarto).unwrap();
1876 assert!(
1877 quarto_result.is_empty(),
1878 "Quarto flavor should also resolve duplicate-heading suffix slugs: {quarto_result:?}"
1879 );
1880 }
1881
1882 #[test]
1885 fn md051_pandoc_flags_overshoot_duplicate_suffix() {
1886 use crate::config::MarkdownFlavor;
1887 let rule = MD051LinkFragments::new();
1888 let content = "# A.\n\n# A.\n\n[overshoot](#a.-2)\n";
1889
1890 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1891 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1892 assert_eq!(
1893 pandoc_result.len(),
1894 1,
1895 "Pandoc must flag `#a.-2` when only `-1` exists (two duplicates): {pandoc_result:?}"
1896 );
1897 }
1898
1899 fn front_matter_checked() -> MD051Config {
1900 MD051Config {
1901 check_frontmatter: true,
1902 ..MD051Config::default()
1903 }
1904 }
1905
1906 fn check_front_matter(content: &str, config: MD051Config) -> Vec<LintWarning> {
1907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1908 MD051LinkFragments::from_config_struct(config).check(&ctx).unwrap()
1909 }
1910
1911 #[test]
1912 fn a_broken_frontmatter_fragment_is_reported_when_enabled() {
1913 let content = "---\nanchor: '#missing'\nvalid: '#title'\n---\n\n# Title\n";
1914 let result = check_front_matter(content, front_matter_checked());
1915
1916 assert_eq!(
1917 result.len(),
1918 1,
1919 "Only the unresolved fragment is reported. Got: {result:?}"
1920 );
1921 assert_eq!(
1922 result[0].message,
1923 "Link anchor '#missing' does not exist in document headings"
1924 );
1925 assert_eq!(result[0].line, 2);
1926 assert_eq!(result[0].column, 10, "The warning points at the value, not the key");
1927 assert_eq!(result[0].end_column, 18);
1928 }
1929
1930 #[test]
1931 fn frontmatter_fragments_are_not_checked_by_default() {
1932 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
1933 let result = check_front_matter(content, MD051Config::default());
1934
1935 assert!(
1936 result.is_empty(),
1937 "Frontmatter is only checked on request. Got: {result:?}"
1938 );
1939 }
1940
1941 #[test]
1942 fn an_ignored_frontmatter_field_is_not_checked() {
1943 let content = "---\nhero: '#missing'\nanchor: '#other'\n---\n\n# Title\n";
1944 let config = MD051Config {
1945 check_frontmatter: true,
1946 ignore_frontmatter_fields: vec!["Hero".to_string()],
1947 ..MD051Config::default()
1948 };
1949 let result = check_front_matter(content, config);
1950
1951 assert_eq!(
1952 result.len(),
1953 1,
1954 "The ignored field is skipped and the other is not. Got: {result:?}"
1955 );
1956 assert_eq!(result[0].line, 3);
1957 }
1958
1959 #[test]
1960 fn the_ignored_pattern_applies_to_frontmatter_fragments() {
1961 let content = "---\nnote: '#fn:1'\nanchor: '#missing'\n---\n\n# Title\n";
1962 let config = MD051Config {
1963 check_frontmatter: true,
1964 ignored_pattern: Some("^fn:".to_string()),
1965 ..MD051Config::default()
1966 };
1967 let result = check_front_matter(content, config);
1968
1969 assert_eq!(
1970 result.len(),
1971 1,
1972 "The matching fragment is skipped and the other is not. Got: {result:?}"
1973 );
1974 assert_eq!(result[0].line, 3);
1975 }
1976
1977 #[test]
1978 fn a_frontmatter_fragment_honors_ignore_case() {
1979 let content = "---\nanchor: '#Title'\n---\n\n# Title\n";
1980
1981 let permissive = check_front_matter(content, front_matter_checked());
1982 assert!(
1983 permissive.is_empty(),
1984 "The default resolves a case mismatch. Got: {permissive:?}"
1985 );
1986
1987 let strict = check_front_matter(
1988 content,
1989 MD051Config {
1990 check_frontmatter: true,
1991 ignore_case: false,
1992 ..MD051Config::default()
1993 },
1994 );
1995 assert_eq!(strict.len(), 1, "Strict matching reports it. Got: {strict:?}");
1996 }
1997
1998 #[test]
1999 fn prose_in_frontmatter_is_not_read_as_a_fragment() {
2000 let content = "---\ntitle: Node.js\ntags: ci/cd\n---\n\n# Title\n";
2001 let result = check_front_matter(content, front_matter_checked());
2002
2003 assert!(
2004 result.is_empty(),
2005 "Only path-shaped values are destinations. Got: {result:?}"
2006 );
2007 }
2008
2009 #[test]
2010 fn a_frontmatter_path_with_a_fragment_is_validated_across_files() {
2011 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
2012 let source = "---\ntemplate: other.md#missing\nvalid: other.md#target\n---\n\n# Source\n";
2013
2014 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2015 let mut source_index = FileIndex::default();
2016 rule.contribute_to_index(&source_ctx, &mut source_index);
2017
2018 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
2019 let mut target_index = FileIndex::default();
2020 rule.contribute_to_index(&target_ctx, &mut target_index);
2021
2022 let (_dir, root) = files_on_disk(&["docs/source.md", "docs/other.md"]);
2023 let source_path = root.join("docs/source.md");
2024 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
2025 workspace.insert_file(source_path.clone(), source_index.clone());
2026 workspace.insert_file(root.join("docs/other.md"), target_index);
2027
2028 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
2029
2030 assert_eq!(
2031 warnings.len(),
2032 1,
2033 "Only the unresolved fragment is reported. Got: {warnings:?}"
2034 );
2035 assert_eq!(warnings[0].message, "Link fragment 'missing' not found in 'other.md'");
2036 assert_eq!(warnings[0].line, 2);
2037 assert_eq!(warnings[0].column, 11);
2038 }
2039
2040 #[test]
2041 fn a_query_string_does_not_hide_the_target_file() {
2042 let rule = MD051LinkFragments::new();
2043 let source = "# Source\n\n- [a](other.md?raw=true#missing)\n- [b](other.md?raw=true#target)\n";
2044
2045 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2046 let mut source_index = FileIndex::default();
2047 rule.contribute_to_index(&source_ctx, &mut source_index);
2048
2049 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
2050 let mut target_index = FileIndex::default();
2051 rule.contribute_to_index(&target_ctx, &mut target_index);
2052
2053 let (_dir, root) = files_on_disk(&["docs/source.md", "docs/other.md"]);
2054 let source_path = root.join("docs/source.md");
2055 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
2056 workspace.insert_file(source_path.clone(), source_index.clone());
2057 workspace.insert_file(root.join("docs/other.md"), target_index);
2058
2059 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
2060
2061 assert_eq!(
2062 warnings.len(),
2063 1,
2064 "The query is stripped to find the file, so both fragments resolve against it. Got: {warnings:?}"
2065 );
2066 assert_eq!(
2067 warnings[0].message,
2068 "Link fragment 'missing' not found in 'other.md?raw=true'"
2069 );
2070 assert_eq!(warnings[0].line, 3);
2071 }
2072
2073 #[test]
2074 fn a_query_string_does_not_hide_an_extensionless_target_file() {
2075 let rule = MD051LinkFragments::new();
2076 let source = "# Source\n\n- [a](other?raw=true#target)\n- [b](other#target)\n- [c](other?raw=true#absent)\n";
2077
2078 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2079 let same_document = rule.check(&source_ctx).unwrap();
2080 assert!(
2081 same_document.is_empty(),
2082 "Every fragment here belongs to another file, so none is a missing anchor of this one. Got: {same_document:?}"
2083 );
2084
2085 let mut source_index = FileIndex::default();
2086 rule.contribute_to_index(&source_ctx, &mut source_index);
2087
2088 let target_ctx = LintContext::new("# Other\n\n## Target\n", crate::config::MarkdownFlavor::Standard, None);
2089 let mut target_index = FileIndex::default();
2090 rule.contribute_to_index(&target_ctx, &mut target_index);
2091
2092 let (_dir, root) = files_on_disk(&["docs/source.md", "docs/other.md"]);
2093 let source_path = root.join("docs/source.md");
2094 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
2095 workspace.insert_file(source_path.clone(), source_index.clone());
2096 workspace.insert_file(root.join("docs/other.md"), target_index);
2097
2098 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
2099
2100 assert_eq!(
2101 warnings.len(),
2102 1,
2103 "The query is stripped before the markdown extension is added. Got: {warnings:?}"
2104 );
2105 assert_eq!(
2106 warnings[0].message,
2107 "Link fragment 'absent' not found in 'other?raw=true'"
2108 );
2109 assert_eq!(warnings[0].line, 5);
2110 }
2111
2112 #[test]
2113 fn a_destination_that_is_only_a_query_stays_on_this_page() {
2114 let rule = MD051LinkFragments::new();
2115 let source = "# Source\n\n## Here\n\n- [a](?raw=true#here)\n- [b](?raw=true#nowhere)\n";
2116
2117 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2118 let warnings = rule.check(&source_ctx).unwrap();
2119
2120 assert_eq!(
2121 warnings.len(),
2122 1,
2123 "Only the absent anchor is reported. Got: {warnings:?}"
2124 );
2125 assert_eq!(
2126 warnings[0].message,
2127 "Link anchor '#nowhere' does not exist in document headings"
2128 );
2129 assert_eq!(warnings[0].line, 6);
2130
2131 let mut source_index = FileIndex::default();
2132 rule.contribute_to_index(&source_ctx, &mut source_index);
2133 assert!(
2134 source_index.cross_file_links.is_empty(),
2135 "A query with no path names no other file. Got: {:?}",
2136 source_index.cross_file_links
2137 );
2138 }
2139
2140 #[test]
2141 fn blockquote_syntax_inside_raw_html_does_not_create_an_anchor() {
2142 let rule = MD051LinkFragments::new();
2143 let source = "<div>\n> ## Hidden\n</div>\n\n[link](#hidden)\n";
2144 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2145
2146 let warnings = rule.check(&ctx).unwrap();
2147 assert_eq!(
2148 warnings.len(),
2149 1,
2150 "raw HTML must not satisfy the fragment: {warnings:?}"
2151 );
2152
2153 let mut file_index = FileIndex::default();
2154 rule.contribute_to_index(&ctx, &mut file_index);
2155 assert!(
2156 file_index.headings.is_empty(),
2157 "raw HTML must not enter the workspace index"
2158 );
2159 }
2160
2161 #[test]
2162 fn a_frontmatter_path_carrying_a_query_is_indexed() {
2163 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
2164 let source = "---\ntemplate: docs/other.md?raw=true#missing\n---\n\n# Source\n";
2165
2166 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2167 let mut source_index = FileIndex::default();
2168 rule.contribute_to_index(&source_ctx, &mut source_index);
2169
2170 assert_eq!(source_index.cross_file_links.len(), 1);
2171 assert_eq!(source_index.cross_file_links[0].target_path, "docs/other.md?raw=true");
2172 assert_eq!(source_index.cross_file_links[0].fragment, "missing");
2173 }
2174
2175 #[test]
2179 fn frontmatter_cross_file_paths_are_not_reported_by_default() {
2180 use crate::workspace_index::WorkspaceIndex;
2181
2182 let rule = MD051LinkFragments::new();
2183 let source = "---\ntemplate: other.md#missing\n---\n\n# Source\n";
2184
2185 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
2186 let mut source_index = FileIndex::default();
2187 rule.contribute_to_index(&source_ctx, &mut source_index);
2188 assert_eq!(source_index.cross_file_links.len(), 1);
2189
2190 let mut workspace_index = WorkspaceIndex::new();
2191 let mut target = FileIndex::new();
2192 target.add_heading(HeadingIndex {
2193 text: "Present".to_string(),
2194 auto_anchor: "present".to_string(),
2195 custom_anchor: None,
2196 line: 1,
2197 text_lines: 1,
2198 is_setext: false,
2199 });
2200 let (_dir, root) = files_on_disk(&["source.md", "other.md"]);
2201 workspace_index.insert_file(root.join("other.md"), target);
2202
2203 let warnings = rule
2204 .cross_file_check(&root.join("source.md"), &source_index, &workspace_index)
2205 .unwrap();
2206 assert!(
2207 warnings.is_empty(),
2208 "Frontmatter is only checked on request. Got: {warnings:?}"
2209 );
2210
2211 let checking = MD051LinkFragments::from_config_struct(MD051Config {
2215 check_frontmatter: true,
2216 ..Default::default()
2217 });
2218 assert_eq!(
2219 checking
2220 .cross_file_check(&root.join("source.md"), &source_index, &workspace_index)
2221 .unwrap()
2222 .len(),
2223 1
2224 );
2225 }
2226}