1use crate::rule::{CrossFileScope, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::anchor_styles::AnchorStyle;
4use crate::utils::frontmatter_values;
5use crate::utils::range_utils::byte_to_char_count;
6use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex};
7use pulldown_cmark::LinkType;
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::path::{Component, Path, PathBuf};
12use std::sync::LazyLock;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "kebab-case")]
17pub struct MD051Config {
18 #[serde(default, alias = "anchor_style")]
20 pub anchor_style: AnchorStyle,
21
22 #[serde(default = "default_ignore_case", alias = "ignore_case")]
28 pub ignore_case: bool,
29
30 #[serde(default, alias = "ignored_pattern")]
34 pub ignored_pattern: Option<String>,
35
36 #[serde(default)]
55 pub check_frontmatter: bool,
56
57 #[serde(default)]
61 pub ignore_frontmatter_fields: Vec<String>,
62}
63
64fn default_ignore_case() -> bool {
65 true
66}
67
68impl Default for MD051Config {
69 fn default() -> Self {
70 Self {
71 anchor_style: AnchorStyle::default(),
72 ignore_case: true,
73 ignored_pattern: None,
74 check_frontmatter: false,
75 ignore_frontmatter_fields: Vec::new(),
76 }
77 }
78}
79
80impl RuleConfig for MD051Config {
81 const RULE_NAME: &'static str = "MD051";
82}
83static HTML_ANCHOR_PATTERN: LazyLock<Regex> =
86 LazyLock::new(|| Regex::new(r#"\b(?:id|name)\s*=\s*["']([^"']+)["']"#).unwrap());
87
88static ATTR_ANCHOR_PATTERN: LazyLock<Regex> =
92 LazyLock::new(|| Regex::new(r#"\{\s*#([a-zA-Z0-9_][a-zA-Z0-9_-]*)[^}]*\}"#).unwrap());
93
94static MD_SETTING_PATTERN: LazyLock<Regex> =
97 LazyLock::new(|| Regex::new(r"<!--\s*md:setting\s+([^\s]+)\s*-->").unwrap());
98
99fn normalize_path(path: &Path) -> PathBuf {
101 let mut result = PathBuf::new();
102 for component in path.components() {
103 match component {
104 Component::CurDir => {} Component::ParentDir => {
106 result.pop(); }
108 c => result.push(c.as_os_str()),
109 }
110 }
111 result
112}
113
114#[derive(Clone)]
121pub struct MD051LinkFragments {
122 config: MD051Config,
123 ignored_pattern_regex: Option<Regex>,
127 ignored_front_matter_fields: HashSet<String>,
129}
130
131struct AnchorSets {
136 markdown_headings: HashSet<String>,
137 markdown_headings_exact: HashSet<String>,
138 html_anchors: HashSet<String>,
139 html_anchors_exact: HashSet<String>,
140}
141
142impl Default for MD051LinkFragments {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148impl MD051LinkFragments {
149 pub fn new() -> Self {
150 Self::from_config_struct(MD051Config::default())
151 }
152
153 pub fn with_anchor_style(style: AnchorStyle) -> Self {
155 Self::from_config_struct(MD051Config {
156 anchor_style: style,
157 ..MD051Config::default()
158 })
159 }
160
161 pub fn from_config_struct(config: MD051Config) -> Self {
167 let ignored_pattern_regex = config
168 .ignored_pattern
169 .as_deref()
170 .and_then(|pattern| match Regex::new(pattern) {
171 Ok(re) => Some(re),
172 Err(err) => {
173 log::warn!(
174 "Invalid ignored_pattern regex for MD051 ('{pattern}'): {err}. Falling back to no filter."
175 );
176 None
177 }
178 });
179 let ignored_front_matter_fields = config
180 .ignore_frontmatter_fields
181 .iter()
182 .map(|field| field.to_lowercase())
183 .collect();
184 Self {
185 config,
186 ignored_pattern_regex,
187 ignored_front_matter_fields,
188 }
189 }
190
191 fn parse_blockquote_heading(bq_content: &str) -> Option<(String, Option<String>)> {
195 crate::utils::header_id_utils::parse_blockquote_atx_heading(bq_content)
196 }
197
198 fn insert_deduplicated_fragment(
206 fragment: String,
207 fragment_counts: &mut HashMap<String, usize>,
208 markdown_headings: &mut HashSet<String>,
209 mut markdown_headings_exact: Option<&mut HashSet<String>>,
210 use_underscore_dedup: bool,
211 ) {
212 let mut also_insert_exact = |form: &str| {
218 if let Some(set) = markdown_headings_exact.as_deref_mut() {
219 set.insert(form.to_string());
220 }
221 };
222
223 if fragment.is_empty() {
224 if !use_underscore_dedup {
225 return;
226 }
227 let count = fragment_counts.entry(fragment).or_insert(0);
229 *count += 1;
230 let formed = format!("_{count}");
231 also_insert_exact(&formed);
232 markdown_headings.insert(formed);
233 return;
234 }
235 if let Some(count) = fragment_counts.get_mut(&fragment) {
236 let suffix = *count;
237 *count += 1;
238 if use_underscore_dedup {
239 let underscore_form = format!("{fragment}_{suffix}");
241 also_insert_exact(&underscore_form);
242 markdown_headings.insert(underscore_form);
243 let dash_form = format!("{fragment}-{suffix}");
245 also_insert_exact(&dash_form);
246 markdown_headings.insert(dash_form);
247 } else {
248 let form = format!("{fragment}-{suffix}");
250 also_insert_exact(&form);
251 markdown_headings.insert(form);
252 }
253 } else {
254 fragment_counts.insert(fragment.clone(), 1);
255 also_insert_exact(&fragment);
256 markdown_headings.insert(fragment);
257 }
258 }
259
260 fn add_heading_to_index(
266 fragment: &str,
267 text: &str,
268 custom_anchor: Option<String>,
269 line: usize,
270 fragment_counts: &mut HashMap<String, usize>,
271 file_index: &mut FileIndex,
272 use_underscore_dedup: bool,
273 ) {
274 if fragment.is_empty() {
275 if !use_underscore_dedup {
276 return;
277 }
278 let count = fragment_counts.entry(fragment.to_string()).or_insert(0);
280 *count += 1;
281 file_index.add_heading(HeadingIndex {
282 text: text.to_string(),
283 auto_anchor: format!("_{count}"),
284 custom_anchor,
285 line,
286 is_setext: false,
287 });
288 return;
289 }
290 if let Some(count) = fragment_counts.get_mut(fragment) {
291 let suffix = *count;
292 *count += 1;
293 let (primary, alias) = if use_underscore_dedup {
294 (format!("{fragment}_{suffix}"), Some(format!("{fragment}-{suffix}")))
296 } else {
297 (format!("{fragment}-{suffix}"), None)
299 };
300 file_index.add_heading(HeadingIndex {
301 text: text.to_string(),
302 auto_anchor: primary,
303 custom_anchor,
304 line,
305 is_setext: false,
306 });
307 if let Some(alias_anchor) = alias {
308 let heading_idx = file_index.headings.len() - 1;
309 file_index.add_anchor_alias(&alias_anchor, heading_idx);
310 }
311 } else {
312 fragment_counts.insert(fragment.to_string(), 1);
313 file_index.add_heading(HeadingIndex {
314 text: text.to_string(),
315 auto_anchor: fragment.to_string(),
316 custom_anchor,
317 line,
318 is_setext: false,
319 });
320 }
321 }
322
323 fn extract_headings_from_context(&self, ctx: &crate::lint_context::LintContext) -> AnchorSets {
330 let track_exact = !self.config.ignore_case;
331 let mut markdown_headings = HashSet::with_capacity(32);
332 let mut markdown_headings_exact = if track_exact {
333 HashSet::with_capacity(32)
334 } else {
335 HashSet::new()
336 };
337 let mut html_anchors = HashSet::with_capacity(16);
338 let mut html_anchors_exact = if track_exact {
339 HashSet::with_capacity(16)
340 } else {
341 HashSet::new()
342 };
343 let mut fragment_counts = std::collections::HashMap::new();
344 let use_underscore_dedup = self.config.anchor_style == AnchorStyle::PythonMarkdown;
345
346 for line_info in &ctx.lines {
347 if line_info.in_front_matter {
348 continue;
349 }
350
351 if line_info.in_code_block {
353 continue;
354 }
355
356 let content = line_info.content(ctx.content);
357 let bytes = content.as_bytes();
358
359 if bytes.contains(&b'<') && (content.contains("id=") || content.contains("name=")) {
361 let mut pos = 0;
364 while pos < content.len() {
365 if let Some(start) = content[pos..].find('<') {
366 let tag_start = pos + start;
367 if let Some(end) = content[tag_start..].find('>') {
368 let tag_end = tag_start + end + 1;
369 let tag = &content[tag_start..tag_end];
370
371 if let Some(caps) = HTML_ANCHOR_PATTERN.find(tag) {
373 let matched_text = caps.as_str();
374 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(matched_text)
375 && let Some(id_match) = caps.get(1)
376 {
377 let id = id_match.as_str();
378 if !id.is_empty() {
379 html_anchors.insert(id.to_lowercase());
380 if track_exact {
381 html_anchors_exact.insert(id.to_string());
382 }
383 }
384 }
385 }
386 pos = tag_end;
387 } else {
388 break;
389 }
390 } else {
391 break;
392 }
393 }
394 }
395
396 if line_info.heading.is_none() && content.contains('{') && content.contains('#') {
399 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
400 if let Some(id_match) = caps.get(1) {
401 let id = id_match.as_str();
402 markdown_headings.insert(id.to_lowercase());
403 if track_exact {
404 markdown_headings_exact.insert(id.to_string());
405 }
406 }
407 }
408 }
409
410 if line_info.heading.is_none()
414 && let Some(bq) = &line_info.blockquote
415 && let Some((clean_text, custom_id)) = Self::parse_blockquote_heading(&bq.content)
416 {
417 if let Some(id) = custom_id {
418 markdown_headings.insert(id.to_lowercase());
419 if track_exact {
420 markdown_headings_exact.insert(id);
421 }
422 }
423 let fragment = self.config.anchor_style.generate_fragment(&clean_text);
424 Self::insert_deduplicated_fragment(
425 fragment,
426 &mut fragment_counts,
427 &mut markdown_headings,
428 track_exact.then_some(&mut markdown_headings_exact),
429 use_underscore_dedup,
430 );
431 }
432
433 if let Some(heading) = &line_info.heading {
435 if let Some(custom_id) = &heading.custom_id {
437 markdown_headings.insert(custom_id.to_lowercase());
438 if track_exact {
439 markdown_headings_exact.insert(custom_id.clone());
440 }
441 }
442
443 let fragment = self.config.anchor_style.generate_fragment(&heading.text);
447
448 Self::insert_deduplicated_fragment(
449 fragment,
450 &mut fragment_counts,
451 &mut markdown_headings,
452 track_exact.then_some(&mut markdown_headings_exact),
453 use_underscore_dedup,
454 );
455 }
456 }
457
458 AnchorSets {
459 markdown_headings,
460 markdown_headings_exact,
461 html_anchors,
462 html_anchors_exact,
463 }
464 }
465
466 #[inline]
468 fn is_external_url_fast(url: &str) -> bool {
469 url.starts_with("http://")
471 || url.starts_with("https://")
472 || url.starts_with("ftp://")
473 || url.starts_with("mailto:")
474 || url.starts_with("tel:")
475 || url.starts_with("//")
476 }
477
478 #[inline]
486 fn resolve_path_with_extensions(path: &Path, extensions: &[&str]) -> Vec<PathBuf> {
487 if path.extension().is_none() {
488 let mut paths = Vec::with_capacity(extensions.len() + 1);
490 paths.push(path.to_path_buf());
492 for ext in extensions {
494 let path_with_ext = path.with_extension(&ext[1..]); paths.push(path_with_ext);
496 }
497 paths
498 } else {
499 vec![path.to_path_buf()]
501 }
502 }
503
504 #[inline]
518 fn is_extensionless_path(path_part: &str) -> bool {
519 if path_part.is_empty() || path_part.contains('.') || path_part.contains('&') || path_part.contains('=') {
521 return false;
522 }
523
524 let mut has_alphanumeric = false;
526 for c in path_part.chars() {
527 if c.is_alphanumeric() {
528 has_alphanumeric = true;
529 } else if !matches!(c, '/' | '\\' | '-' | '_') {
530 return false;
532 }
533 }
534
535 has_alphanumeric
537 }
538
539 #[inline]
541 fn is_cross_file_link(url: &str) -> bool {
542 if let Some(fragment_pos) = url.find('#') {
543 let path_part = &url[..fragment_pos];
544
545 if path_part.is_empty() {
547 return false;
548 }
549
550 if let Some(tag_start) = path_part.find("{%")
556 && path_part[tag_start + 2..].contains("%}")
557 {
558 return true;
559 }
560 if let Some(var_start) = path_part.find("{{")
561 && path_part[var_start + 2..].contains("}}")
562 {
563 return true;
564 }
565
566 if path_part.starts_with('/') {
569 return true;
570 }
571
572 let path_part = path_part.split('?').next().unwrap_or(path_part);
575
576 if path_part.is_empty() {
578 return false;
579 }
580
581 let has_extension = path_part.contains('.')
587 && (
588 {
590 if let Some(after_dot) = path_part.strip_prefix('.') {
592 let dots_count = path_part.matches('.').count();
593 if dots_count == 1 {
594 !after_dot.is_empty() && after_dot.len() <= 10 &&
597 after_dot.chars().all(|c| c.is_ascii_alphanumeric())
598 } else {
599 path_part.split('.').next_back().is_some_and(|ext| {
601 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
602 })
603 }
604 } else {
605 path_part.split('.').next_back().is_some_and(|ext| {
607 !ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
608 })
609 }
610 } ||
611 path_part.contains('/') || path_part.contains('\\') ||
613 path_part.starts_with("./") || path_part.starts_with("../")
615 );
616
617 let is_extensionless = Self::is_extensionless_path(path_part);
620
621 has_extension || is_extensionless
622 } else {
623 false
624 }
625 }
626
627 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
632 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
633 }
634
635 fn front_matter_links(&self, ctx: &crate::lint_context::LintContext) -> Vec<frontmatter_values::FrontMatterLink> {
638 if !self.checks_front_matter_of(ctx) {
639 return Vec::new();
640 }
641 frontmatter_values::link_destinations(ctx, &self.ignored_front_matter_fields)
642 }
643
644 fn fragment_is_exempt(&self, ctx: &crate::lint_context::LintContext, fragment: &str) -> bool {
647 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
652 && (fragment.starts_with("fn:")
653 || fragment.starts_with("fnref:")
654 || (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
655 {
656 return true;
657 }
658
659 self.ignored_pattern_regex
661 .as_ref()
662 .is_some_and(|re| re.is_match(fragment))
663 }
664
665 fn fragment_resolves(&self, fragment: &str, anchors: &AnchorSets) -> bool {
669 if self.config.ignore_case {
670 let lower = fragment.to_lowercase();
671 anchors.html_anchors.contains(&lower) || anchors.markdown_headings.contains(&lower)
672 } else {
673 anchors.html_anchors_exact.contains(fragment) || anchors.markdown_headings_exact.contains(fragment)
674 }
675 }
676
677 fn check_front_matter(
684 &self,
685 ctx: &crate::lint_context::LintContext,
686 links: &[frontmatter_values::FrontMatterLink],
687 anchors: &AnchorSets,
688 warnings: &mut Vec<LintWarning>,
689 ) {
690 for link in links {
691 let line = ctx.lines[link.line - 1].content(ctx.content);
692 let Some(fragment) = line[link.range.clone()].strip_prefix('#') else {
693 continue;
694 };
695 if fragment.is_empty() {
696 continue;
697 }
698
699 if ctx.flavor.is_pandoc_compatible() && ctx.has_pandoc_slug(fragment) {
702 continue;
703 }
704
705 if self.fragment_is_exempt(ctx, fragment) || self.fragment_resolves(fragment, anchors) {
706 continue;
707 }
708
709 let column = byte_to_char_count(line, link.range.start);
710 warnings.push(LintWarning {
711 rule_name: Some(self.name().to_string()),
712 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
713 line: link.line,
714 column,
715 end_line: link.line,
716 end_column: column + 1 + fragment.chars().count(),
717 severity: Severity::Error,
718 fix: None,
719 });
720 }
721 }
722}
723
724impl Rule for MD051LinkFragments {
725 fn name(&self) -> &'static str {
726 "MD051"
727 }
728
729 fn description(&self) -> &'static str {
730 "Link fragments should reference valid headings"
731 }
732
733 fn fix_capability(&self) -> FixCapability {
734 FixCapability::Unfixable
735 }
736
737 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
738 if !ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx) {
742 return true;
743 }
744 !ctx.has_char('#')
746 }
747
748 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
749 let mut warnings = Vec::new();
750
751 if ctx.content.is_empty() || self.should_skip(ctx) {
752 return Ok(warnings);
753 }
754
755 let front_matter_links = self.front_matter_links(ctx);
756 if ctx.links.is_empty() && front_matter_links.is_empty() {
757 return Ok(warnings);
758 }
759
760 let anchors = self.extract_headings_from_context(ctx);
761
762 for link in &ctx.links {
763 if link.is_reference {
764 continue;
765 }
766
767 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
769 continue;
770 }
771
772 if matches!(link.link_type, LinkType::WikiLink { .. }) {
774 continue;
775 }
776
777 if ctx.is_in_jinja_range(link.byte_offset) {
779 continue;
780 }
781
782 if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
785 continue;
786 }
787
788 if ctx.is_in_shortcode(link.byte_offset) {
791 continue;
792 }
793
794 let url = &link.url;
795
796 if !url.contains('#') || Self::is_external_url_fast(url) {
798 continue;
799 }
800
801 if url.contains("{{#") && url.contains("}}") {
804 continue;
805 }
806
807 if ctx.flavor.is_pandoc_compatible()
813 && let Some(frag) = url.strip_prefix('#')
814 && ctx.has_pandoc_slug(frag)
815 {
816 continue;
817 }
818
819 if url.starts_with('@') {
823 continue;
824 }
825
826 if Self::is_cross_file_link(url) {
828 continue;
829 }
830
831 let Some(fragment_pos) = url.find('#') else {
832 continue;
833 };
834
835 let fragment = &url[fragment_pos + 1..];
836
837 if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
839 continue;
840 }
841
842 if fragment.is_empty() {
843 continue;
844 }
845
846 if self.fragment_is_exempt(ctx, fragment) {
847 continue;
848 }
849
850 if !self.fragment_resolves(fragment, &anchors) {
851 warnings.push(LintWarning {
852 rule_name: Some(self.name().to_string()),
853 message: format!("Link anchor '#{fragment}' does not exist in document headings"),
854 line: link.line,
855 column: link.start_col + 1,
856 end_line: link.line,
857 end_column: link.end_col + 1,
858 severity: Severity::Error,
859 fix: None,
860 });
861 }
862 }
863
864 self.check_front_matter(ctx, &front_matter_links, &anchors, &mut warnings);
865
866 Ok(warnings)
867 }
868
869 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
870 Ok(ctx.content.to_string())
873 }
874
875 fn as_any(&self) -> &dyn std::any::Any {
876 self
877 }
878
879 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
880 where
881 Self: Sized,
882 {
883 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD051Config>(config);
884
885 let explicit_style_present = config
888 .rules
889 .get("MD051")
890 .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
891 if !explicit_style_present {
892 rule_config.anchor_style = match config.global.flavor {
893 crate::config::MarkdownFlavor::MkDocs => AnchorStyle::PythonMarkdown,
894 crate::config::MarkdownFlavor::Kramdown => AnchorStyle::KramdownGfm,
895 _ => AnchorStyle::GitHub,
896 };
897 }
898
899 Box::new(MD051LinkFragments::from_config_struct(rule_config))
900 }
901
902 fn category(&self) -> RuleCategory {
903 RuleCategory::Link
904 }
905
906 fn skippable_by_category(&self) -> bool {
907 !self.config.check_frontmatter
910 }
911
912 fn cross_file_scope(&self) -> CrossFileScope {
913 CrossFileScope::Workspace
914 }
915
916 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
917 let mut fragment_counts = HashMap::new();
918 let use_underscore_dedup = self.config.anchor_style == AnchorStyle::PythonMarkdown;
919
920 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
922 if line_info.in_front_matter {
923 continue;
924 }
925
926 if line_info.in_code_block {
928 continue;
929 }
930
931 let content = line_info.content(ctx.content);
932
933 if content.contains('<') && (content.contains("id=") || content.contains("name=")) {
935 let mut pos = 0;
936 while pos < content.len() {
937 if let Some(start) = content[pos..].find('<') {
938 let tag_start = pos + start;
939 if let Some(end) = content[tag_start..].find('>') {
940 let tag_end = tag_start + end + 1;
941 let tag = &content[tag_start..tag_end];
942
943 if let Some(caps) = HTML_ANCHOR_PATTERN.captures(tag)
944 && let Some(id_match) = caps.get(1)
945 {
946 file_index.add_html_anchor(id_match.as_str());
947 }
948 pos = tag_end;
949 } else {
950 break;
951 }
952 } else {
953 break;
954 }
955 }
956 }
957
958 if line_info.heading.is_none() && content.contains('{') && content.contains('#') {
961 for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
962 if let Some(id_match) = caps.get(1) {
963 file_index.add_attribute_anchor(id_match.as_str());
964 }
965 }
966 }
967
968 if line_info.heading.is_none()
970 && let Some(bq) = &line_info.blockquote
971 && let Some((clean_text, custom_id)) = Self::parse_blockquote_heading(&bq.content)
972 {
973 let fragment = self.config.anchor_style.generate_fragment(&clean_text);
974 Self::add_heading_to_index(
975 &fragment,
976 &clean_text,
977 custom_id,
978 line_idx + 1,
979 &mut fragment_counts,
980 file_index,
981 use_underscore_dedup,
982 );
983 }
984
985 if let Some(heading) = &line_info.heading {
987 let fragment = self.config.anchor_style.generate_fragment(&heading.text);
988
989 Self::add_heading_to_index(
990 &fragment,
991 &heading.text,
992 heading.custom_id.clone(),
993 line_idx + 1,
994 &mut fragment_counts,
995 file_index,
996 use_underscore_dedup,
997 );
998
999 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
1004 && let Some(caps) = MD_SETTING_PATTERN.captures(content)
1005 && let Some(name) = caps.get(1)
1006 {
1007 file_index.add_html_anchor(name.as_str());
1008 }
1009 }
1010 }
1011
1012 for link in &ctx.links {
1014 if link.is_reference {
1015 continue;
1016 }
1017
1018 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
1020 continue;
1021 }
1022
1023 if matches!(link.link_type, LinkType::WikiLink { .. }) {
1026 continue;
1027 }
1028
1029 let url = &link.url;
1030
1031 if Self::is_external_url_fast(url) {
1033 continue;
1034 }
1035
1036 if Self::is_cross_file_link(url)
1038 && let Some(fragment_pos) = url.find('#')
1039 {
1040 let path_part = &url[..fragment_pos];
1041 let fragment = &url[fragment_pos + 1..];
1042
1043 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1045 continue;
1046 }
1047
1048 file_index.add_cross_file_link(CrossFileLinkIndex {
1049 target_path: path_part.to_string(),
1050 fragment: fragment.to_string(),
1051 line: link.line,
1052 column: link.start_col + 1,
1053 });
1054 }
1055 }
1056
1057 for link in self.front_matter_links(ctx) {
1059 let line = ctx.lines[link.line - 1].content(ctx.content);
1060 let value = &line[link.range.clone()];
1061
1062 if Self::is_external_url_fast(value) || !Self::is_cross_file_link(value) {
1063 continue;
1064 }
1065
1066 let Some(fragment_pos) = value.find('#') else {
1067 continue;
1068 };
1069 let path_part = &value[..fragment_pos];
1070 let fragment = &value[fragment_pos + 1..];
1071
1072 if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
1074 continue;
1075 }
1076
1077 file_index.add_cross_file_link(CrossFileLinkIndex {
1078 target_path: path_part.to_string(),
1079 fragment: fragment.to_string(),
1080 line: link.line,
1081 column: byte_to_char_count(line, link.range.start),
1082 });
1083 }
1084 }
1085
1086 fn cross_file_check(
1087 &self,
1088 file_path: &Path,
1089 file_index: &FileIndex,
1090 workspace_index: &crate::workspace_index::WorkspaceIndex,
1091 ) -> LintResult {
1092 let mut warnings = Vec::new();
1093
1094 const MARKDOWN_EXTENSIONS: &[&str] = &[
1096 ".md",
1097 ".markdown",
1098 ".mdx",
1099 ".mkd",
1100 ".mkdn",
1101 ".mdown",
1102 ".mdwn",
1103 ".qmd",
1104 ".rmd",
1105 ];
1106
1107 let ignored_pattern = self.ignored_pattern_regex.as_ref();
1108 let ignore_case = self.config.ignore_case;
1109
1110 for cross_link in &file_index.cross_file_links {
1112 if cross_link.fragment.is_empty() {
1114 continue;
1115 }
1116
1117 if ignored_pattern.is_some_and(|re| re.is_match(&cross_link.fragment)) {
1119 continue;
1120 }
1121
1122 let target_path = cross_link
1125 .target_path
1126 .split('?')
1127 .next()
1128 .unwrap_or(&cross_link.target_path);
1129
1130 let base_target_path = if let Some(parent) = file_path.parent() {
1132 parent.join(target_path)
1133 } else {
1134 Path::new(target_path).to_path_buf()
1135 };
1136
1137 let base_target_path = normalize_path(&base_target_path);
1139
1140 let target_paths_to_try = Self::resolve_path_with_extensions(&base_target_path, MARKDOWN_EXTENSIONS);
1143
1144 let mut target_file_index = None;
1146
1147 for target_path in &target_paths_to_try {
1148 if let Some(index) = workspace_index.get_file(target_path) {
1149 target_file_index = Some(index);
1150 break;
1151 }
1152 }
1153
1154 if let Some(target_file_index) = target_file_index {
1155 if !target_file_index.has_anchor_with_case(&cross_link.fragment, ignore_case) {
1157 warnings.push(LintWarning {
1158 rule_name: Some(self.name().to_string()),
1159 line: cross_link.line,
1160 column: cross_link.column,
1161 end_line: cross_link.line,
1162 end_column: cross_link.column
1163 + cross_link.target_path.chars().count()
1164 + 1
1165 + cross_link.fragment.chars().count(),
1166 message: format!(
1167 "Link fragment '{}' not found in '{}'",
1168 cross_link.fragment, cross_link.target_path
1169 ),
1170 severity: Severity::Error,
1171 fix: None,
1172 });
1173 }
1174 }
1175 }
1177
1178 Ok(warnings)
1179 }
1180
1181 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1182 let table = crate::rule_config_serde::config_schema_table(&MD051Config::default())?;
1183 if table.is_empty() {
1184 None
1185 } else {
1186 Some((MD051Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1187 }
1188 }
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193 use super::*;
1194 use crate::lint_context::LintContext;
1195
1196 #[test]
1197 fn test_quarto_cross_references() {
1198 let rule = MD051LinkFragments::new();
1199
1200 let content = r#"# Test Document
1202
1203## Figures
1204
1205See [@fig-plot] for the visualization.
1206
1207More details in [@tbl-results] and [@sec-methods].
1208
1209The equation [@eq-regression] shows the relationship.
1210
1211Reference to [@lst-code] for implementation."#;
1212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1213 let result = rule.check(&ctx).unwrap();
1214 assert!(
1215 result.is_empty(),
1216 "Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
1217 result.len()
1218 );
1219
1220 let content_with_anchor = r#"# Test
1222
1223See [link](#test) for details."#;
1224 let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
1225 let result_anchor = rule.check(&ctx_anchor).unwrap();
1226 assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
1227
1228 let content_invalid = r#"# Test
1230
1231See [link](#nonexistent) for details."#;
1232 let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
1233 let result_invalid = rule.check(&ctx_invalid).unwrap();
1234 assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
1235 }
1236
1237 #[test]
1238 fn test_jsx_in_heading_anchor() {
1239 let rule = MD051LinkFragments::new();
1241
1242 let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
1244 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245 let result = rule.check(&ctx).unwrap();
1246 assert!(
1247 result.is_empty(),
1248 "JSX self-closing tag should be stripped from anchor: got {result:?}"
1249 );
1250
1251 let content2 =
1253 "### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
1254 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
1255 let result2 = rule.check(&ctx2).unwrap();
1256 assert!(
1257 result2.is_empty(),
1258 "JSX tag with attributes should be stripped from anchor: got {result2:?}"
1259 );
1260
1261 let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
1263 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
1264 let result3 = rule.check(&ctx3).unwrap();
1265 assert!(
1266 result3.is_empty(),
1267 "HTML tag content should be preserved in anchor: got {result3:?}"
1268 );
1269 }
1270
1271 #[test]
1273 fn test_cross_file_scope() {
1274 let rule = MD051LinkFragments::new();
1275 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1276 }
1277
1278 #[test]
1279 fn test_contribute_to_index_extracts_headings() {
1280 let rule = MD051LinkFragments::new();
1281 let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
1282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1283
1284 let mut file_index = FileIndex::new();
1285 rule.contribute_to_index(&ctx, &mut file_index);
1286
1287 assert_eq!(file_index.headings.len(), 3);
1288 assert_eq!(file_index.headings[0].text, "First Heading");
1289 assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
1290 assert!(file_index.headings[0].custom_anchor.is_none());
1291
1292 assert_eq!(file_index.headings[1].text, "Second");
1293 assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
1294
1295 assert_eq!(file_index.headings[2].text, "Third");
1296 }
1297
1298 #[test]
1299 fn test_contribute_to_index_extracts_cross_file_links() {
1300 let rule = MD051LinkFragments::new();
1301 let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
1302 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1303
1304 let mut file_index = FileIndex::new();
1305 rule.contribute_to_index(&ctx, &mut file_index);
1306
1307 assert_eq!(file_index.cross_file_links.len(), 2);
1308 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1309 assert_eq!(file_index.cross_file_links[0].fragment, "installation");
1310 assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
1311 assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
1312 }
1313
1314 #[test]
1315 fn test_cross_file_check_valid_fragment() {
1316 use crate::workspace_index::WorkspaceIndex;
1317
1318 let rule = MD051LinkFragments::new();
1319
1320 let mut workspace_index = WorkspaceIndex::new();
1322 let mut target_file_index = FileIndex::new();
1323 target_file_index.add_heading(HeadingIndex {
1324 text: "Installation Guide".to_string(),
1325 auto_anchor: "installation-guide".to_string(),
1326 custom_anchor: None,
1327 line: 1,
1328 is_setext: false,
1329 });
1330 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1331
1332 let mut current_file_index = FileIndex::new();
1334 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1335 target_path: "install.md".to_string(),
1336 fragment: "installation-guide".to_string(),
1337 line: 3,
1338 column: 5,
1339 });
1340
1341 let warnings = rule
1342 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1343 .unwrap();
1344
1345 assert!(warnings.is_empty());
1347 }
1348
1349 #[test]
1350 fn test_cross_file_check_invalid_fragment() {
1351 use crate::workspace_index::WorkspaceIndex;
1352
1353 let rule = MD051LinkFragments::new();
1354
1355 let mut workspace_index = WorkspaceIndex::new();
1357 let mut target_file_index = FileIndex::new();
1358 target_file_index.add_heading(HeadingIndex {
1359 text: "Installation Guide".to_string(),
1360 auto_anchor: "installation-guide".to_string(),
1361 custom_anchor: None,
1362 line: 1,
1363 is_setext: false,
1364 });
1365 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1366
1367 let mut current_file_index = FileIndex::new();
1369 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1370 target_path: "install.md".to_string(),
1371 fragment: "nonexistent".to_string(),
1372 line: 3,
1373 column: 5,
1374 });
1375
1376 let warnings = rule
1377 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1378 .unwrap();
1379
1380 assert_eq!(warnings.len(), 1);
1382 assert!(warnings[0].message.contains("nonexistent"));
1383 assert!(warnings[0].message.contains("install.md"));
1384 }
1385
1386 #[test]
1387 fn test_cross_file_check_custom_anchor_match() {
1388 use crate::workspace_index::WorkspaceIndex;
1389
1390 let rule = MD051LinkFragments::new();
1391
1392 let mut workspace_index = WorkspaceIndex::new();
1394 let mut target_file_index = FileIndex::new();
1395 target_file_index.add_heading(HeadingIndex {
1396 text: "Installation Guide".to_string(),
1397 auto_anchor: "installation-guide".to_string(),
1398 custom_anchor: Some("install".to_string()),
1399 line: 1,
1400 is_setext: false,
1401 });
1402 workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
1403
1404 let mut current_file_index = FileIndex::new();
1406 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1407 target_path: "install.md".to_string(),
1408 fragment: "install".to_string(),
1409 line: 3,
1410 column: 5,
1411 });
1412
1413 let warnings = rule
1414 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1415 .unwrap();
1416
1417 assert!(warnings.is_empty());
1419 }
1420
1421 #[test]
1422 fn test_cross_file_check_target_not_in_workspace() {
1423 use crate::workspace_index::WorkspaceIndex;
1424
1425 let rule = MD051LinkFragments::new();
1426
1427 let workspace_index = WorkspaceIndex::new();
1429
1430 let mut current_file_index = FileIndex::new();
1432 current_file_index.add_cross_file_link(CrossFileLinkIndex {
1433 target_path: "external.md".to_string(),
1434 fragment: "heading".to_string(),
1435 line: 3,
1436 column: 5,
1437 });
1438
1439 let warnings = rule
1440 .cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
1441 .unwrap();
1442
1443 assert!(warnings.is_empty());
1445 }
1446
1447 #[test]
1448 fn test_wikilinks_skipped_in_check() {
1449 let rule = MD051LinkFragments::new();
1451
1452 let content = r#"# Test Document
1453
1454## Valid Heading
1455
1456[[Microsoft#Windows OS]]
1457[[SomePage#section]]
1458[[page|Display Text]]
1459[[path/to/page#section]]
1460"#;
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 let result = rule.check(&ctx).unwrap();
1463
1464 assert!(
1465 result.is_empty(),
1466 "Wikilinks should not trigger MD051 warnings. Got: {result:?}"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_wikilinks_not_added_to_cross_file_index() {
1472 let rule = MD051LinkFragments::new();
1474
1475 let content = r#"# Test Document
1476
1477[[Microsoft#Windows OS]]
1478[[SomePage#section]]
1479[Regular Link](other.md#section)
1480"#;
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482
1483 let mut file_index = FileIndex::new();
1484 rule.contribute_to_index(&ctx, &mut file_index);
1485
1486 let cross_file_links = &file_index.cross_file_links;
1489 assert_eq!(
1490 cross_file_links.len(),
1491 1,
1492 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1493 );
1494 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1495 assert_eq!(file_index.cross_file_links[0].fragment, "section");
1496 }
1497
1498 #[test]
1499 fn test_pandoc_flavor_skips_citations() {
1500 let rule = MD051LinkFragments::new();
1504 let content = "# Test Document\n\nSee [@smith2020] for details.\n";
1505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
1506 let result = rule.check(&ctx).unwrap();
1507 assert!(
1508 result.is_empty(),
1509 "MD051 should skip Pandoc citations under Pandoc flavor: {result:?}"
1510 );
1511 }
1512
1513 #[test]
1514 fn md051_pandoc_resolves_pandoc_slug_diverging_from_github() {
1515 use crate::config::MarkdownFlavor;
1522 let rule = MD051LinkFragments::new();
1523 let content = "# 5. Five Things\n\nSee [details](#5.-five-things).\n";
1524
1525 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1528 let std_result = rule.check(&ctx_std).unwrap();
1529 assert_eq!(
1530 std_result.len(),
1531 1,
1532 "Standard flavor should flag the Pandoc-style fragment: {std_result:?}"
1533 );
1534
1535 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1537 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1538 assert!(
1539 pandoc_result.is_empty(),
1540 "Pandoc flavor should resolve `#5.-five-things` against the heading slug: {pandoc_result:?}"
1541 );
1542 }
1543
1544 #[test]
1548 fn md051_pandoc_flags_missing_fragment_with_email_in_link_text() {
1549 use crate::config::MarkdownFlavor;
1550 let rule = MD051LinkFragments::new();
1551 let content = "# Title\n\n[contact user@example.com](#missing)\n";
1552
1553 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1554 let std_result = rule.check(&ctx_std).unwrap();
1555 assert_eq!(
1556 std_result.len(),
1557 1,
1558 "Standard flavor must flag the missing fragment: {std_result:?}"
1559 );
1560
1561 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1562 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1563 assert_eq!(
1564 pandoc_result.len(),
1565 1,
1566 "Pandoc flavor must also flag the missing fragment — link text with embedded email is not a citation: {pandoc_result:?}"
1567 );
1568 }
1569
1570 #[test]
1574 fn md051_pandoc_flags_missing_fragment_with_citation_in_link_text() {
1575 use crate::config::MarkdownFlavor;
1576 let rule = MD051LinkFragments::new();
1577 let content = "# Title\n\n[see @smith2020](#missing)\n";
1578
1579 let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
1580 let std_result = rule.check(&ctx_std).unwrap();
1581 assert_eq!(
1582 std_result.len(),
1583 1,
1584 "Standard flavor must flag the missing fragment: {std_result:?}"
1585 );
1586
1587 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1588 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1589 assert_eq!(
1590 pandoc_result.len(),
1591 1,
1592 "Pandoc flavor must flag the missing fragment — `[label](url)` is a link, not a citation: {pandoc_result:?}"
1593 );
1594 }
1595
1596 #[test]
1600 fn md051_pandoc_resolves_duplicate_heading_suffix_slug() {
1601 use crate::config::MarkdownFlavor;
1602 let rule = MD051LinkFragments::new();
1603 let content = "# A.\n\nfirst\n\n# A.\n\nsecond\n\n[first](#a.) and [second](#a.-1).\n";
1604
1605 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1606 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1607 assert!(
1608 pandoc_result.is_empty(),
1609 "Pandoc flavor should resolve `#a.` and `#a.-1` against duplicate headings: {pandoc_result:?}"
1610 );
1611
1612 let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
1613 let quarto_result = rule.check(&ctx_quarto).unwrap();
1614 assert!(
1615 quarto_result.is_empty(),
1616 "Quarto flavor should also resolve duplicate-heading suffix slugs: {quarto_result:?}"
1617 );
1618 }
1619
1620 #[test]
1623 fn md051_pandoc_flags_overshoot_duplicate_suffix() {
1624 use crate::config::MarkdownFlavor;
1625 let rule = MD051LinkFragments::new();
1626 let content = "# A.\n\n# A.\n\n[overshoot](#a.-2)\n";
1627
1628 let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
1629 let pandoc_result = rule.check(&ctx_pandoc).unwrap();
1630 assert_eq!(
1631 pandoc_result.len(),
1632 1,
1633 "Pandoc must flag `#a.-2` when only `-1` exists (two duplicates): {pandoc_result:?}"
1634 );
1635 }
1636
1637 fn front_matter_checked() -> MD051Config {
1638 MD051Config {
1639 check_frontmatter: true,
1640 ..MD051Config::default()
1641 }
1642 }
1643
1644 fn check_front_matter(content: &str, config: MD051Config) -> Vec<LintWarning> {
1645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1646 MD051LinkFragments::from_config_struct(config).check(&ctx).unwrap()
1647 }
1648
1649 #[test]
1650 fn a_broken_frontmatter_fragment_is_reported_when_enabled() {
1651 let content = "---\nanchor: '#missing'\nvalid: '#title'\n---\n\n# Title\n";
1652 let result = check_front_matter(content, front_matter_checked());
1653
1654 assert_eq!(
1655 result.len(),
1656 1,
1657 "Only the unresolved fragment is reported. Got: {result:?}"
1658 );
1659 assert_eq!(
1660 result[0].message,
1661 "Link anchor '#missing' does not exist in document headings"
1662 );
1663 assert_eq!(result[0].line, 2);
1664 assert_eq!(result[0].column, 10, "The warning points at the value, not the key");
1665 assert_eq!(result[0].end_column, 18);
1666 }
1667
1668 #[test]
1669 fn frontmatter_fragments_are_not_checked_by_default() {
1670 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
1671 let result = check_front_matter(content, MD051Config::default());
1672
1673 assert!(
1674 result.is_empty(),
1675 "Frontmatter is only checked on request. Got: {result:?}"
1676 );
1677 }
1678
1679 #[test]
1680 fn an_ignored_frontmatter_field_is_not_checked() {
1681 let content = "---\nhero: '#missing'\nanchor: '#other'\n---\n\n# Title\n";
1682 let config = MD051Config {
1683 check_frontmatter: true,
1684 ignore_frontmatter_fields: vec!["Hero".to_string()],
1685 ..MD051Config::default()
1686 };
1687 let result = check_front_matter(content, config);
1688
1689 assert_eq!(
1690 result.len(),
1691 1,
1692 "The ignored field is skipped and the other is not. Got: {result:?}"
1693 );
1694 assert_eq!(result[0].line, 3);
1695 }
1696
1697 #[test]
1698 fn the_ignored_pattern_applies_to_frontmatter_fragments() {
1699 let content = "---\nnote: '#fn:1'\nanchor: '#missing'\n---\n\n# Title\n";
1700 let config = MD051Config {
1701 check_frontmatter: true,
1702 ignored_pattern: Some("^fn:".to_string()),
1703 ..MD051Config::default()
1704 };
1705 let result = check_front_matter(content, config);
1706
1707 assert_eq!(
1708 result.len(),
1709 1,
1710 "The matching fragment is skipped and the other is not. Got: {result:?}"
1711 );
1712 assert_eq!(result[0].line, 3);
1713 }
1714
1715 #[test]
1716 fn a_frontmatter_fragment_honors_ignore_case() {
1717 let content = "---\nanchor: '#Title'\n---\n\n# Title\n";
1718
1719 let permissive = check_front_matter(content, front_matter_checked());
1720 assert!(
1721 permissive.is_empty(),
1722 "The default resolves a case mismatch. Got: {permissive:?}"
1723 );
1724
1725 let strict = check_front_matter(
1726 content,
1727 MD051Config {
1728 check_frontmatter: true,
1729 ignore_case: false,
1730 ..MD051Config::default()
1731 },
1732 );
1733 assert_eq!(strict.len(), 1, "Strict matching reports it. Got: {strict:?}");
1734 }
1735
1736 #[test]
1737 fn prose_in_frontmatter_is_not_read_as_a_fragment() {
1738 let content = "---\ntitle: Node.js\ntags: ci/cd\n---\n\n# Title\n";
1739 let result = check_front_matter(content, front_matter_checked());
1740
1741 assert!(
1742 result.is_empty(),
1743 "Only path-shaped values are destinations. Got: {result:?}"
1744 );
1745 }
1746
1747 #[test]
1748 fn a_frontmatter_path_with_a_fragment_is_validated_across_files() {
1749 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1750 let source = "---\ntemplate: other.md#missing\nvalid: other.md#target\n---\n\n# Source\n";
1751
1752 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1753 let mut source_index = FileIndex::default();
1754 rule.contribute_to_index(&source_ctx, &mut source_index);
1755
1756 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1757 let mut target_index = FileIndex::default();
1758 rule.contribute_to_index(&target_ctx, &mut target_index);
1759
1760 let source_path = PathBuf::from("docs/source.md");
1761 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1762 workspace.insert_file(source_path.clone(), source_index.clone());
1763 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1764
1765 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1766
1767 assert_eq!(
1768 warnings.len(),
1769 1,
1770 "Only the unresolved fragment is reported. Got: {warnings:?}"
1771 );
1772 assert_eq!(warnings[0].message, "Link fragment 'missing' not found in 'other.md'");
1773 assert_eq!(warnings[0].line, 2);
1774 assert_eq!(warnings[0].column, 11);
1775 }
1776
1777 #[test]
1778 fn a_query_string_does_not_hide_the_target_file() {
1779 let rule = MD051LinkFragments::new();
1780 let source = "# Source\n\n- [a](other.md?raw=true#missing)\n- [b](other.md?raw=true#target)\n";
1781
1782 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1783 let mut source_index = FileIndex::default();
1784 rule.contribute_to_index(&source_ctx, &mut source_index);
1785
1786 let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
1787 let mut target_index = FileIndex::default();
1788 rule.contribute_to_index(&target_ctx, &mut target_index);
1789
1790 let source_path = PathBuf::from("docs/source.md");
1791 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1792 workspace.insert_file(source_path.clone(), source_index.clone());
1793 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1794
1795 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1796
1797 assert_eq!(
1798 warnings.len(),
1799 1,
1800 "The query is stripped to find the file, so both fragments resolve against it. Got: {warnings:?}"
1801 );
1802 assert_eq!(
1803 warnings[0].message,
1804 "Link fragment 'missing' not found in 'other.md?raw=true'"
1805 );
1806 assert_eq!(warnings[0].line, 3);
1807 }
1808
1809 #[test]
1810 fn a_query_string_does_not_hide_an_extensionless_target_file() {
1811 let rule = MD051LinkFragments::new();
1812 let source = "# Source\n\n- [a](other?raw=true#target)\n- [b](other#target)\n- [c](other?raw=true#absent)\n";
1813
1814 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1815 let same_document = rule.check(&source_ctx).unwrap();
1816 assert!(
1817 same_document.is_empty(),
1818 "Every fragment here belongs to another file, so none is a missing anchor of this one. Got: {same_document:?}"
1819 );
1820
1821 let mut source_index = FileIndex::default();
1822 rule.contribute_to_index(&source_ctx, &mut source_index);
1823
1824 let target_ctx = LintContext::new("# Other\n\n## Target\n", crate::config::MarkdownFlavor::Standard, None);
1825 let mut target_index = FileIndex::default();
1826 rule.contribute_to_index(&target_ctx, &mut target_index);
1827
1828 let source_path = PathBuf::from("docs/source.md");
1829 let mut workspace = crate::workspace_index::WorkspaceIndex::new();
1830 workspace.insert_file(source_path.clone(), source_index.clone());
1831 workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
1832
1833 let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
1834
1835 assert_eq!(
1836 warnings.len(),
1837 1,
1838 "The query is stripped before the markdown extension is added. Got: {warnings:?}"
1839 );
1840 assert_eq!(
1841 warnings[0].message,
1842 "Link fragment 'absent' not found in 'other?raw=true'"
1843 );
1844 assert_eq!(warnings[0].line, 5);
1845 }
1846
1847 #[test]
1848 fn a_destination_that_is_only_a_query_stays_on_this_page() {
1849 let rule = MD051LinkFragments::new();
1850 let source = "# Source\n\n## Here\n\n- [a](?raw=true#here)\n- [b](?raw=true#nowhere)\n";
1851
1852 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1853 let warnings = rule.check(&source_ctx).unwrap();
1854
1855 assert_eq!(
1856 warnings.len(),
1857 1,
1858 "Only the absent anchor is reported. Got: {warnings:?}"
1859 );
1860 assert_eq!(
1861 warnings[0].message,
1862 "Link anchor '#nowhere' does not exist in document headings"
1863 );
1864 assert_eq!(warnings[0].line, 6);
1865
1866 let mut source_index = FileIndex::default();
1867 rule.contribute_to_index(&source_ctx, &mut source_index);
1868 assert!(
1869 source_index.cross_file_links.is_empty(),
1870 "A query with no path names no other file. Got: {:?}",
1871 source_index.cross_file_links
1872 );
1873 }
1874
1875 #[test]
1876 fn a_frontmatter_path_carrying_a_query_is_indexed() {
1877 let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
1878 let source = "---\ntemplate: docs/other.md?raw=true#missing\n---\n\n# Source\n";
1879
1880 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1881 let mut source_index = FileIndex::default();
1882 rule.contribute_to_index(&source_ctx, &mut source_index);
1883
1884 assert_eq!(source_index.cross_file_links.len(), 1);
1885 assert_eq!(source_index.cross_file_links[0].target_path, "docs/other.md?raw=true");
1886 assert_eq!(source_index.cross_file_links[0].fragment, "missing");
1887 }
1888
1889 #[test]
1890 fn frontmatter_cross_file_paths_are_not_indexed_by_default() {
1891 let rule = MD051LinkFragments::new();
1892 let source = "---\ntemplate: other.md#missing\n---\n\n# Source\n";
1893
1894 let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1895 let mut source_index = FileIndex::default();
1896 rule.contribute_to_index(&source_ctx, &mut source_index);
1897
1898 assert!(
1899 source_index.cross_file_links.is_empty(),
1900 "Frontmatter is only checked on request. Got: {:?}",
1901 source_index.cross_file_links
1902 );
1903 }
1904}