use crate::lint_context::LintContext;
use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
use crate::utils::anchor_styles::AnchorStyle;
use crate::utils::range_utils::calculate_match_range;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
fn default_levels() -> Vec<u8> {
vec![1, 2, 3, 4, 5, 6]
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct MD080Config {
#[serde(default, alias = "anchor_style")]
pub anchor_style: AnchorStyle,
#[serde(default = "default_levels")]
pub levels: Vec<u8>,
}
impl Default for MD080Config {
fn default() -> Self {
Self {
anchor_style: AnchorStyle::default(),
levels: default_levels(),
}
}
}
impl RuleConfig for MD080Config {
const RULE_NAME: &'static str = "MD080";
}
#[derive(Debug, Clone)]
pub struct MD080HeadingAnchorCollision {
config: MD080Config,
anchor_style_pinned: bool,
}
impl Default for MD080HeadingAnchorCollision {
fn default() -> Self {
Self::from_config_struct(MD080Config::default())
}
}
impl MD080HeadingAnchorCollision {
pub fn new() -> Self {
Self::default()
}
pub fn from_config_struct(config: MD080Config) -> Self {
Self {
config,
anchor_style_pinned: true,
}
}
fn anchor_style(&self, ctx: &LintContext) -> AnchorStyle {
if self.anchor_style_pinned {
self.config.anchor_style
} else {
AnchorStyle::for_flavor(ctx.flavor)
}
}
fn effective_anchor(&self, text: &str, custom_id: Option<&str>, anchor_style: AnchorStyle) -> String {
match custom_id {
Some(id) => id.to_string(),
None => anchor_style.generate_fragment(text),
}
}
#[allow(clippy::too_many_arguments)]
fn record(
&self,
text: &str,
custom_id: Option<&str>,
level: u8,
line_num: usize,
content: &str,
anchor_style: AnchorStyle,
seen: &mut HashMap<String, usize>,
warnings: &mut Vec<LintWarning>,
) {
if !self.config.levels.contains(&level) {
return;
}
let anchor = self.effective_anchor(text, custom_id, anchor_style);
if anchor.is_empty() {
return;
}
if let Some(&first_line) = seen.get(&anchor) {
let (start_line, start_col, end_line, end_col) =
calculate_match_range(line_num, content, content.find(text).unwrap_or(0), text.len());
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
severity: Severity::Warning,
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: format!(
"Heading anchor '{anchor}' collides with the heading at line {first_line}; \
fragment links and any derived page identifier resolve only to the first occurrence"
),
fix: None,
});
} else {
seen.insert(anchor, line_num);
}
}
}
impl Rule for MD080HeadingAnchorCollision {
fn name(&self) -> &'static str {
"MD080"
}
fn description(&self) -> &'static str {
"Heading anchors must be unique"
}
fn check(&self, ctx: &LintContext) -> LintResult {
let mut warnings = Vec::new();
let mut seen: HashMap<String, usize> = HashMap::new();
let anchor_style = self.anchor_style(ctx);
for parsed in ctx.headings() {
let heading = parsed.heading;
if !heading.is_valid || heading.text.is_empty() {
continue;
}
self.record(
&heading.text,
heading.custom_id.as_deref(),
heading.level,
parsed.line_num,
parsed.line_info.content(ctx.content),
anchor_style,
&mut seen,
&mut warnings,
);
}
Ok(warnings)
}
fn fix_capability(&self) -> FixCapability {
FixCapability::Unfixable
}
fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
Err(LintError::FixFailed("MD080 has no auto-fix".to_string()))
}
fn category(&self) -> RuleCategory {
RuleCategory::Heading
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_sections!(MD080Config);
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let mut rule_config = crate::rule_config_serde::load_rule_config::<MD080Config>(config);
let explicit_style_present = config
.rules
.get("MD080")
.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(MD080HeadingAnchorCollision {
config: rule_config,
anchor_style_pinned: explicit_style_present,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MarkdownFlavor;
fn check(content: &str) -> Vec<LintWarning> {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
MD080HeadingAnchorCollision::new().check(&ctx).unwrap()
}
fn check_with(config: MD080Config, content: &str) -> Vec<LintWarning> {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
MD080HeadingAnchorCollision::from_config_struct(config)
.check(&ctx)
.unwrap()
}
const ANCHOR_STYLE_PROBE: &str = "# Test--Double\n\n## Test Double\n";
fn collisions(rule: &dyn Rule, flavor: MarkdownFlavor) -> usize {
let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
rule.check(&ctx).unwrap().len()
}
#[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;
MD080HeadingAnchorCollision::from_config(&config)
};
let standard_global = rule_from_global(MarkdownFlavor::Standard);
assert_eq!(
collisions(standard_global.as_ref(), MarkdownFlavor::Standard),
0,
"GitHub anchors keep the doubled hyphen, so there is no collision"
);
assert_eq!(
collisions(standard_global.as_ref(), MarkdownFlavor::MkDocs),
1,
"a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
);
let mkdocs_global = rule_from_global(MarkdownFlavor::MkDocs);
assert_eq!(collisions(mkdocs_global.as_ref(), MarkdownFlavor::MkDocs), 1);
assert_eq!(
collisions(mkdocs_global.as_ref(), MarkdownFlavor::Standard),
0,
"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 = MarkdownFlavor::MkDocs;
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("MD080".to_string(), rule_config);
let rule = MD080HeadingAnchorCollision::from_config(&config);
for flavor in [
MarkdownFlavor::Standard,
MarkdownFlavor::MkDocs,
MarkdownFlavor::Kramdown,
] {
assert_eq!(
collisions(rule.as_ref(), flavor),
0,
"pinned github anchors must survive a {flavor:?} file"
);
}
}
#[test]
fn test_directly_constructed_rule_keeps_its_anchor_style() {
let rule = MD080HeadingAnchorCollision::from_config_struct(MD080Config {
anchor_style: AnchorStyle::PythonMarkdown,
..Default::default()
});
assert_eq!(
collisions(&rule, MarkdownFlavor::Standard),
1,
"an explicitly constructed Python-Markdown rule must not follow the file flavor"
);
}
#[test]
fn flags_distinct_text_same_github_slug() {
let w = check("# Setup & Run\n\n# Setup Run\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert!(w[0].message.contains("collides with the heading at line 1"));
assert_eq!(w[0].line, 3);
}
#[test]
fn flags_punctuation_only_difference() {
let w = check("# C++\n\n## C\n");
assert_eq!(w.len(), 1, "got: {w:?}");
}
#[test]
fn flags_same_text_across_levels() {
let w = check("# Intro\n\nbody\n\n## Intro\n");
assert_eq!(w.len(), 1, "distinct-level slug collision must flag: {w:?}");
assert_eq!(w[0].line, 5);
}
#[test]
fn no_warning_when_slugs_differ() {
assert!(check("# Alpha\n\n## Beta\n\n### Gamma\n").is_empty());
}
#[test]
fn flags_three_way_collision_once_per_extra() {
let w = check("# Dup\n\n## Dup\n\n### Dup\n");
assert_eq!(w.len(), 2, "first defines, each later collides: {w:?}");
assert_eq!(w[0].line, 3);
assert_eq!(w[1].line, 5);
}
#[test]
fn flags_colliding_custom_ids() {
let w = check("# Alpha {#dup}\n\n## Beta {#dup}\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert!(w[0].message.contains("'dup'"));
}
#[test]
fn custom_id_disambiguates_same_text() {
let w = check("# Repeat {#first}\n\n## Repeat {#second}\n");
assert!(w.is_empty(), "explicit ids disambiguate: {w:?}");
}
#[test]
fn ignores_headings_in_code_fences() {
let w = check("# Title\n\n```\n# Title\n```\n");
assert!(w.is_empty(), "fenced `# Title` is not a heading: {w:?}");
}
#[test]
fn ignores_front_matter() {
let w = check("---\ntitle: Title\n---\n\n# Title\n\n## Title\n");
assert_eq!(w.len(), 1, "got: {w:?}");
assert_eq!(w[0].line, 7);
}
#[test]
fn levels_filter_restricts_scope() {
let cfg = MD080Config {
anchor_style: AnchorStyle::GitHub,
levels: vec![1, 2],
};
let w = check_with(cfg, "# Page\n\n### Dup\n\n### Dup\n");
assert!(w.is_empty(), "H3 collisions excluded by levels=[1,2]: {w:?}");
}
#[test]
fn anchor_style_changes_collision_outcome() {
let content = "# a_b\n\n## ab\n";
assert!(
check_with(
MD080Config {
anchor_style: AnchorStyle::GitHub,
levels: default_levels()
},
content
)
.is_empty(),
"GitHub keeps the underscore, slugs stay distinct"
);
assert_eq!(
check_with(
MD080Config {
anchor_style: AnchorStyle::Kramdown,
levels: default_levels()
},
content
)
.len(),
1,
"Kramdown removes `_`, so both headings slug to `ab`"
);
}
#[test]
fn flags_setext_heading_collision() {
let w = check("Intro\n=====\n\nbody\n\n## Intro\n");
assert_eq!(w.len(), 1, "setext + atx slug collision must flag: {w:?}");
assert_eq!(w[0].line, 6);
}
#[test]
fn custom_id_case_is_significant() {
let w = check("# Alpha {#API}\n\n## Beta {#api}\n");
assert!(w.is_empty(), "custom ids differing only in case are distinct: {w:?}");
}
#[test]
fn flags_blockquote_heading_collision() {
let w = check("> ## Intro\n\n## Intro\n");
assert_eq!(w.len(), 1, "blockquote heading slug collision must flag: {w:?}");
assert_eq!(w[0].line, 3);
}
#[test]
fn blockquote_in_html_block_is_not_a_heading() {
let w = check("<div>\n> ## Intro\n</div>\n\n## Intro\n");
assert!(w.is_empty(), "raw HTML must not create a heading collision: {w:?}");
}
#[test]
fn no_auto_fix_offered() {
let w = check("# Dup\n\n## Dup\n");
assert!(w[0].fix.is_none());
let ctx = LintContext::new("# Dup\n\n## Dup\n", MarkdownFlavor::Standard, None);
assert!(MD080HeadingAnchorCollision::new().fix(&ctx).is_err());
}
#[test]
fn empty_document_is_clean() {
assert!(check("").is_empty());
assert!(check("Just prose, no headings.\n").is_empty());
}
}