1use crate::lint_context::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::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::cell::OnceCell;
13use std::collections::HashSet;
14use std::path::Path;
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
86#[derive(Clone)]
93pub struct MD051LinkFragments {
94 config: MD051Config,
95 ignored_pattern_regex: Option<Regex>,
99 ignored_front_matter_fields: HashSet<String>,
101 anchor_style_pinned: bool,
105 link_resolver: LinkResolver,
108}
109
110struct AnchorSets {
115 markdown_headings: HashSet<String>,
116 markdown_headings_exact: HashSet<String>,
117 html_anchors: HashSet<String>,
118 html_anchors_exact: HashSet<String>,
119}
120
121impl Default for MD051LinkFragments {
122 fn default() -> Self {
123 Self::new()
124 }
125}
126
127impl MD051LinkFragments {
128 pub fn new() -> Self {
129 Self::from_config_struct(MD051Config::default())
130 }
131
132 pub fn with_anchor_style(style: AnchorStyle) -> Self {
134 Self::from_config_struct(MD051Config {
135 anchor_style: style,
136 ..MD051Config::default()
137 })
138 }
139
140 pub fn from_config_struct(config: MD051Config) -> Self {
146 Self::from_config_struct_from(config, false)
147 }
148
149 fn from_config_struct_from(config: MD051Config, values_withheld: bool) -> Self {
152 Self::build(config, values_withheld, true)
153 }
154
155 fn build(config: MD051Config, values_withheld: bool, anchor_style_pinned: bool) -> Self {
159 let ignored_pattern_regex = config.ignored_pattern.as_deref().and_then(|pattern| {
160 crate::rule_config_serde::compile_config_regex(pattern, "MD051", "ignored-pattern", values_withheld)
161 });
162 let ignored_front_matter_fields = config
163 .ignore_frontmatter_fields
164 .iter()
165 .map(|field| field.to_lowercase())
166 .collect();
167 Self {
168 config,
169 ignored_pattern_regex,
170 ignored_front_matter_fields,
171 anchor_style_pinned,
172 link_resolver: LinkResolver::default(),
173 }
174 }
175
176 fn anchor_style(&self, ctx: &crate::lint_context::LintContext) -> AnchorStyle {
183 if self.anchor_style_pinned {
184 self.config.anchor_style
185 } else {
186 AnchorStyle::for_flavor(ctx.flavor)
187 }
188 }
189
190 fn extract_headings_from_context(&self, ctx: &crate::lint_context::LintContext) -> AnchorSets {
197 let track_exact = !self.config.ignore_case;
198 let mut markdown_headings = HashSet::with_capacity(32);
199 let mut markdown_headings_exact = if track_exact {
200 HashSet::with_capacity(32)
201 } else {
202 HashSet::new()
203 };
204 let mut html_anchors = HashSet::with_capacity(16);
205 let mut html_anchors_exact = if track_exact {
206 HashSet::with_capacity(16)
207 } else {
208 HashSet::new()
209 };
210 let targets = ctx.document_anchors();
211 for range in &targets.html {
212 let id = &ctx.content[range.clone()];
213 html_anchors.insert(id.to_lowercase());
214 if track_exact {
215 html_anchors_exact.insert(id.to_string());
216 }
217 }
218 for range in &targets.attributes {
219 let id = &ctx.content[range.clone()];
220 markdown_headings.insert(id.to_lowercase());
221 if track_exact {
222 markdown_headings_exact.insert(id.to_string());
223 }
224 }
225 for anchor in targets.headings(ctx, self.anchor_style(ctx)) {
226 let heading = ctx.heading_on_line(anchor.line).expect("recorded heading").heading;
227 if let Some(id) = &heading.custom_id {
228 markdown_headings.insert(id.to_lowercase());
229 if track_exact {
230 markdown_headings_exact.insert(id.clone());
231 }
232 }
233 for fragment in anchor.primary.iter().chain(anchor.alias.iter()) {
234 markdown_headings.insert(fragment.clone());
235 if track_exact {
236 markdown_headings_exact.insert(fragment.clone());
237 }
238 }
239 }
240
241 AnchorSets {
242 markdown_headings,
243 markdown_headings_exact,
244 html_anchors,
245 html_anchors_exact,
246 }
247 }
248
249 #[inline]
251 fn is_external_url_fast(url: &str) -> bool {
252 url.starts_with("http://")
254 || url.starts_with("https://")
255 || url.starts_with("ftp://")
256 || url.starts_with("mailto:")
257 || url.starts_with("tel:")
258 || url.starts_with("//")
259 }
260
261 fn cross_file_parts(url: &str) -> Option<(&str, &str)> {
272 let fragment_pos = url.find('#')?;
273 if crate::workspace_index::strip_query_and_fragment(url).is_empty() || Self::is_external_url_fast(url) {
274 return None;
275 }
276 Some((&url[..fragment_pos], &url[fragment_pos + 1..]))
277 }
278
279 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
284 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
285 }
286
287 fn front_matter_links(&self, ctx: &crate::lint_context::LintContext) -> Vec<frontmatter_values::FrontMatterLink> {
290 if !self.checks_front_matter_of(ctx) {
291 return Vec::new();
292 }
293 frontmatter_values::link_destinations(ctx)
294 .into_iter()
295 .filter(|link| !link.field_is_in(&self.ignored_front_matter_fields))
296 .collect()
297 }
298
299 fn reports_link_from(&self, origin: &LinkOrigin) -> bool {
306 match origin {
307 LinkOrigin::Body => true,
308 LinkOrigin::FrontMatter { field } => {
309 self.config.check_frontmatter
310 && !field
311 .as_ref()
312 .is_some_and(|field| self.ignored_front_matter_fields.contains(field))
313 }
314 }
315 }
316
317 fn fragment_is_exempt(&self, ctx: &crate::lint_context::LintContext, fragment: &str) -> bool {
320 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
325 && (fragment.starts_with("fn:")
326 || fragment.starts_with("fnref:")
327 || (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
328 {
329 return true;
330 }
331
332 self.ignored_pattern_regex
334 .as_ref()
335 .is_some_and(|re| re.is_match(fragment))
336 }
337
338 fn fragment_resolves(&self, ctx: &LintContext, fragment: &str, anchors: &OnceCell<AnchorSets>) -> bool {
346 let anchors = anchors.get_or_init(|| self.extract_headings_from_context(ctx));
347 let names_anchor = |candidate: &str| {
348 if ctx.flavor.is_pandoc_compatible() && ctx.has_pandoc_slug(candidate) {
351 return true;
352 }
353 if self.config.ignore_case {
354 let lower = candidate.to_lowercase();
355 anchors.html_anchors.contains(&lower) || anchors.markdown_headings.contains(&lower)
356 } else {
357 anchors.html_anchors_exact.contains(candidate) || anchors.markdown_headings_exact.contains(candidate)
358 }
359 };
360
361 if names_anchor(fragment) {
362 return true;
363 }
364 if fragment.contains('%') {
365 let decoded = crate::workspace_index::url_decode(fragment);
366 return decoded != fragment && names_anchor(&decoded);
367 }
368 false
369 }
370
371 fn check_front_matter(
378 &self,
379 ctx: &crate::lint_context::LintContext,
380 links: &[frontmatter_values::FrontMatterLink],
381 anchors: &OnceCell<AnchorSets>,
382 warnings: &mut Vec<LintWarning>,
383 ) {
384 for link in links {
385 let line = ctx.lines[link.line - 1].content(ctx.content);
386 let Some(fragment) = line[link.range.clone()].strip_prefix('#') else {
387 continue;
388 };
389 if fragment.is_empty() {
390 continue;
391 }
392
393 if self.fragment_is_exempt(ctx, fragment) || self.fragment_resolves(ctx, fragment, anchors) {
394 continue;
395 }
396
397 let column = byte_to_char_count(line, link.range.start);
398 warnings.push(LintWarning {
399 rule_name: Some(self.name().to_string()),
400 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
401 line: link.line,
402 column,
403 end_line: link.line,
404 end_column: column + 1 + fragment.chars().count(),
405 severity: Severity::Error,
406 fix: None,
407 });
408 }
409 }
410}
411
412impl Rule for MD051LinkFragments {
413 fn name(&self) -> &'static str {
414 "MD051"
415 }
416
417 fn description(&self) -> &'static str {
418 "Link fragments should reference valid headings"
419 }
420
421 fn fix_capability(&self) -> FixCapability {
422 FixCapability::Unfixable
423 }
424
425 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
426 if !ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx) {
430 return true;
431 }
432 !ctx.has_char('#')
434 }
435
436 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
437 let mut warnings = Vec::new();
438
439 if ctx.content.is_empty() || self.should_skip(ctx) {
440 return Ok(warnings);
441 }
442
443 let front_matter_links = self.front_matter_links(ctx);
444 if ctx.links().is_empty() && front_matter_links.is_empty() {
445 return Ok(warnings);
446 }
447
448 let anchors = OnceCell::new();
451
452 for link in ctx.links() {
453 if link.is_reference {
454 continue;
455 }
456
457 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
459 continue;
460 }
461
462 if matches!(link.link_type, LinkType::WikiLink { .. }) {
464 continue;
465 }
466
467 if ctx.is_in_jinja_range(link.byte_offset) {
469 continue;
470 }
471
472 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
475 continue;
476 }
477
478 if ctx.is_in_shortcode(link.byte_offset) {
481 continue;
482 }
483
484 let url = &link.url;
485
486 if !url.contains('#') || Self::is_external_url_fast(url) {
488 continue;
489 }
490
491 if url.contains("{{#") && url.contains("}}") {
494 continue;
495 }
496
497 if url.starts_with('@') {
501 continue;
502 }
503
504 if !crate::workspace_index::strip_query_and_fragment(url).is_empty() {
507 continue;
508 }
509
510 let Some(fragment_pos) = url.find('#') else {
511 continue;
512 };
513
514 let fragment = &url[fragment_pos + 1..];
515
516 if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
518 continue;
519 }
520
521 if fragment.is_empty() {
522 continue;
523 }
524
525 if self.fragment_is_exempt(ctx, fragment) {
526 continue;
527 }
528
529 if !self.fragment_resolves(ctx, fragment, &anchors) {
530 warnings.push(LintWarning {
531 rule_name: Some(self.name().to_string()),
532 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
533 line: link.line,
534 column: link.start_col + 1,
535 end_line: link.end_line,
536 end_column: link.end_col + 1,
537 severity: Severity::Error,
538 fix: None,
539 });
540 }
541 }
542
543 self.check_front_matter(ctx, &front_matter_links, &anchors, &mut warnings);
544
545 Ok(warnings)
546 }
547
548 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
549 Ok(ctx.content.to_string())
552 }
553
554 fn as_any(&self) -> &dyn std::any::Any {
555 self
556 }
557
558 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
559 where
560 Self: Sized,
561 {
562 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD051Config>(config);
563
564 let explicit_style_present = config
569 .rules
570 .get("MD051")
571 .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
572 if !explicit_style_present {
573 rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
574 }
575
576 let mut rule = MD051LinkFragments::build(
577 rule_config,
578 config.withheld_rule_values.contains("MD051"),
579 explicit_style_present,
580 );
581 rule.link_resolver = LinkResolver::from_config(config);
582 Box::new(rule)
583 }
584
585 fn category(&self) -> RuleCategory {
586 RuleCategory::Link
587 }
588
589 fn skippable_by_category(&self) -> bool {
590 !self.config.check_frontmatter
593 }
594
595 fn cross_file_scope(&self) -> CrossFileScope {
596 CrossFileScope::Workspace
597 }
598
599 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
600 let targets = ctx.document_anchors();
601 for range in &targets.html {
602 let id = &ctx.content[range.clone()];
603 file_index.add_html_anchor(id);
604 }
605 for range in &targets.attributes {
606 let id = &ctx.content[range.clone()];
607 file_index.add_attribute_anchor(id);
608 }
609 for range in &targets.settings {
610 let id = &ctx.content[range.clone()];
611 file_index.add_html_anchor(id);
612 }
613 for anchor in targets.headings(ctx, self.anchor_style(ctx)) {
614 let Some(fragment) = &anchor.primary else { continue };
615 let parsed = ctx.heading_on_line(anchor.line).expect("recorded heading");
616 let heading = parsed.heading;
617 file_index.add_heading(HeadingIndex {
618 text: heading.text.clone(),
619 auto_anchor: fragment.clone(),
620 custom_anchor: heading.custom_id.clone(),
621 line: anchor.line,
622 text_lines: heading.text_lines,
623 is_setext: parsed.is_setext(),
624 });
625 if let Some(alias) = &anchor.alias {
626 file_index.add_anchor_alias(alias, file_index.headings.len() - 1);
627 }
628 }
629
630 for link in ctx.links() {
632 if link.is_reference {
633 continue;
634 }
635
636 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
638 continue;
639 }
640
641 if matches!(link.link_type, LinkType::WikiLink { .. }) {
644 continue;
645 }
646
647 if let Some((path_part, fragment)) = Self::cross_file_parts(&link.url) {
648 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
650 continue;
651 }
652
653 file_index.add_cross_file_link(CrossFileLinkIndex {
654 target_path: path_part.to_string(),
655 fragment: fragment.to_string(),
656 line: link.line,
657 column: link.start_col + 1,
658 origin: LinkOrigin::Body,
659 });
660 }
661 }
662
663 for link in frontmatter_values::link_destinations(ctx) {
670 let line = ctx.lines[link.line - 1].content(ctx.content);
671 let value = &line[link.range.clone()];
672
673 let Some((path_part, fragment)) = Self::cross_file_parts(value) else {
674 continue;
675 };
676
677 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
679 continue;
680 }
681
682 file_index.add_cross_file_link(CrossFileLinkIndex {
683 target_path: path_part.to_string(),
684 fragment: fragment.to_string(),
685 line: link.line,
686 column: byte_to_char_count(line, link.range.start),
687 origin: LinkOrigin::FrontMatter { field: link.field },
688 });
689 }
690 }
691
692 fn cross_file_check(
693 &self,
694 file_path: &Path,
695 file_index: &FileIndex,
696 workspace_index: &crate::workspace_index::WorkspaceIndex,
697 ) -> LintResult {
698 let mut warnings = Vec::new();
699
700 let ignored_pattern = self.ignored_pattern_regex.as_ref();
701 let ignore_case = self.config.ignore_case;
702 let mut document_links = None;
706 let policy = workspace_index.link_target_policy();
707
708 for cross_link in &file_index.cross_file_links {
710 if cross_link.fragment.is_empty() {
712 continue;
713 }
714
715 if !self.reports_link_from(&cross_link.origin) {
718 continue;
719 }
720
721 if ignored_pattern.is_some_and(|re| re.is_match(&cross_link.fragment)) {
723 continue;
724 }
725
726 let document_links = document_links.get_or_insert_with(|| self.link_resolver.for_document(file_path));
729 let target_file_index = match document_links.resolve(&cross_link.target_path, policy) {
730 LinkResolution::Target(target) => workspace_index.get_resolved_file(&target).or_else(|| {
735 let identity = target.canonicalize().ok()?;
736 crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path)
737 .iter()
738 .filter(|candidate| candidate.canonicalize().is_ok_and(|path| path == identity))
739 .find_map(|candidate| workspace_index.get_resolved_file(candidate))
740 }),
741 LinkResolution::Missing => {
748 crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path)
749 .iter()
750 .filter_map(|candidate| Some((candidate, workspace_index.get_file(candidate)?)))
751 .find(|(candidate, _)| matches!(candidate.try_exists(), Ok(false)))
752 .map(|(_, index)| index)
753 }
754 LinkResolution::Unchecked => {
760 let candidates = crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path);
761 match candidates
762 .first()
763 .and_then(|base| policy.and_then(|policy| policy.declared_target_for(base)))
764 {
765 Some(declared) => workspace_index.get_resolved_file(&declared),
766 None => candidates.iter().find_map(|target| workspace_index.get_file(target)),
767 }
768 }
769 };
770
771 if let Some(target_file_index) = target_file_index {
772 if !target_file_index.has_anchor_with_case(&cross_link.fragment, ignore_case) {
774 warnings.push(LintWarning {
775 rule_name: Some(self.name().to_string()),
776 line: cross_link.line,
777 column: cross_link.column,
778 end_line: cross_link.line,
779 end_column: cross_link.column
780 + cross_link.target_path.chars().count()
781 + 1
782 + cross_link.fragment.chars().count(),
783 message: format!(
784 "Link fragment '{}' not found in '{}'",
785 cross_link.fragment, cross_link.target_path
786 ),
787 severity: Severity::Error,
788 fix: None,
789 });
790 }
791 }
792 }
798
799 Ok(warnings)
800 }
801
802 crate::impl_rule_config_sections!(MD051Config);
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808 use crate::lint_context::LintContext;
809 use std::path::PathBuf;
810
811 #[test]
812 fn local_fragments_after_skipped_links_keep_duplicate_and_html_targets() {
813 let content = "---\nanchor: '#missing-frontmatter'\n---\n\n\
814 # Title\n\n# Title\n\n<a id=\"Custom\"></a>\n\n\
815 [external](https://example.com/#missing)\n\
816 [cross-file](other.md#missing)\n\
817 [duplicate](#title-1)\n\
818 [html](#Custom)\n\
819 [missing](#missing-body)\n";
820 for ignore_case in [false, true] {
821 let rule = MD051LinkFragments::from_config_struct(MD051Config {
822 check_frontmatter: true,
823 ignore_case,
824 ..MD051Config::default()
825 });
826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827 let warnings = rule.check(&ctx).unwrap();
828 assert_eq!(warnings.len(), 2, "{warnings:?}");
829 assert!(warnings[0].message.contains("#missing-body"));
830 assert!(warnings[1].message.contains("#missing-frontmatter"));
831 }
832 }
833
834 fn files_on_disk(files: &[&str]) -> (tempfile::TempDir, PathBuf) {
838 let dir = tempfile::tempdir().unwrap();
839 let root = dir.path().canonicalize().unwrap();
840 for file in files {
841 let path = root.join(file);
842 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
843 std::fs::write(&path, "").unwrap();
844 }
845 (dir, root)
846 }
847
848 const ANCHOR_STYLE_PROBE: &str = "### Getting Started — Advanced\n\n\
852 [python-markdown slug](#getting-started-advanced)\n\
853 [github slug](#getting-started--advanced)\n";
854
855 fn flagged_fragment(rule: &dyn Rule, flavor: crate::config::MarkdownFlavor) -> String {
856 let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
857 let warnings = rule.check(&ctx).unwrap();
858 assert_eq!(
859 warnings.len(),
860 1,
861 "exactly one of the two links must be invalid under any style: {warnings:?}"
862 );
863 warnings[0].message.clone()
864 }
865
866 #[test]
870 fn test_unpinned_anchor_style_follows_the_file_flavor() {
871 let rule_from_global = |flavor| {
872 let mut config = crate::config::Config::default();
873 config.global.flavor = flavor;
874 MD051LinkFragments::from_config(&config)
875 };
876
877 let standard_global = rule_from_global(crate::config::MarkdownFlavor::Standard);
879 assert!(
882 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::Standard)
883 .contains("#getting-started-advanced'"),
884 "a standard file must be checked against GitHub anchors"
885 );
886 assert!(
889 flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
890 .contains("#getting-started--advanced'"),
891 "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
892 );
893
894 let mkdocs_global = rule_from_global(crate::config::MarkdownFlavor::MkDocs);
896 assert!(
897 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
898 .contains("#getting-started--advanced'"),
899 "a mkdocs file must be checked against Python-Markdown anchors"
900 );
901 assert!(
902 flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::Standard)
903 .contains("#getting-started-advanced'"),
904 "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
905 );
906 }
907
908 #[test]
911 fn test_pinned_anchor_style_ignores_the_file_flavor() {
912 let mut config = crate::config::Config::default();
913 config.global.flavor = crate::config::MarkdownFlavor::Standard;
914 let mut rule_config = crate::config::RuleConfig::default();
915 rule_config
916 .values
917 .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
918 config.rules.insert("MD051".to_string(), rule_config);
919 let rule = MD051LinkFragments::from_config(&config);
920
921 for flavor in [
922 crate::config::MarkdownFlavor::Standard,
923 crate::config::MarkdownFlavor::MkDocs,
924 crate::config::MarkdownFlavor::Kramdown,
925 ] {
926 assert!(
927 flagged_fragment(rule.as_ref(), flavor).contains("#getting-started-advanced'"),
928 "pinned github anchors must survive a {flavor:?} file"
929 );
930 }
931 }
932
933 #[test]
936 fn test_directly_constructed_rule_keeps_its_anchor_style() {
937 let rule = MD051LinkFragments::from_config_struct(MD051Config {
938 anchor_style: AnchorStyle::PythonMarkdown,
939 ..Default::default()
940 });
941 assert!(
942 flagged_fragment(&rule, crate::config::MarkdownFlavor::Standard).contains("#getting-started--advanced'"),
943 "an explicitly constructed Python-Markdown rule must not follow the file flavor"
944 );
945 }
946
947 #[test]
948 fn test_quarto_cross_references() {
949 let rule = MD051LinkFragments::new();
950
951 let content = r#"# Test Document
953
954## Figures
955
956See [@fig-plot] for the visualization.
957
958More details in [@tbl-results] and [@sec-methods].
959
960The equation [@eq-regression] shows the relationship.
961
962Reference to [@lst-code] for implementation."#;
963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
964 let result = rule.check(&ctx).unwrap();
965 assert!(
966 result.is_empty(),
967 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
968 result.len()
969 );
970
971 let content_with_anchor = r#"# Test
973
974See [link](#test) for details."#;
975 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
976 let result_anchor = rule.check(&ctx_anchor).unwrap();
977 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
978
979 let content_invalid = r#"# Test
981
982See [link](#nonexistent) for details."#;
983 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
984 let result_invalid = rule.check(&ctx_invalid).unwrap();
985 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
986 }
987
988 #[test]
989 fn test_jsx_in_heading_anchor() {
990 let rule = MD051LinkFragments::new();
992
993 let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997 assert!(
998 result.is_empty(),
999 "JSX self-closing tag should be stripped from anchor: got {result:?}"
1000 );
1001
1002 let content2 =
1004 "### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
1005 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1006 let result2 = rule.check(&ctx2).unwrap();
1007 assert!(
1008 result2.is_empty(),
1009 "JSX tag with attributes should be stripped from anchor: got {result2:?}"
1010 );
1011
1012 let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
1014 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1015 let result3 = rule.check(&ctx3).unwrap();
1016 assert!(
1017 result3.is_empty(),
1018 "HTML tag content should be preserved in anchor: got {result3:?}"
1019 );
1020 }
1021
1022 #[test]
1024 fn test_cross_file_scope() {
1025 let rule = MD051LinkFragments::new();
1026 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1027 }
1028
1029 #[test]
1030 fn test_contribute_to_index_extracts_headings() {
1031 let rule = MD051LinkFragments::new();
1032 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034
1035 let mut file_index = FileIndex::new();
1036 rule.contribute_to_index(&ctx, &mut file_index);
1037
1038 assert_eq!(file_index.headings.len(), 3);
1039 assert_eq!(file_index.headings[0].text, "First Heading");
1040 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
1041 assert!(file_index.headings[0].custom_anchor.is_none());
1042
1043 assert_eq!(file_index.headings[1].text, "Second");
1044 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
1045
1046 assert_eq!(file_index.headings[2].text, "Third");
1047 }
1048
1049 #[test]
1050 fn test_contribute_to_index_extracts_cross_file_links() {
1051 let rule = MD051LinkFragments::new();
1052 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054
1055 let mut file_index = FileIndex::new();
1056 rule.contribute_to_index(&ctx, &mut file_index);
1057
1058 assert_eq!(file_index.cross_file_links.len(), 2);
1059 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1060 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
1061 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
1062 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
1063 }
1064
1065 #[test]
1069 fn test_contribute_to_index_records_setext_headings() {
1070 let rule = MD051LinkFragments::new();
1071 let content = "Setext One\n==========\n\nSetext Two\n----------\n\n### Atx Three\n";
1072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073
1074 let mut file_index = FileIndex::new();
1075 rule.contribute_to_index(&ctx, &mut file_index);
1076
1077 let styles: Vec<(&str, bool)> = file_index
1078 .headings
1079 .iter()
1080 .map(|h| (h.text.as_str(), h.is_setext))
1081 .collect();
1082 assert_eq!(
1083 styles,
1084 vec![("Setext One", true), ("Setext Two", true), ("Atx Three", false)]
1085 );
1086 }
1087
1088 #[test]
1096 fn test_a_frontmatter_link_is_indexed_regardless_of_the_indexing_config() {
1097 let content = "---\nlink: 'other.md#nope'\n---\n\n# Real\n";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099
1100 for check_frontmatter in [true, false] {
1101 let rule = MD051LinkFragments::from_config_struct(MD051Config {
1102 check_frontmatter,
1103 ..Default::default()
1104 });
1105 let mut file_index = FileIndex::new();
1106 rule.contribute_to_index(&ctx, &mut file_index);
1107
1108 assert_eq!(
1109 file_index.cross_file_links.len(),
1110 1,
1111 "check_frontmatter = {check_frontmatter} changed what was indexed"
1112 );
1113 assert_eq!(
1114 file_index.cross_file_links[0].origin,
1115 LinkOrigin::FrontMatter {
1116 field: Some("link".to_string())
1117 },
1118 );
1119 }
1120 }
1121
1122 #[test]
1126 fn test_cross_file_check_applies_this_files_frontmatter_config() {
1127 use crate::workspace_index::WorkspaceIndex;
1128
1129 let mut workspace_index = WorkspaceIndex::new();
1130 let mut target = FileIndex::new();
1131 target.add_heading(HeadingIndex {
1132 text: "Real".to_string(),
1133 auto_anchor: "real".to_string(),
1134 custom_anchor: None,
1135 line: 1,
1136 text_lines: 1,
1137 is_setext: false,
1138 });
1139 let (_dir, root) = files_on_disk(&["docs/readme.md", "docs/other.md"]);
1140 workspace_index.insert_file(root.join("docs/other.md"), target);
1141
1142 let mut file_index = FileIndex::new();
1143 file_index.add_cross_file_link(CrossFileLinkIndex {
1144 target_path: "other.md".to_string(),
1145 fragment: "nope".to_string(),
1146 line: 2,
1147 column: 7,
1148 origin: LinkOrigin::FrontMatter {
1149 field: Some("link".to_string()),
1150 },
1151 });
1152 file_index.add_cross_file_link(CrossFileLinkIndex {
1156 target_path: "other.md".to_string(),
1157 fragment: "nope".to_string(),
1158 line: 6,
1159 column: 5,
1160 origin: LinkOrigin::Body,
1161 });
1162
1163 let count = |config: MD051Config| {
1164 MD051LinkFragments::from_config_struct(config)
1165 .cross_file_check(&root.join("docs/readme.md"), &file_index, &workspace_index)
1166 .unwrap()
1167 .len()
1168 };
1169
1170 assert_eq!(
1171 count(MD051Config {
1172 check_frontmatter: true,
1173 ..Default::default()
1174 }),
1175 2,
1176 "checking frontmatter should report both the frontmatter and body links"
1177 );
1178 assert_eq!(
1179 count(MD051Config {
1180 check_frontmatter: false,
1181 ..Default::default()
1182 }),
1183 1,
1184 "not checking frontmatter should leave only the body link"
1185 );
1186 assert_eq!(
1187 count(MD051Config {
1188 check_frontmatter: true,
1189 ignore_frontmatter_fields: vec!["LINK".to_string()],
1190 ..Default::default()
1191 }),
1192 1,
1193 "an ignored field should be matched case-insensitively"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_cross_file_check_valid_fragment() {
1199 use crate::workspace_index::WorkspaceIndex;
1200
1201 let rule = MD051LinkFragments::new();
1202
1203 let mut workspace_index = WorkspaceIndex::new();
1205 let mut target_file_index = FileIndex::new();
1206 target_file_index.add_heading(HeadingIndex {
1207 text: "Installation Guide".to_string(),
1208 auto_anchor: "installation-guide".to_string(),
1209 custom_anchor: None,
1210 line: 1,
1211 text_lines: 1,
1212 is_setext: false,
1213 });
1214 let (_dir, root) = files_on_disk(&["docs/readme.md", "docs/install.md"]);
1215 workspace_index.insert_file(root.join("docs/install.md"), target_file_index);
1216
1217 let mut current_file_index = FileIndex::new();
1219 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1220 target_path: "install.md".to_string(),
1221 fragment: "installation-guide".to_string(),
1222 line: 3,
1223 column: 5,
1224 origin: LinkOrigin::Body,
1225 });
1226
1227 let warnings = rule
1228 .cross_file_check(&root.join("docs/readme.md"), ¤t_file_index, &workspace_index)
1229 .unwrap();
1230
1231 assert!(warnings.is_empty());
1233 }
1234
1235 #[test]
1236 fn test_cross_file_check_invalid_fragment() {
1237 use crate::workspace_index::WorkspaceIndex;
1238
1239 let rule = MD051LinkFragments::new();
1240
1241 let mut workspace_index = WorkspaceIndex::new();
1243 let mut target_file_index = FileIndex::new();
1244 target_file_index.add_heading(HeadingIndex {
1245 text: "Installation Guide".to_string(),
1246 auto_anchor: "installation-guide".to_string(),
1247 custom_anchor: None,
1248 line: 1,
1249 text_lines: 1,
1250 is_setext: false,
1251 });
1252 let (_dir, root) = files_on_disk(&["docs/readme.md", "docs/install.md"]);
1253 workspace_index.insert_file(root.join("docs/install.md"), target_file_index);
1254
1255 let mut current_file_index = FileIndex::new();
1257 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1258 target_path: "install.md".to_string(),
1259 fragment: "nonexistent".to_string(),
1260 line: 3,
1261 column: 5,
1262 origin: LinkOrigin::Body,
1263 });
1264
1265 let warnings = rule
1266 .cross_file_check(&root.join("docs/readme.md"), ¤t_file_index, &workspace_index)
1267 .unwrap();
1268
1269 assert_eq!(warnings.len(), 1);
1271 assert!(warnings[0].message.contains("nonexistent"));
1272 assert!(warnings[0].message.contains("install.md"));
1273 }
1274
1275 #[test]
1276 fn test_cross_file_check_custom_anchor_match() {
1277 use crate::workspace_index::WorkspaceIndex;
1278
1279 let rule = MD051LinkFragments::new();
1280
1281 let mut workspace_index = WorkspaceIndex::new();
1283 let mut target_file_index = FileIndex::new();
1284 target_file_index.add_heading(HeadingIndex {
1285 text: "Installation Guide".to_string(),
1286 auto_anchor: "installation-guide".to_string(),
1287 custom_anchor: Some("install".to_string()),
1288 line: 1,
1289 text_lines: 1,
1290 is_setext: false,
1291 });
1292 let (_dir, root) = files_on_disk(&["docs/readme.md", "docs/install.md"]);
1293 workspace_index.insert_file(root.join("docs/install.md"), target_file_index);
1294
1295 let mut current_file_index = FileIndex::new();
1297 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1298 target_path: "install.md".to_string(),
1299 fragment: "install".to_string(),
1300 line: 3,
1301 column: 5,
1302 origin: LinkOrigin::Body,
1303 });
1304
1305 let warnings = rule
1306 .cross_file_check(&root.join("docs/readme.md"), ¤t_file_index, &workspace_index)
1307 .unwrap();
1308
1309 assert!(warnings.is_empty());
1311 }
1312
1313 #[test]
1314 fn test_cross_file_check_target_not_in_workspace() {
1315 use crate::workspace_index::WorkspaceIndex;
1316
1317 let rule = MD051LinkFragments::new();
1318
1319 let (_dir, root) = files_on_disk(&["docs/readme.md", "docs/external.md"]);
1321 let workspace_index = WorkspaceIndex::new();
1322
1323 let mut current_file_index = FileIndex::new();
1325 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1326 target_path: "external.md".to_string(),
1327 fragment: "heading".to_string(),
1328 line: 3,
1329 column: 5,
1330 origin: LinkOrigin::Body,
1331 });
1332
1333 let warnings = rule
1334 .cross_file_check(&root.join("docs/readme.md"), ¤t_file_index, &workspace_index)
1335 .unwrap();
1336
1337 assert!(warnings.is_empty());
1339 }
1340
1341 fn heading_index(anchor: &str) -> FileIndex {
1342 let mut index = FileIndex::new();
1343 index.add_heading(HeadingIndex {
1344 text: anchor.to_string(),
1345 auto_anchor: anchor.to_string(),
1346 custom_anchor: None,
1347 line: 1,
1348 text_lines: 1,
1349 is_setext: false,
1350 });
1351 index
1352 }
1353
1354 fn body_link(target_path: &str, fragment: &str) -> FileIndex {
1355 let mut index = FileIndex::new();
1356 index.add_cross_file_link(CrossFileLinkIndex {
1357 target_path: target_path.to_string(),
1358 fragment: fragment.to_string(),
1359 line: 1,
1360 column: 1,
1361 origin: LinkOrigin::Body,
1362 });
1363 index
1364 }
1365
1366 #[cfg(unix)]
1369 #[test]
1370 fn a_target_indexed_under_a_symlinked_spelling_is_checked() {
1371 use crate::workspace_index::WorkspaceIndex;
1372
1373 let (_dir, root) = files_on_disk(&["real/readme.md", "real/install.md"]);
1374 let alias = root.join("alias");
1375 std::os::unix::fs::symlink(root.join("real"), &alias).unwrap();
1376
1377 let mut workspace_index = WorkspaceIndex::new();
1378 workspace_index.insert_file(alias.join("install.md"), heading_index("setup"));
1379
1380 let rule = MD051LinkFragments::new();
1381 let check = |fragment: &str| {
1382 rule.cross_file_check(
1383 &alias.join("readme.md"),
1384 &body_link("install.md", fragment),
1385 &workspace_index,
1386 )
1387 .unwrap()
1388 };
1389 assert!(check("setup").is_empty());
1390 assert_eq!(check("absent").len(), 1);
1391 }
1392
1393 #[cfg(unix)]
1396 #[test]
1397 fn a_symlinked_source_is_not_answered_by_a_namesake_beside_the_link() {
1398 use crate::workspace_index::WorkspaceIndex;
1399
1400 let (_dir, root) = files_on_disk(&["real/readme.md", "real/install.md", "links/install.md"]);
1401 std::os::unix::fs::symlink(root.join("real/readme.md"), root.join("links/readme.md")).unwrap();
1402
1403 let mut workspace_index = WorkspaceIndex::new();
1404 workspace_index.insert_file(root.join("real/install.md"), heading_index("setup"));
1405 workspace_index.insert_file(root.join("links/install.md"), heading_index("other"));
1406
1407 let warnings = MD051LinkFragments::new()
1408 .cross_file_check(
1409 &root.join("links/readme.md"),
1410 &body_link("install.md", "other"),
1411 &workspace_index,
1412 )
1413 .unwrap();
1414 assert_eq!(
1415 warnings.len(),
1416 1,
1417 "#other is only in the namesake beside the link, got {warnings:?}"
1418 );
1419 }
1420
1421 #[test]
1425 fn a_target_open_only_in_the_editor_is_checked() {
1426 use crate::workspace_index::WorkspaceIndex;
1427
1428 let (_dir, root) = files_on_disk(&["readme.md"]);
1429 let mut workspace_index = WorkspaceIndex::new();
1430 workspace_index.insert_file(root.join("unsaved.md"), heading_index("setup"));
1431
1432 let rule = MD051LinkFragments::new();
1433 let source = root.join("readme.md");
1434 let broken = rule
1435 .cross_file_check(&source, &body_link("unsaved.md", "missing"), &workspace_index)
1436 .unwrap();
1437 assert_eq!(broken.len(), 1, "#missing is not in the open document, got {broken:?}");
1438 let valid = rule
1439 .cross_file_check(&source, &body_link("unsaved.md", "setup"), &workspace_index)
1440 .unwrap();
1441 assert!(valid.is_empty(), "#setup is in the open document, got {valid:?}");
1442 }
1443
1444 #[test]
1445 fn an_index_entry_behind_a_file_in_another_case_is_not_checked() {
1446 use crate::workspace_index::WorkspaceIndex;
1447
1448 let (_dir, root) = files_on_disk(&["readme.md", "Guide.md"]);
1449 let mut workspace_index = WorkspaceIndex::new();
1450 workspace_index.insert_file(root.join("guide.md"), heading_index("setup"));
1451
1452 let rule = MD051LinkFragments::new();
1453 let warnings = rule
1454 .cross_file_check(
1455 &root.join("readme.md"),
1456 &body_link("guide.md", "missing"),
1457 &workspace_index,
1458 )
1459 .unwrap();
1460 if root.join("guide.md").exists() {
1464 assert!(warnings.is_empty(), "Guide.md answers the spelling, got {warnings:?}");
1465 } else {
1466 assert_eq!(
1467 warnings.len(),
1468 1,
1469 "#missing is not in the open document, got {warnings:?}"
1470 );
1471 }
1472 }
1473
1474 #[test]
1475 fn test_wikilinks_skipped_in_check() {
1476 let rule = MD051LinkFragments::new();
1478
1479 let content = r#"# Test Document
1480
1481## Valid Heading
1482
1483[[Microsoft#Windows OS]]
1484[[SomePage#section]]
1485[[page|Display Text]]
1486[[path/to/page#section]]
1487"#;
1488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1489 let result = rule.check(&ctx).unwrap();
1490
1491 assert!(
1492 result.is_empty(),
1493 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1494 );
1495 }
1496
1497 #[test]
1498 fn test_wikilinks_not_added_to_cross_file_index() {
1499 let rule = MD051LinkFragments::new();
1501
1502 let content = r#"# Test Document
1503
1504[[Microsoft#Windows OS]]
1505[[SomePage#section]]
1506[Regular Link](other.md#section)
1507"#;
1508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1509
1510 let mut file_index = FileIndex::new();
1511 rule.contribute_to_index(&ctx, &mut file_index);
1512
1513 let cross_file_links = &file_index.cross_file_links;
1516 assert_eq!(
1517 cross_file_links.len(),
1518 1,
1519 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1520 );
1521 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1522 assert_eq!(file_index.cross_file_links[0].fragment, "section");
1523 }
1524
1525 #[test]
1526 fn test_pandoc_flavor_skips_citations() {
1527 let rule = MD051LinkFragments::new();
1531 let content = "# Test Document\n\nSee [@smith2020] for details.\n";
1532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1533 let result = rule.check(&ctx).unwrap();
1534 assert!(
1535 result.is_empty(),
1536 "MD051 should skip Pandoc citations under Pandoc flavor: {result:?}"
1537 );
1538 }
1539
1540 #[test]
1541 fn md051_pandoc_resolves_pandoc_slug_diverging_from_github() {
1542 use crate::config::MarkdownFlavor;
1549 let rule = MD051LinkFragments::new();
1550 let content = "# 5. Five Things\n\nSee [details](#5.-five-things).\n";
1551
1552 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1555 let std_result = rule.check(&ctx_std).unwrap();
1556 assert_eq!(
1557 std_result.len(),
1558 1,
1559 "Standard flavor should flag the Pandoc-style fragment: {std_result:?}"
1560 );
1561
1562 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1564 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1565 assert!(
1566 pandoc_result.is_empty(),
1567 "Pandoc flavor should resolve `#5.-five-things` against the heading slug: {pandoc_result:?}"
1568 );
1569 }
1570
1571 #[test]
1575 fn md051_pandoc_flags_missing_fragment_with_email_in_link_text() {
1576 use crate::config::MarkdownFlavor;
1577 let rule = MD051LinkFragments::new();
1578 let content = "# Title\n\n[contact user@example.com](#missing)\n";
1579
1580 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1581 let std_result = rule.check(&ctx_std).unwrap();
1582 assert_eq!(
1583 std_result.len(),
1584 1,
1585 "Standard flavor must flag the missing fragment: {std_result:?}"
1586 );
1587
1588 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1589 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1590 assert_eq!(
1591 pandoc_result.len(),
1592 1,
1593 "Pandoc flavor must also flag the missing fragment — link text with embedded email is not a citation: {pandoc_result:?}"
1594 );
1595 }
1596
1597 #[test]
1601 fn md051_pandoc_flags_missing_fragment_with_citation_in_link_text() {
1602 use crate::config::MarkdownFlavor;
1603 let rule = MD051LinkFragments::new();
1604 let content = "# Title\n\n[see @smith2020](#missing)\n";
1605
1606 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1607 let std_result = rule.check(&ctx_std).unwrap();
1608 assert_eq!(
1609 std_result.len(),
1610 1,
1611 "Standard flavor must flag the missing fragment: {std_result:?}"
1612 );
1613
1614 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1615 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1616 assert_eq!(
1617 pandoc_result.len(),
1618 1,
1619 "Pandoc flavor must flag the missing fragment — `[label](url)` is a link, not a citation: {pandoc_result:?}"
1620 );
1621 }
1622
1623 #[test]
1627 fn md051_pandoc_resolves_duplicate_heading_suffix_slug() {
1628 use crate::config::MarkdownFlavor;
1629 let rule = MD051LinkFragments::new();
1630 let content = "# A.\n\nfirst\n\n# A.\n\nsecond\n\n[first](#a.) and [second](#a.-1).\n";
1631
1632 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1633 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1634 assert!(
1635 pandoc_result.is_empty(),
1636 "Pandoc flavor should resolve `#a.` and `#a.-1` against duplicate headings: {pandoc_result:?}"
1637 );
1638
1639 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1640 let quarto_result = rule.check(&ctx_quarto).unwrap();
1641 assert!(
1642 quarto_result.is_empty(),
1643 "Quarto flavor should also resolve duplicate-heading suffix slugs: {quarto_result:?}"
1644 );
1645 }
1646
1647 #[test]
1650 fn md051_pandoc_flags_overshoot_duplicate_suffix() {
1651 use crate::config::MarkdownFlavor;
1652 let rule = MD051LinkFragments::new();
1653 let content = "# A.\n\n# A.\n\n[overshoot](#a.-2)\n";
1654
1655 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1656 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1657 assert_eq!(
1658 pandoc_result.len(),
1659 1,
1660 "Pandoc must flag `#a.-2` when only `-1` exists (two duplicates): {pandoc_result:?}"
1661 );
1662 }
1663
1664 fn front_matter_checked() -> MD051Config {
1665 MD051Config {
1666 check_frontmatter: true,
1667 ..MD051Config::default()
1668 }
1669 }
1670
1671 fn check_front_matter(content: &str, config: MD051Config) -> Vec<LintWarning> {
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673 MD051LinkFragments::from_config_struct(config).check(&ctx).unwrap()
1674 }
1675
1676 #[test]
1677 fn a_broken_frontmatter_fragment_is_reported_when_enabled() {
1678 let content = "---\nanchor: '#missing'\nvalid: '#title'\n---\n\n# Title\n";
1679 let result = check_front_matter(content, front_matter_checked());
1680
1681 assert_eq!(
1682 result.len(),
1683 1,
1684 "Only the unresolved fragment is reported. Got: {result:?}"
1685 );
1686 assert_eq!(
1687 result[0].message,
1688 "Link anchor '#missing' does not exist in document headings"
1689 );
1690 assert_eq!(result[0].line, 2);
1691 assert_eq!(result[0].column, 10, "The warning points at the value, not the key");
1692 assert_eq!(result[0].end_column, 18);
1693 }
1694
1695 #[test]
1696 fn frontmatter_fragments_are_not_checked_by_default() {
1697 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
1698 let result = check_front_matter(content, MD051Config::default());
1699
1700 assert!(
1701 result.is_empty(),
1702 "Frontmatter is only checked on request. Got: {result:?}"
1703 );
1704 }
1705
1706 #[test]
1707 fn an_ignored_frontmatter_field_is_not_checked() {
1708 let content = "---\nhero: '#missing'\nanchor: '#other'\n---\n\n# Title\n";
1709 let config = MD051Config {
1710 check_frontmatter: true,
1711 ignore_frontmatter_fields: vec!["Hero".to_string()],
1712 ..MD051Config::default()
1713 };
1714 let result = check_front_matter(content, config);
1715
1716 assert_eq!(
1717 result.len(),
1718 1,
1719 "The ignored field is skipped and the other is not. Got: {result:?}"
1720 );
1721 assert_eq!(result[0].line, 3);
1722 }
1723
1724 #[test]
1725 fn the_ignored_pattern_applies_to_frontmatter_fragments() {
1726 let content = "---\nnote: '#fn:1'\nanchor: '#missing'\n---\n\n# Title\n";
1727 let config = MD051Config {
1728 check_frontmatter: true,
1729 ignored_pattern: Some("^fn:".to_string()),
1730 ..MD051Config::default()
1731 };
1732 let result = check_front_matter(content, config);
1733
1734 assert_eq!(
1735 result.len(),
1736 1,
1737 "The matching fragment is skipped and the other is not. Got: {result:?}"
1738 );
1739 assert_eq!(result[0].line, 3);
1740 }
1741
1742 #[test]
1743 fn a_frontmatter_fragment_honors_ignore_case() {
1744 let content = "---\nanchor: '#Title'\n---\n\n# Title\n";
1745
1746 let permissive = check_front_matter(content, front_matter_checked());
1747 assert!(
1748 permissive.is_empty(),
1749 "The default resolves a case mismatch. Got: {permissive:?}"
1750 );
1751
1752 let strict = check_front_matter(
1753 content,
1754 MD051Config {
1755 check_frontmatter: true,
1756 ignore_case: false,
1757 ..MD051Config::default()
1758 },
1759 );
1760 assert_eq!(strict.len(), 1, "Strict matching reports it. Got: {strict:?}");
1761 }
1762
1763 #[test]
1764 fn prose_in_frontmatter_is_not_read_as_a_fragment() {
1765 let content = "---\ntitle: Node.js\ntags: ci/cd\n---\n\n# Title\n";
1766 let result = check_front_matter(content, front_matter_checked());
1767
1768 assert!(
1769 result.is_empty(),
1770 "Only path-shaped values are destinations. Got: {result:?}"
1771 );
1772 }
1773
1774 #[test]
1775 fn a_frontmatter_path_with_a_fragment_is_validated_across_files() {
1776 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1777 let source = "---\ntemplate: other.md#missing\nvalid: other.md#target\n---\n\n# Source\n";
1778
1779 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1780 let mut source_index = FileIndex::default();
1781 rule.contribute_to_index(&source_ctx, &mut source_index);
1782
1783 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1784 let mut target_index = FileIndex::default();
1785 rule.contribute_to_index(&target_ctx, &mut target_index);
1786
1787 let (_dir, root) = files_on_disk(&["docs/source.md", "docs/other.md"]);
1788 let source_path = root.join("docs/source.md");
1789 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1790 workspace.insert_file(source_path.clone(), source_index.clone());
1791 workspace.insert_file(root.join("docs/other.md"), target_index);
1792
1793 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1794
1795 assert_eq!(
1796 warnings.len(),
1797 1,
1798 "Only the unresolved fragment is reported. Got: {warnings:?}"
1799 );
1800 assert_eq!(warnings[0].message, "Link fragment 'missing' not found in 'other.md'");
1801 assert_eq!(warnings[0].line, 2);
1802 assert_eq!(warnings[0].column, 11);
1803 }
1804
1805 #[test]
1806 fn a_query_string_does_not_hide_the_target_file() {
1807 let rule = MD051LinkFragments::new();
1808 let source = "# Source\n\n- [a](other.md?raw=true#missing)\n- [b](other.md?raw=true#target)\n";
1809
1810 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1811 let mut source_index = FileIndex::default();
1812 rule.contribute_to_index(&source_ctx, &mut source_index);
1813
1814 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1815 let mut target_index = FileIndex::default();
1816 rule.contribute_to_index(&target_ctx, &mut target_index);
1817
1818 let (_dir, root) = files_on_disk(&["docs/source.md", "docs/other.md"]);
1819 let source_path = root.join("docs/source.md");
1820 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1821 workspace.insert_file(source_path.clone(), source_index.clone());
1822 workspace.insert_file(root.join("docs/other.md"), target_index);
1823
1824 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1825
1826 assert_eq!(
1827 warnings.len(),
1828 1,
1829 "The query is stripped to find the file, so both fragments resolve against it. Got: {warnings:?}"
1830 );
1831 assert_eq!(
1832 warnings[0].message,
1833 "Link fragment 'missing' not found in 'other.md?raw=true'"
1834 );
1835 assert_eq!(warnings[0].line, 3);
1836 }
1837
1838 #[test]
1839 fn a_query_string_does_not_hide_an_extensionless_target_file() {
1840 let rule = MD051LinkFragments::new();
1841 let source = "# Source\n\n- [a](other?raw=true#target)\n- [b](other#target)\n- [c](other?raw=true#absent)\n";
1842
1843 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1844 let same_document = rule.check(&source_ctx).unwrap();
1845 assert!(
1846 same_document.is_empty(),
1847 "Every fragment here belongs to another file, so none is a missing anchor of this one. Got: {same_document:?}"
1848 );
1849
1850 let mut source_index = FileIndex::default();
1851 rule.contribute_to_index(&source_ctx, &mut source_index);
1852
1853 let target_ctx = LintContext::new("# Other\n\n## Target\n", crate::config::MarkdownFlavor::Standard, None);
1854 let mut target_index = FileIndex::default();
1855 rule.contribute_to_index(&target_ctx, &mut target_index);
1856
1857 let (_dir, root) = files_on_disk(&["docs/source.md", "docs/other.md"]);
1858 let source_path = root.join("docs/source.md");
1859 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1860 workspace.insert_file(source_path.clone(), source_index.clone());
1861 workspace.insert_file(root.join("docs/other.md"), target_index);
1862
1863 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1864
1865 assert_eq!(
1866 warnings.len(),
1867 1,
1868 "The query is stripped before the markdown extension is added. Got: {warnings:?}"
1869 );
1870 assert_eq!(
1871 warnings[0].message,
1872 "Link fragment 'absent' not found in 'other?raw=true'"
1873 );
1874 assert_eq!(warnings[0].line, 5);
1875 }
1876
1877 #[test]
1878 fn a_destination_that_is_only_a_query_stays_on_this_page() {
1879 let rule = MD051LinkFragments::new();
1880 let source = "# Source\n\n## Here\n\n- [a](?raw=true#here)\n- [b](?raw=true#nowhere)\n";
1881
1882 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1883 let warnings = rule.check(&source_ctx).unwrap();
1884
1885 assert_eq!(
1886 warnings.len(),
1887 1,
1888 "Only the absent anchor is reported. Got: {warnings:?}"
1889 );
1890 assert_eq!(
1891 warnings[0].message,
1892 "Link anchor '#nowhere' does not exist in document headings"
1893 );
1894 assert_eq!(warnings[0].line, 6);
1895
1896 let mut source_index = FileIndex::default();
1897 rule.contribute_to_index(&source_ctx, &mut source_index);
1898 assert!(
1899 source_index.cross_file_links.is_empty(),
1900 "A query with no path names no other file. Got: {:?}",
1901 source_index.cross_file_links
1902 );
1903 }
1904
1905 #[test]
1906 fn blockquote_syntax_inside_raw_html_does_not_create_an_anchor() {
1907 let rule = MD051LinkFragments::new();
1908 let source = "<div>\n> ## Hidden\n</div>\n\n[link](#hidden)\n";
1909 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1910
1911 let warnings = rule.check(&ctx).unwrap();
1912 assert_eq!(
1913 warnings.len(),
1914 1,
1915 "raw HTML must not satisfy the fragment: {warnings:?}"
1916 );
1917
1918 let mut file_index = FileIndex::default();
1919 rule.contribute_to_index(&ctx, &mut file_index);
1920 assert!(
1921 file_index.headings.is_empty(),
1922 "raw HTML must not enter the workspace index"
1923 );
1924 }
1925
1926 #[test]
1927 fn a_frontmatter_path_carrying_a_query_is_indexed() {
1928 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1929 let source = "---\ntemplate: docs/other.md?raw=true#missing\n---\n\n# Source\n";
1930
1931 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1932 let mut source_index = FileIndex::default();
1933 rule.contribute_to_index(&source_ctx, &mut source_index);
1934
1935 assert_eq!(source_index.cross_file_links.len(), 1);
1936 assert_eq!(source_index.cross_file_links[0].target_path, "docs/other.md?raw=true");
1937 assert_eq!(source_index.cross_file_links[0].fragment, "missing");
1938 }
1939
1940 #[test]
1944 fn frontmatter_cross_file_paths_are_not_reported_by_default() {
1945 use crate::workspace_index::WorkspaceIndex;
1946
1947 let rule = MD051LinkFragments::new();
1948 let source = "---\ntemplate: other.md#missing\n---\n\n# Source\n";
1949
1950 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1951 let mut source_index = FileIndex::default();
1952 rule.contribute_to_index(&source_ctx, &mut source_index);
1953 assert_eq!(source_index.cross_file_links.len(), 1);
1954
1955 let mut workspace_index = WorkspaceIndex::new();
1956 let mut target = FileIndex::new();
1957 target.add_heading(HeadingIndex {
1958 text: "Present".to_string(),
1959 auto_anchor: "present".to_string(),
1960 custom_anchor: None,
1961 line: 1,
1962 text_lines: 1,
1963 is_setext: false,
1964 });
1965 let (_dir, root) = files_on_disk(&["source.md", "other.md"]);
1966 workspace_index.insert_file(root.join("other.md"), target);
1967
1968 let warnings = rule
1969 .cross_file_check(&root.join("source.md"), &source_index, &workspace_index)
1970 .unwrap();
1971 assert!(
1972 warnings.is_empty(),
1973 "Frontmatter is only checked on request. Got: {warnings:?}"
1974 );
1975
1976 let checking = MD051LinkFragments::from_config_struct(MD051Config {
1980 check_frontmatter: true,
1981 ..Default::default()
1982 });
1983 assert_eq!(
1984 checking
1985 .cross_file_check(&root.join("source.md"), &source_index, &workspace_index)
1986 .unwrap()
1987 .len(),
1988 1
1989 );
1990 }
1991}