use crate::lint_context::{LineInfo, LintContext};
use crate::rule::{CrossFileScope, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
use crate::utils::anchor_styles::AnchorStyle;
use crate::utils::frontmatter_values;
use crate::utils::header_id_utils::{HTML_BLOCK_OPEN_TAG, HTML_OPEN_TAG, html_tag_attribute, is_backslash_escaped};
use crate::utils::range_utils::byte_to_char_count;
use crate::workspace_index::{CrossFileLinkIndex, FileIndex, HeadingIndex, LinkOrigin};
use pulldown_cmark::LinkType;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::LazyLock;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct MD051Config {
#[serde(default, alias = "anchor_style")]
pub anchor_style: AnchorStyle,
#[serde(default = "default_ignore_case", alias = "ignore_case")]
pub ignore_case: bool,
#[serde(default, alias = "ignored_pattern")]
pub ignored_pattern: Option<String>,
#[serde(default)]
pub check_frontmatter: bool,
#[serde(default)]
pub ignore_frontmatter_fields: Vec<String>,
}
fn default_ignore_case() -> bool {
true
}
impl Default for MD051Config {
fn default() -> Self {
Self {
anchor_style: AnchorStyle::default(),
ignore_case: true,
ignored_pattern: None,
check_frontmatter: false,
ignore_frontmatter_fields: Vec::new(),
}
}
}
impl RuleConfig for MD051Config {
const RULE_NAME: &'static str = "MD051";
}
fn for_each_html_anchor_target(ctx: &LintContext, line_info: &LineInfo, mut record: impl FnMut(&str)) {
let content = line_info.content(ctx.content);
if !content.contains('<') {
return;
}
let escapes_apply = !line_info.in_html_block;
let open_tags: &Regex = if line_info.in_html_block {
&HTML_BLOCK_OPEN_TAG
} else {
&HTML_OPEN_TAG
};
let mut pos = 0;
while let Some(tag) = open_tags.captures_at(content, pos) {
let whole = tag.get(0).unwrap();
let byte_pos = line_info.byte_offset + whole.start();
if ctx.is_in_code_span_byte(byte_pos)
|| ctx.is_in_html_comment(byte_pos)
|| ctx.image_containing(byte_pos).is_some()
|| (escapes_apply && is_backslash_escaped(content, whole.start()))
{
pos = whole.start() + 1;
continue;
}
pos = whole.end();
if let Some(id) = html_tag_attribute(whole.as_str(), "id") {
record(id);
}
if tag[1].eq_ignore_ascii_case("a")
&& let Some(name) = html_tag_attribute(whole.as_str(), "name")
{
record(name);
}
}
}
static ATTR_ANCHOR_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"\{\s*#([a-zA-Z0-9_][a-zA-Z0-9_-]*)[^}]*\}"#).unwrap());
static MD_SETTING_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<!--\s*md:setting\s+([^\s]+)\s*-->").unwrap());
#[derive(Clone)]
pub struct MD051LinkFragments {
config: MD051Config,
ignored_pattern_regex: Option<Regex>,
ignored_front_matter_fields: HashSet<String>,
anchor_style_pinned: bool,
}
struct AnchorSets {
markdown_headings: HashSet<String>,
markdown_headings_exact: HashSet<String>,
html_anchors: HashSet<String>,
html_anchors_exact: HashSet<String>,
}
impl Default for MD051LinkFragments {
fn default() -> Self {
Self::new()
}
}
impl MD051LinkFragments {
pub fn new() -> Self {
Self::from_config_struct(MD051Config::default())
}
pub fn with_anchor_style(style: AnchorStyle) -> Self {
Self::from_config_struct(MD051Config {
anchor_style: style,
..MD051Config::default()
})
}
pub fn from_config_struct(config: MD051Config) -> Self {
Self::from_config_struct_from(config, false)
}
fn from_config_struct_from(config: MD051Config, values_withheld: bool) -> Self {
Self::build(config, values_withheld, true)
}
fn build(config: MD051Config, values_withheld: bool, anchor_style_pinned: bool) -> Self {
let ignored_pattern_regex = config.ignored_pattern.as_deref().and_then(|pattern| {
crate::rule_config_serde::compile_config_regex(pattern, "MD051", "ignored-pattern", values_withheld)
});
let ignored_front_matter_fields = config
.ignore_frontmatter_fields
.iter()
.map(|field| field.to_lowercase())
.collect();
Self {
config,
ignored_pattern_regex,
ignored_front_matter_fields,
anchor_style_pinned,
}
}
fn anchor_style(&self, ctx: &crate::lint_context::LintContext) -> AnchorStyle {
if self.anchor_style_pinned {
self.config.anchor_style
} else {
AnchorStyle::for_flavor(ctx.flavor)
}
}
fn insert_deduplicated_fragment(
fragment: String,
fragment_counts: &mut HashMap<String, usize>,
markdown_headings: &mut HashSet<String>,
mut markdown_headings_exact: Option<&mut HashSet<String>>,
use_underscore_dedup: bool,
) {
let mut also_insert_exact = |form: &str| {
if let Some(set) = markdown_headings_exact.as_deref_mut() {
set.insert(form.to_string());
}
};
if fragment.is_empty() {
if !use_underscore_dedup {
return;
}
let count = fragment_counts.entry(fragment).or_insert(0);
*count += 1;
let formed = format!("_{count}");
also_insert_exact(&formed);
markdown_headings.insert(formed);
return;
}
if let Some(count) = fragment_counts.get_mut(&fragment) {
let suffix = *count;
*count += 1;
if use_underscore_dedup {
let underscore_form = format!("{fragment}_{suffix}");
also_insert_exact(&underscore_form);
markdown_headings.insert(underscore_form);
let dash_form = format!("{fragment}-{suffix}");
also_insert_exact(&dash_form);
markdown_headings.insert(dash_form);
} else {
let form = format!("{fragment}-{suffix}");
also_insert_exact(&form);
markdown_headings.insert(form);
}
} else {
fragment_counts.insert(fragment.clone(), 1);
also_insert_exact(&fragment);
markdown_headings.insert(fragment);
}
}
#[allow(clippy::too_many_arguments)]
fn add_heading_to_index(
fragment: &str,
text: &str,
custom_anchor: Option<String>,
line: usize,
is_setext: bool,
fragment_counts: &mut HashMap<String, usize>,
file_index: &mut FileIndex,
use_underscore_dedup: bool,
) {
if fragment.is_empty() {
if !use_underscore_dedup {
return;
}
let count = fragment_counts.entry(fragment.to_string()).or_insert(0);
*count += 1;
file_index.add_heading(HeadingIndex {
text: text.to_string(),
auto_anchor: format!("_{count}"),
custom_anchor,
line,
is_setext,
});
return;
}
if let Some(count) = fragment_counts.get_mut(fragment) {
let suffix = *count;
*count += 1;
let (primary, alias) = if use_underscore_dedup {
(format!("{fragment}_{suffix}"), Some(format!("{fragment}-{suffix}")))
} else {
(format!("{fragment}-{suffix}"), None)
};
file_index.add_heading(HeadingIndex {
text: text.to_string(),
auto_anchor: primary,
custom_anchor,
line,
is_setext,
});
if let Some(alias_anchor) = alias {
let heading_idx = file_index.headings.len() - 1;
file_index.add_anchor_alias(&alias_anchor, heading_idx);
}
} else {
fragment_counts.insert(fragment.to_string(), 1);
file_index.add_heading(HeadingIndex {
text: text.to_string(),
auto_anchor: fragment.to_string(),
custom_anchor,
line,
is_setext,
});
}
}
fn extract_headings_from_context(&self, ctx: &crate::lint_context::LintContext) -> AnchorSets {
let track_exact = !self.config.ignore_case;
let mut markdown_headings = HashSet::with_capacity(32);
let mut markdown_headings_exact = if track_exact {
HashSet::with_capacity(32)
} else {
HashSet::new()
};
let mut html_anchors = HashSet::with_capacity(16);
let mut html_anchors_exact = if track_exact {
HashSet::with_capacity(16)
} else {
HashSet::new()
};
let mut fragment_counts = std::collections::HashMap::new();
let anchor_style = self.anchor_style(ctx);
let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
for (line_idx, line_info) in ctx.lines.iter().enumerate() {
if line_info.in_front_matter {
continue;
}
if line_info.in_code_block {
continue;
}
let content = line_info.content(ctx.content);
for_each_html_anchor_target(ctx, line_info, |id| {
html_anchors.insert(id.to_lowercase());
if track_exact {
html_anchors_exact.insert(id.to_string());
}
});
let parsed_heading = ctx.heading_on_line(line_idx + 1);
if parsed_heading.is_none() && content.contains('{') && content.contains('#') {
for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
if let Some(id_match) = caps.get(1) {
let id = id_match.as_str();
markdown_headings.insert(id.to_lowercase());
if track_exact {
markdown_headings_exact.insert(id.to_string());
}
}
}
}
if let Some(parsed) = parsed_heading {
let heading = parsed.heading;
if let Some(custom_id) = &heading.custom_id {
markdown_headings.insert(custom_id.to_lowercase());
if track_exact {
markdown_headings_exact.insert(custom_id.clone());
}
}
let fragment = anchor_style.generate_fragment(&heading.text);
Self::insert_deduplicated_fragment(
fragment,
&mut fragment_counts,
&mut markdown_headings,
track_exact.then_some(&mut markdown_headings_exact),
use_underscore_dedup,
);
}
}
AnchorSets {
markdown_headings,
markdown_headings_exact,
html_anchors,
html_anchors_exact,
}
}
#[inline]
fn is_external_url_fast(url: &str) -> bool {
url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("ftp://")
|| url.starts_with("mailto:")
|| url.starts_with("tel:")
|| url.starts_with("//")
}
#[inline]
fn is_extensionless_path(path_part: &str) -> bool {
if path_part.is_empty() || path_part.contains('.') || path_part.contains('&') || path_part.contains('=') {
return false;
}
let mut has_alphanumeric = false;
for c in path_part.chars() {
if c.is_alphanumeric() {
has_alphanumeric = true;
} else if !matches!(c, '/' | '\\' | '-' | '_') {
return false;
}
}
has_alphanumeric
}
#[inline]
fn is_cross_file_link(url: &str) -> bool {
if let Some(fragment_pos) = url.find('#') {
let path_part = &url[..fragment_pos];
if path_part.is_empty() {
return false;
}
if let Some(tag_start) = path_part.find("{%")
&& path_part[tag_start + 2..].contains("%}")
{
return true;
}
if let Some(var_start) = path_part.find("{{")
&& path_part[var_start + 2..].contains("}}")
{
return true;
}
if path_part.starts_with('/') {
return true;
}
let path_part = path_part.split('?').next().unwrap_or(path_part);
if path_part.is_empty() {
return false;
}
let has_extension = path_part.contains('.')
&& (
{
if let Some(after_dot) = path_part.strip_prefix('.') {
let dots_count = path_part.matches('.').count();
if dots_count == 1 {
!after_dot.is_empty() && after_dot.len() <= 10 &&
after_dot.chars().all(|c| c.is_ascii_alphanumeric())
} else {
path_part.split('.').next_back().is_some_and(|ext| {
!ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
})
}
} else {
path_part.split('.').next_back().is_some_and(|ext| {
!ext.is_empty() && ext.len() <= 10 && ext.chars().all(|c| c.is_ascii_alphanumeric())
})
}
} ||
path_part.contains('/') || path_part.contains('\\') ||
path_part.starts_with("./") || path_part.starts_with("../")
);
let is_extensionless = Self::is_extensionless_path(path_part);
has_extension || is_extensionless
} else {
false
}
}
fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
self.config.check_frontmatter && ctx.front_matter_end_line() > 0
}
fn front_matter_links(&self, ctx: &crate::lint_context::LintContext) -> Vec<frontmatter_values::FrontMatterLink> {
if !self.checks_front_matter_of(ctx) {
return Vec::new();
}
frontmatter_values::link_destinations(ctx)
.into_iter()
.filter(|link| !link.field_is_in(&self.ignored_front_matter_fields))
.collect()
}
fn reports_link_from(&self, origin: &LinkOrigin) -> bool {
match origin {
LinkOrigin::Body => true,
LinkOrigin::FrontMatter { field } => {
self.config.check_frontmatter
&& !field
.as_ref()
.is_some_and(|field| self.ignored_front_matter_fields.contains(field))
}
}
}
fn fragment_is_exempt(&self, ctx: &crate::lint_context::LintContext, fragment: &str) -> bool {
if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
&& (fragment.starts_with("fn:")
|| fragment.starts_with("fnref:")
|| (fragment.starts_with('+') && (fragment.contains('.') || fragment.contains(':'))))
{
return true;
}
self.ignored_pattern_regex
.as_ref()
.is_some_and(|re| re.is_match(fragment))
}
fn fragment_resolves(&self, fragment: &str, anchors: &AnchorSets) -> bool {
if self.config.ignore_case {
let lower = fragment.to_lowercase();
anchors.html_anchors.contains(&lower) || anchors.markdown_headings.contains(&lower)
} else {
anchors.html_anchors_exact.contains(fragment) || anchors.markdown_headings_exact.contains(fragment)
}
}
fn check_front_matter(
&self,
ctx: &crate::lint_context::LintContext,
links: &[frontmatter_values::FrontMatterLink],
anchors: &AnchorSets,
warnings: &mut Vec<LintWarning>,
) {
for link in links {
let line = ctx.lines[link.line - 1].content(ctx.content);
let Some(fragment) = line[link.range.clone()].strip_prefix('#') else {
continue;
};
if fragment.is_empty() {
continue;
}
if ctx.flavor.is_pandoc_compatible() && ctx.has_pandoc_slug(fragment) {
continue;
}
if self.fragment_is_exempt(ctx, fragment) || self.fragment_resolves(fragment, anchors) {
continue;
}
let column = byte_to_char_count(line, link.range.start);
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message: format!("Link anchor '#{fragment}' does not exist in document headings"),
line: link.line,
column,
end_line: link.line,
end_column: column + 1 + fragment.chars().count(),
severity: Severity::Error,
fix: None,
});
}
}
}
impl Rule for MD051LinkFragments {
fn name(&self) -> &'static str {
"MD051"
}
fn description(&self) -> &'static str {
"Link fragments should reference valid headings"
}
fn fix_capability(&self) -> FixCapability {
FixCapability::Unfixable
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
if !ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx) {
return true;
}
!ctx.has_char('#')
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut warnings = Vec::new();
if ctx.content.is_empty() || self.should_skip(ctx) {
return Ok(warnings);
}
let front_matter_links = self.front_matter_links(ctx);
if ctx.links().is_empty() && front_matter_links.is_empty() {
return Ok(warnings);
}
let anchors = self.extract_headings_from_context(ctx);
for link in ctx.links() {
if link.is_reference {
continue;
}
if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
continue;
}
if matches!(link.link_type, LinkType::WikiLink { .. }) {
continue;
}
if ctx.is_in_jinja_range(link.byte_offset) {
continue;
}
if ctx.flavor.is_pandoc_compatible() && ctx.is_in_citation(link.byte_offset) {
continue;
}
if ctx.is_in_shortcode(link.byte_offset) {
continue;
}
let url = &link.url;
if !url.contains('#') || Self::is_external_url_fast(url) {
continue;
}
if url.contains("{{#") && url.contains("}}") {
continue;
}
if ctx.flavor.is_pandoc_compatible()
&& let Some(frag) = url.strip_prefix('#')
&& ctx.has_pandoc_slug(frag)
{
continue;
}
if url.starts_with('@') {
continue;
}
if Self::is_cross_file_link(url) {
continue;
}
let Some(fragment_pos) = url.find('#') else {
continue;
};
let fragment = &url[fragment_pos + 1..];
if (url.contains("{{") && fragment.contains('|')) || fragment.ends_with("}}") || fragment.ends_with("%}") {
continue;
}
if fragment.is_empty() {
continue;
}
if self.fragment_is_exempt(ctx, fragment) {
continue;
}
if !self.fragment_resolves(fragment, &anchors) {
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
message: format!("Link anchor '#{fragment}' does not exist in document headings"),
line: link.line,
column: link.start_col + 1,
end_line: link.end_line,
end_column: link.end_col + 1,
severity: Severity::Error,
fix: None,
});
}
}
self.check_front_matter(ctx, &front_matter_links, &anchors, &mut warnings);
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
Ok(ctx.content.to_string())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let mut rule_config = crate::rule_config_serde::load_rule_config::<MD051Config>(config);
let explicit_style_present = config
.rules
.get("MD051")
.is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
if !explicit_style_present {
rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
}
Box::new(MD051LinkFragments::build(
rule_config,
config.withheld_rule_values.contains("MD051"),
explicit_style_present,
))
}
fn category(&self) -> RuleCategory {
RuleCategory::Link
}
fn skippable_by_category(&self) -> bool {
!self.config.check_frontmatter
}
fn cross_file_scope(&self) -> CrossFileScope {
CrossFileScope::Workspace
}
fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, file_index: &mut FileIndex) {
let mut fragment_counts = HashMap::new();
let anchor_style = self.anchor_style(ctx);
let use_underscore_dedup = anchor_style == AnchorStyle::PythonMarkdown;
for (line_idx, line_info) in ctx.lines.iter().enumerate() {
if line_info.in_front_matter {
continue;
}
if line_info.in_code_block {
continue;
}
let content = line_info.content(ctx.content);
for_each_html_anchor_target(ctx, line_info, |id| file_index.add_html_anchor(id));
let parsed_heading = ctx.heading_on_line(line_idx + 1);
if parsed_heading.is_none() && content.contains('{') && content.contains('#') {
for caps in ATTR_ANCHOR_PATTERN.captures_iter(content) {
if let Some(id_match) = caps.get(1) {
file_index.add_attribute_anchor(id_match.as_str());
}
}
}
if let Some(parsed) = parsed_heading {
let heading = parsed.heading;
let fragment = anchor_style.generate_fragment(&heading.text);
Self::add_heading_to_index(
&fragment,
&heading.text,
heading.custom_id.clone(),
line_idx + 1,
parsed.is_setext(),
&mut fragment_counts,
file_index,
use_underscore_dedup,
);
if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
&& let Some(caps) = MD_SETTING_PATTERN.captures(content)
&& let Some(name) = caps.get(1)
{
file_index.add_html_anchor(name.as_str());
}
}
}
for link in ctx.links() {
if link.is_reference {
continue;
}
if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
continue;
}
if matches!(link.link_type, LinkType::WikiLink { .. }) {
continue;
}
let url = &link.url;
if Self::is_external_url_fast(url) {
continue;
}
if Self::is_cross_file_link(url)
&& let Some(fragment_pos) = url.find('#')
{
let path_part = &url[..fragment_pos];
let fragment = &url[fragment_pos + 1..];
if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
continue;
}
file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: path_part.to_string(),
fragment: fragment.to_string(),
line: link.line,
column: link.start_col + 1,
origin: LinkOrigin::Body,
});
}
}
for link in frontmatter_values::link_destinations(ctx) {
let line = ctx.lines[link.line - 1].content(ctx.content);
let value = &line[link.range.clone()];
if Self::is_external_url_fast(value) || !Self::is_cross_file_link(value) {
continue;
}
let Some(fragment_pos) = value.find('#') else {
continue;
};
let path_part = &value[..fragment_pos];
let fragment = &value[fragment_pos + 1..];
if fragment.is_empty() || fragment.contains("{{") || fragment.contains("{%") {
continue;
}
file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: path_part.to_string(),
fragment: fragment.to_string(),
line: link.line,
column: byte_to_char_count(line, link.range.start),
origin: LinkOrigin::FrontMatter { field: link.field },
});
}
}
fn cross_file_check(
&self,
file_path: &Path,
file_index: &FileIndex,
workspace_index: &crate::workspace_index::WorkspaceIndex,
) -> LintResult {
let mut warnings = Vec::new();
let ignored_pattern = self.ignored_pattern_regex.as_ref();
let ignore_case = self.config.ignore_case;
for cross_link in &file_index.cross_file_links {
if cross_link.fragment.is_empty() {
continue;
}
if !self.reports_link_from(&cross_link.origin) {
continue;
}
if ignored_pattern.is_some_and(|re| re.is_match(&cross_link.fragment)) {
continue;
}
let target_paths_to_try =
crate::workspace_index::link_target_candidates(file_path, &cross_link.target_path);
let mut target_file_index = None;
for target_path in &target_paths_to_try {
if let Some(index) = workspace_index.get_file(target_path) {
target_file_index = Some(index);
break;
}
}
if let Some(target_file_index) = target_file_index {
if !target_file_index.has_anchor_with_case(&cross_link.fragment, ignore_case) {
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: cross_link.line,
column: cross_link.column,
end_line: cross_link.line,
end_column: cross_link.column
+ cross_link.target_path.chars().count()
+ 1
+ cross_link.fragment.chars().count(),
message: format!(
"Link fragment '{}' not found in '{}'",
cross_link.fragment, cross_link.target_path
),
severity: Severity::Error,
fix: None,
});
}
}
}
Ok(warnings)
}
crate::impl_rule_config_sections!(MD051Config);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
use std::path::PathBuf;
const ANCHOR_STYLE_PROBE: &str = "### Getting Started — Advanced\n\n\
[python-markdown slug](#getting-started-advanced)\n\
[github slug](#getting-started--advanced)\n";
fn flagged_fragment(rule: &dyn Rule, flavor: crate::config::MarkdownFlavor) -> String {
let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"exactly one of the two links must be invalid under any style: {warnings:?}"
);
warnings[0].message.clone()
}
#[test]
fn test_unpinned_anchor_style_follows_the_file_flavor() {
let rule_from_global = |flavor| {
let mut config = crate::config::Config::default();
config.global.flavor = flavor;
MD051LinkFragments::from_config(&config)
};
let standard_global = rule_from_global(crate::config::MarkdownFlavor::Standard);
assert!(
flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::Standard)
.contains("#getting-started-advanced'"),
"a standard file must be checked against GitHub anchors"
);
assert!(
flagged_fragment(standard_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
.contains("#getting-started--advanced'"),
"a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
);
let mkdocs_global = rule_from_global(crate::config::MarkdownFlavor::MkDocs);
assert!(
flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::MkDocs)
.contains("#getting-started--advanced'"),
"a mkdocs file must be checked against Python-Markdown anchors"
);
assert!(
flagged_fragment(mkdocs_global.as_ref(), crate::config::MarkdownFlavor::Standard)
.contains("#getting-started-advanced'"),
"a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
);
}
#[test]
fn test_pinned_anchor_style_ignores_the_file_flavor() {
let mut config = crate::config::Config::default();
config.global.flavor = crate::config::MarkdownFlavor::Standard;
let mut rule_config = crate::config::RuleConfig::default();
rule_config
.values
.insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
config.rules.insert("MD051".to_string(), rule_config);
let rule = MD051LinkFragments::from_config(&config);
for flavor in [
crate::config::MarkdownFlavor::Standard,
crate::config::MarkdownFlavor::MkDocs,
crate::config::MarkdownFlavor::Kramdown,
] {
assert!(
flagged_fragment(rule.as_ref(), flavor).contains("#getting-started-advanced'"),
"pinned github anchors must survive a {flavor:?} file"
);
}
}
#[test]
fn test_directly_constructed_rule_keeps_its_anchor_style() {
let rule = MD051LinkFragments::from_config_struct(MD051Config {
anchor_style: AnchorStyle::PythonMarkdown,
..Default::default()
});
assert!(
flagged_fragment(&rule, crate::config::MarkdownFlavor::Standard).contains("#getting-started--advanced'"),
"an explicitly constructed Python-Markdown rule must not follow the file flavor"
);
}
#[test]
fn test_quarto_cross_references() {
let rule = MD051LinkFragments::new();
let content = r#"# Test Document
## Figures
See [@fig-plot] for the visualization.
More details in [@tbl-results] and [@sec-methods].
The equation [@eq-regression] shows the relationship.
Reference to [@lst-code] for implementation."#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Quarto cross-references (@fig-, @tbl-, @sec-, @eq-) should not trigger MD051 warnings. Got {} warnings",
result.len()
);
let content_with_anchor = r#"# Test
See [link](#test) for details."#;
let ctx_anchor = LintContext::new(content_with_anchor, crate::config::MarkdownFlavor::Quarto, None);
let result_anchor = rule.check(&ctx_anchor).unwrap();
assert!(result_anchor.is_empty(), "Valid anchor should not trigger warning");
let content_invalid = r#"# Test
See [link](#nonexistent) for details."#;
let ctx_invalid = LintContext::new(content_invalid, crate::config::MarkdownFlavor::Quarto, None);
let result_invalid = rule.check(&ctx_invalid).unwrap();
assert_eq!(result_invalid.len(), 1, "Invalid anchor should still trigger warning");
}
#[test]
fn test_jsx_in_heading_anchor() {
let rule = MD051LinkFragments::new();
let content = "# Test\n\n### `retentionPolicy`<Component />\n\n[link](#retentionpolicy)\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"JSX self-closing tag should be stripped from anchor: got {result:?}"
);
let content2 =
"### retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />\n\n[link](#retentionpolicy)\n";
let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert!(
result2.is_empty(),
"JSX tag with attributes should be stripped from anchor: got {result2:?}"
);
let content3 = "### Test <span>extra</span>\n\n[link](#test-extra)\n";
let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
let result3 = rule.check(&ctx3).unwrap();
assert!(
result3.is_empty(),
"HTML tag content should be preserved in anchor: got {result3:?}"
);
}
#[test]
fn test_cross_file_scope() {
let rule = MD051LinkFragments::new();
assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
}
#[test]
fn test_contribute_to_index_extracts_headings() {
let rule = MD051LinkFragments::new();
let content = "# First Heading\n\n# Second { #custom }\n\n## Third";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let mut file_index = FileIndex::new();
rule.contribute_to_index(&ctx, &mut file_index);
assert_eq!(file_index.headings.len(), 3);
assert_eq!(file_index.headings[0].text, "First Heading");
assert_eq!(file_index.headings[0].auto_anchor, "first-heading");
assert!(file_index.headings[0].custom_anchor.is_none());
assert_eq!(file_index.headings[1].text, "Second");
assert_eq!(file_index.headings[1].custom_anchor, Some("custom".to_string()));
assert_eq!(file_index.headings[2].text, "Third");
}
#[test]
fn test_contribute_to_index_extracts_cross_file_links() {
let rule = MD051LinkFragments::new();
let content = "See [docs](other.md#installation) and [more](../guide.md#getting-started)";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let mut file_index = FileIndex::new();
rule.contribute_to_index(&ctx, &mut file_index);
assert_eq!(file_index.cross_file_links.len(), 2);
assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
assert_eq!(file_index.cross_file_links[0].fragment, "installation");
assert_eq!(file_index.cross_file_links[1].target_path, "../guide.md");
assert_eq!(file_index.cross_file_links[1].fragment, "getting-started");
}
#[test]
fn test_contribute_to_index_records_setext_headings() {
let rule = MD051LinkFragments::new();
let content = "Setext One\n==========\n\nSetext Two\n----------\n\n### Atx Three\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let mut file_index = FileIndex::new();
rule.contribute_to_index(&ctx, &mut file_index);
let styles: Vec<(&str, bool)> = file_index
.headings
.iter()
.map(|h| (h.text.as_str(), h.is_setext))
.collect();
assert_eq!(
styles,
vec![("Setext One", true), ("Setext Two", true), ("Atx Three", false)]
);
}
#[test]
fn test_a_frontmatter_link_is_indexed_regardless_of_the_indexing_config() {
let content = "---\nlink: 'other.md#nope'\n---\n\n# Real\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
for check_frontmatter in [true, false] {
let rule = MD051LinkFragments::from_config_struct(MD051Config {
check_frontmatter,
..Default::default()
});
let mut file_index = FileIndex::new();
rule.contribute_to_index(&ctx, &mut file_index);
assert_eq!(
file_index.cross_file_links.len(),
1,
"check_frontmatter = {check_frontmatter} changed what was indexed"
);
assert_eq!(
file_index.cross_file_links[0].origin,
LinkOrigin::FrontMatter {
field: Some("link".to_string())
},
);
}
}
#[test]
fn test_cross_file_check_applies_this_files_frontmatter_config() {
use crate::workspace_index::WorkspaceIndex;
let mut workspace_index = WorkspaceIndex::new();
let mut target = FileIndex::new();
target.add_heading(HeadingIndex {
text: "Real".to_string(),
auto_anchor: "real".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
workspace_index.insert_file(PathBuf::from("docs/other.md"), target);
let mut file_index = FileIndex::new();
file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "other.md".to_string(),
fragment: "nope".to_string(),
line: 2,
column: 7,
origin: LinkOrigin::FrontMatter {
field: Some("link".to_string()),
},
});
file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "other.md".to_string(),
fragment: "nope".to_string(),
line: 6,
column: 5,
origin: LinkOrigin::Body,
});
let count = |config: MD051Config| {
MD051LinkFragments::from_config_struct(config)
.cross_file_check(Path::new("docs/readme.md"), &file_index, &workspace_index)
.unwrap()
.len()
};
assert_eq!(
count(MD051Config {
check_frontmatter: true,
..Default::default()
}),
2,
"checking frontmatter should report both the frontmatter and body links"
);
assert_eq!(
count(MD051Config {
check_frontmatter: false,
..Default::default()
}),
1,
"not checking frontmatter should leave only the body link"
);
assert_eq!(
count(MD051Config {
check_frontmatter: true,
ignore_frontmatter_fields: vec!["LINK".to_string()],
..Default::default()
}),
1,
"an ignored field should be matched case-insensitively"
);
}
#[test]
fn test_cross_file_check_valid_fragment() {
use crate::workspace_index::WorkspaceIndex;
let rule = MD051LinkFragments::new();
let mut workspace_index = WorkspaceIndex::new();
let mut target_file_index = FileIndex::new();
target_file_index.add_heading(HeadingIndex {
text: "Installation Guide".to_string(),
auto_anchor: "installation-guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
let mut current_file_index = FileIndex::new();
current_file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "install.md".to_string(),
fragment: "installation-guide".to_string(),
line: 3,
column: 5,
origin: LinkOrigin::Body,
});
let warnings = rule
.cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
.unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_cross_file_check_invalid_fragment() {
use crate::workspace_index::WorkspaceIndex;
let rule = MD051LinkFragments::new();
let mut workspace_index = WorkspaceIndex::new();
let mut target_file_index = FileIndex::new();
target_file_index.add_heading(HeadingIndex {
text: "Installation Guide".to_string(),
auto_anchor: "installation-guide".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
let mut current_file_index = FileIndex::new();
current_file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "install.md".to_string(),
fragment: "nonexistent".to_string(),
line: 3,
column: 5,
origin: LinkOrigin::Body,
});
let warnings = rule
.cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
.unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("nonexistent"));
assert!(warnings[0].message.contains("install.md"));
}
#[test]
fn test_cross_file_check_custom_anchor_match() {
use crate::workspace_index::WorkspaceIndex;
let rule = MD051LinkFragments::new();
let mut workspace_index = WorkspaceIndex::new();
let mut target_file_index = FileIndex::new();
target_file_index.add_heading(HeadingIndex {
text: "Installation Guide".to_string(),
auto_anchor: "installation-guide".to_string(),
custom_anchor: Some("install".to_string()),
line: 1,
is_setext: false,
});
workspace_index.insert_file(PathBuf::from("docs/install.md"), target_file_index);
let mut current_file_index = FileIndex::new();
current_file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "install.md".to_string(),
fragment: "install".to_string(),
line: 3,
column: 5,
origin: LinkOrigin::Body,
});
let warnings = rule
.cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
.unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_cross_file_check_target_not_in_workspace() {
use crate::workspace_index::WorkspaceIndex;
let rule = MD051LinkFragments::new();
let workspace_index = WorkspaceIndex::new();
let mut current_file_index = FileIndex::new();
current_file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "external.md".to_string(),
fragment: "heading".to_string(),
line: 3,
column: 5,
origin: LinkOrigin::Body,
});
let warnings = rule
.cross_file_check(Path::new("docs/readme.md"), ¤t_file_index, &workspace_index)
.unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_wikilinks_skipped_in_check() {
let rule = MD051LinkFragments::new();
let content = r#"# Test Document
## Valid Heading
[[Microsoft#Windows OS]]
[[SomePage#section]]
[[page|Display Text]]
[[path/to/page#section]]
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Wikilinks should not trigger MD051 warnings. Got: {result:?}"
);
}
#[test]
fn test_wikilinks_not_added_to_cross_file_index() {
let rule = MD051LinkFragments::new();
let content = r#"# Test Document
[[Microsoft#Windows OS]]
[[SomePage#section]]
[Regular Link](other.md#section)
"#;
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let mut file_index = FileIndex::new();
rule.contribute_to_index(&ctx, &mut file_index);
let cross_file_links = &file_index.cross_file_links;
assert_eq!(
cross_file_links.len(),
1,
"Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
);
assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
assert_eq!(file_index.cross_file_links[0].fragment, "section");
}
#[test]
fn test_pandoc_flavor_skips_citations() {
let rule = MD051LinkFragments::new();
let content = "# Test Document\n\nSee [@smith2020] for details.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD051 should skip Pandoc citations under Pandoc flavor: {result:?}"
);
}
#[test]
fn md051_pandoc_resolves_pandoc_slug_diverging_from_github() {
use crate::config::MarkdownFlavor;
let rule = MD051LinkFragments::new();
let content = "# 5. Five Things\n\nSee [details](#5.-five-things).\n";
let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
let std_result = rule.check(&ctx_std).unwrap();
assert_eq!(
std_result.len(),
1,
"Standard flavor should flag the Pandoc-style fragment: {std_result:?}"
);
let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pandoc_result = rule.check(&ctx_pandoc).unwrap();
assert!(
pandoc_result.is_empty(),
"Pandoc flavor should resolve `#5.-five-things` against the heading slug: {pandoc_result:?}"
);
}
#[test]
fn md051_pandoc_flags_missing_fragment_with_email_in_link_text() {
use crate::config::MarkdownFlavor;
let rule = MD051LinkFragments::new();
let content = "# Title\n\n[contact user@example.com](#missing)\n";
let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
let std_result = rule.check(&ctx_std).unwrap();
assert_eq!(
std_result.len(),
1,
"Standard flavor must flag the missing fragment: {std_result:?}"
);
let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pandoc_result = rule.check(&ctx_pandoc).unwrap();
assert_eq!(
pandoc_result.len(),
1,
"Pandoc flavor must also flag the missing fragment — link text with embedded email is not a citation: {pandoc_result:?}"
);
}
#[test]
fn md051_pandoc_flags_missing_fragment_with_citation_in_link_text() {
use crate::config::MarkdownFlavor;
let rule = MD051LinkFragments::new();
let content = "# Title\n\n[see @smith2020](#missing)\n";
let ctx_std = LintContext::new(content, MarkdownFlavor::Standard, None);
let std_result = rule.check(&ctx_std).unwrap();
assert_eq!(
std_result.len(),
1,
"Standard flavor must flag the missing fragment: {std_result:?}"
);
let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pandoc_result = rule.check(&ctx_pandoc).unwrap();
assert_eq!(
pandoc_result.len(),
1,
"Pandoc flavor must flag the missing fragment — `[label](url)` is a link, not a citation: {pandoc_result:?}"
);
}
#[test]
fn md051_pandoc_resolves_duplicate_heading_suffix_slug() {
use crate::config::MarkdownFlavor;
let rule = MD051LinkFragments::new();
let content = "# A.\n\nfirst\n\n# A.\n\nsecond\n\n[first](#a.) and [second](#a.-1).\n";
let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pandoc_result = rule.check(&ctx_pandoc).unwrap();
assert!(
pandoc_result.is_empty(),
"Pandoc flavor should resolve `#a.` and `#a.-1` against duplicate headings: {pandoc_result:?}"
);
let ctx_quarto = LintContext::new(content, MarkdownFlavor::Quarto, None);
let quarto_result = rule.check(&ctx_quarto).unwrap();
assert!(
quarto_result.is_empty(),
"Quarto flavor should also resolve duplicate-heading suffix slugs: {quarto_result:?}"
);
}
#[test]
fn md051_pandoc_flags_overshoot_duplicate_suffix() {
use crate::config::MarkdownFlavor;
let rule = MD051LinkFragments::new();
let content = "# A.\n\n# A.\n\n[overshoot](#a.-2)\n";
let ctx_pandoc = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pandoc_result = rule.check(&ctx_pandoc).unwrap();
assert_eq!(
pandoc_result.len(),
1,
"Pandoc must flag `#a.-2` when only `-1` exists (two duplicates): {pandoc_result:?}"
);
}
fn front_matter_checked() -> MD051Config {
MD051Config {
check_frontmatter: true,
..MD051Config::default()
}
}
fn check_front_matter(content: &str, config: MD051Config) -> Vec<LintWarning> {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
MD051LinkFragments::from_config_struct(config).check(&ctx).unwrap()
}
#[test]
fn a_broken_frontmatter_fragment_is_reported_when_enabled() {
let content = "---\nanchor: '#missing'\nvalid: '#title'\n---\n\n# Title\n";
let result = check_front_matter(content, front_matter_checked());
assert_eq!(
result.len(),
1,
"Only the unresolved fragment is reported. Got: {result:?}"
);
assert_eq!(
result[0].message,
"Link anchor '#missing' does not exist in document headings"
);
assert_eq!(result[0].line, 2);
assert_eq!(result[0].column, 10, "The warning points at the value, not the key");
assert_eq!(result[0].end_column, 18);
}
#[test]
fn frontmatter_fragments_are_not_checked_by_default() {
let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
let result = check_front_matter(content, MD051Config::default());
assert!(
result.is_empty(),
"Frontmatter is only checked on request. Got: {result:?}"
);
}
#[test]
fn an_ignored_frontmatter_field_is_not_checked() {
let content = "---\nhero: '#missing'\nanchor: '#other'\n---\n\n# Title\n";
let config = MD051Config {
check_frontmatter: true,
ignore_frontmatter_fields: vec!["Hero".to_string()],
..MD051Config::default()
};
let result = check_front_matter(content, config);
assert_eq!(
result.len(),
1,
"The ignored field is skipped and the other is not. Got: {result:?}"
);
assert_eq!(result[0].line, 3);
}
#[test]
fn the_ignored_pattern_applies_to_frontmatter_fragments() {
let content = "---\nnote: '#fn:1'\nanchor: '#missing'\n---\n\n# Title\n";
let config = MD051Config {
check_frontmatter: true,
ignored_pattern: Some("^fn:".to_string()),
..MD051Config::default()
};
let result = check_front_matter(content, config);
assert_eq!(
result.len(),
1,
"The matching fragment is skipped and the other is not. Got: {result:?}"
);
assert_eq!(result[0].line, 3);
}
#[test]
fn a_frontmatter_fragment_honors_ignore_case() {
let content = "---\nanchor: '#Title'\n---\n\n# Title\n";
let permissive = check_front_matter(content, front_matter_checked());
assert!(
permissive.is_empty(),
"The default resolves a case mismatch. Got: {permissive:?}"
);
let strict = check_front_matter(
content,
MD051Config {
check_frontmatter: true,
ignore_case: false,
..MD051Config::default()
},
);
assert_eq!(strict.len(), 1, "Strict matching reports it. Got: {strict:?}");
}
#[test]
fn prose_in_frontmatter_is_not_read_as_a_fragment() {
let content = "---\ntitle: Node.js\ntags: ci/cd\n---\n\n# Title\n";
let result = check_front_matter(content, front_matter_checked());
assert!(
result.is_empty(),
"Only path-shaped values are destinations. Got: {result:?}"
);
}
#[test]
fn a_frontmatter_path_with_a_fragment_is_validated_across_files() {
let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
let source = "---\ntemplate: other.md#missing\nvalid: other.md#target\n---\n\n# Source\n";
let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let mut source_index = FileIndex::default();
rule.contribute_to_index(&source_ctx, &mut source_index);
let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
let mut target_index = FileIndex::default();
rule.contribute_to_index(&target_ctx, &mut target_index);
let source_path = PathBuf::from("docs/source.md");
let mut workspace = crate::workspace_index::WorkspaceIndex::new();
workspace.insert_file(source_path.clone(), source_index.clone());
workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
assert_eq!(
warnings.len(),
1,
"Only the unresolved fragment is reported. Got: {warnings:?}"
);
assert_eq!(warnings[0].message, "Link fragment 'missing' not found in 'other.md'");
assert_eq!(warnings[0].line, 2);
assert_eq!(warnings[0].column, 11);
}
#[test]
fn a_query_string_does_not_hide_the_target_file() {
let rule = MD051LinkFragments::new();
let source = "# Source\n\n- [a](other.md?raw=true#missing)\n- [b](other.md?raw=true#target)\n";
let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let mut source_index = FileIndex::default();
rule.contribute_to_index(&source_ctx, &mut source_index);
let target_ctx = LintContext::new("# Target\n", crate::config::MarkdownFlavor::Standard, None);
let mut target_index = FileIndex::default();
rule.contribute_to_index(&target_ctx, &mut target_index);
let source_path = PathBuf::from("docs/source.md");
let mut workspace = crate::workspace_index::WorkspaceIndex::new();
workspace.insert_file(source_path.clone(), source_index.clone());
workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
assert_eq!(
warnings.len(),
1,
"The query is stripped to find the file, so both fragments resolve against it. Got: {warnings:?}"
);
assert_eq!(
warnings[0].message,
"Link fragment 'missing' not found in 'other.md?raw=true'"
);
assert_eq!(warnings[0].line, 3);
}
#[test]
fn a_query_string_does_not_hide_an_extensionless_target_file() {
let rule = MD051LinkFragments::new();
let source = "# Source\n\n- [a](other?raw=true#target)\n- [b](other#target)\n- [c](other?raw=true#absent)\n";
let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let same_document = rule.check(&source_ctx).unwrap();
assert!(
same_document.is_empty(),
"Every fragment here belongs to another file, so none is a missing anchor of this one. Got: {same_document:?}"
);
let mut source_index = FileIndex::default();
rule.contribute_to_index(&source_ctx, &mut source_index);
let target_ctx = LintContext::new("# Other\n\n## Target\n", crate::config::MarkdownFlavor::Standard, None);
let mut target_index = FileIndex::default();
rule.contribute_to_index(&target_ctx, &mut target_index);
let source_path = PathBuf::from("docs/source.md");
let mut workspace = crate::workspace_index::WorkspaceIndex::new();
workspace.insert_file(source_path.clone(), source_index.clone());
workspace.insert_file(PathBuf::from("docs/other.md"), target_index);
let warnings = rule.cross_file_check(&source_path, &source_index, &workspace).unwrap();
assert_eq!(
warnings.len(),
1,
"The query is stripped before the markdown extension is added. Got: {warnings:?}"
);
assert_eq!(
warnings[0].message,
"Link fragment 'absent' not found in 'other?raw=true'"
);
assert_eq!(warnings[0].line, 5);
}
#[test]
fn a_destination_that_is_only_a_query_stays_on_this_page() {
let rule = MD051LinkFragments::new();
let source = "# Source\n\n## Here\n\n- [a](?raw=true#here)\n- [b](?raw=true#nowhere)\n";
let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&source_ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"Only the absent anchor is reported. Got: {warnings:?}"
);
assert_eq!(
warnings[0].message,
"Link anchor '#nowhere' does not exist in document headings"
);
assert_eq!(warnings[0].line, 6);
let mut source_index = FileIndex::default();
rule.contribute_to_index(&source_ctx, &mut source_index);
assert!(
source_index.cross_file_links.is_empty(),
"A query with no path names no other file. Got: {:?}",
source_index.cross_file_links
);
}
#[test]
fn blockquote_syntax_inside_raw_html_does_not_create_an_anchor() {
let rule = MD051LinkFragments::new();
let source = "<div>\n> ## Hidden\n</div>\n\n[link](#hidden)\n";
let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"raw HTML must not satisfy the fragment: {warnings:?}"
);
let mut file_index = FileIndex::default();
rule.contribute_to_index(&ctx, &mut file_index);
assert!(
file_index.headings.is_empty(),
"raw HTML must not enter the workspace index"
);
}
#[test]
fn a_frontmatter_path_carrying_a_query_is_indexed() {
let rule = MD051LinkFragments::from_config_struct(front_matter_checked());
let source = "---\ntemplate: docs/other.md?raw=true#missing\n---\n\n# Source\n";
let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let mut source_index = FileIndex::default();
rule.contribute_to_index(&source_ctx, &mut source_index);
assert_eq!(source_index.cross_file_links.len(), 1);
assert_eq!(source_index.cross_file_links[0].target_path, "docs/other.md?raw=true");
assert_eq!(source_index.cross_file_links[0].fragment, "missing");
}
#[test]
fn frontmatter_cross_file_paths_are_not_reported_by_default() {
use crate::workspace_index::WorkspaceIndex;
let rule = MD051LinkFragments::new();
let source = "---\ntemplate: other.md#missing\n---\n\n# Source\n";
let source_ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
let mut source_index = FileIndex::default();
rule.contribute_to_index(&source_ctx, &mut source_index);
assert_eq!(source_index.cross_file_links.len(), 1);
let mut workspace_index = WorkspaceIndex::new();
let mut target = FileIndex::new();
target.add_heading(HeadingIndex {
text: "Present".to_string(),
auto_anchor: "present".to_string(),
custom_anchor: None,
line: 1,
is_setext: false,
});
workspace_index.insert_file(PathBuf::from("other.md"), target);
let warnings = rule
.cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
.unwrap();
assert!(
warnings.is_empty(),
"Frontmatter is only checked on request. Got: {warnings:?}"
);
let checking = MD051LinkFragments::from_config_struct(MD051Config {
check_frontmatter: true,
..Default::default()
});
assert_eq!(
checking
.cross_file_check(Path::new("source.md"), &source_index, &workspace_index)
.unwrap()
.len(),
1
);
}
}