Skip to main content

rumdl_lib/rules/
md080_heading_anchor_collision.rs

1//! Rule MD080: Heading anchors must be unique.
2//!
3//! Two headings whose generated URL-safe anchor (slug) is identical produce a
4//! collision: a `[text](#slug)` link and, under the MDXG virtual-page model,
5//! the page identifier derived from an H1/H2 title can only resolve to the
6//! *first* occurrence. GitHub/MkDocs paper over this by auto-suffixing the
7//! later anchor (`slug-1`), which is functional but surprising and breaks any
8//! hand-written `#slug` link that meant the second heading.
9//!
10//! This is distinct from:
11//! - **MD024** (duplicate heading *text*) - misses distinct texts that
12//!   slugify identically (`Setup & Run` vs `Setup Run`, `C++` vs `C`).
13//! - **MD051** (broken/missing fragment *targets*) - this flags *ambiguous*
14//!   targets, where the reference resolves but not unambiguously.
15//!
16//! Diagnostic only: renaming a heading is a semantic choice, so there is no
17//! auto-fix. Opt-in, because the collision is functional under platform
18//! auto-suffixing and flagging it changes established lint output.
19
20use crate::lint_context::{LintContext, ParsedHeading};
21use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
22use crate::rule_config_serde::RuleConfig;
23use crate::utils::anchor_styles::AnchorStyle;
24use serde::{Deserialize, Serialize};
25use std::collections::HashMap;
26
27fn default_levels() -> Vec<u8> {
28    vec![1, 2, 3, 4, 5, 6]
29}
30
31/// Configuration for MD080 (Heading anchor collision)
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33#[serde(rename_all = "kebab-case")]
34pub struct MD080Config {
35    /// Anchor generation style to match the target platform.
36    #[serde(default, alias = "anchor_style")]
37    pub anchor_style: AnchorStyle,
38
39    /// Heading levels whose anchors must be unique. Defaults to all levels
40    /// (any heading can be a fragment target). Set to `[1, 2]` to check only
41    /// the MDXG virtual-page identifiers derived from H1/H2 titles.
42    #[serde(default = "default_levels")]
43    pub levels: Vec<u8>,
44}
45
46impl Default for MD080Config {
47    fn default() -> Self {
48        Self {
49            anchor_style: AnchorStyle::default(),
50            levels: default_levels(),
51        }
52    }
53}
54
55impl RuleConfig for MD080Config {
56    const RULE_NAME: &'static str = "MD080";
57}
58
59#[derive(Debug, Clone)]
60pub struct MD080HeadingAnchorCollision {
61    config: MD080Config,
62    /// Whether `config.anchor_style` was chosen rather than derived from a
63    /// flavor. Unpinned, the style follows the flavor of the file being
64    /// checked; see [`MD080HeadingAnchorCollision::anchor_style`].
65    anchor_style_pinned: bool,
66}
67
68impl Default for MD080HeadingAnchorCollision {
69    fn default() -> Self {
70        Self::from_config_struct(MD080Config::default())
71    }
72}
73
74impl MD080HeadingAnchorCollision {
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    pub fn from_config_struct(config: MD080Config) -> Self {
80        Self {
81            config,
82            anchor_style_pinned: true,
83        }
84    }
85
86    /// The anchor style to generate slugs with for the file in `ctx`.
87    ///
88    /// A style the user pinned applies to every file. Otherwise it follows the
89    /// flavor the file is parsed with, which `per-file-flavor` can make
90    /// different from the global flavor the rule was constructed with.
91    fn anchor_style(&self, ctx: &LintContext) -> AnchorStyle {
92        if self.anchor_style_pinned {
93            self.config.anchor_style
94        } else {
95            AnchorStyle::for_flavor(ctx.flavor)
96        }
97    }
98
99    /// The anchor a heading actually resolves to. An explicit `{#custom-id}`
100    /// wins over the generated slug (it is what platforms emit) and is
101    /// compared in its emitted case: HTML `id` matching is case-sensitive, so
102    /// `{#API}` and `{#api}` are distinct anchors. Generated slugs are already
103    /// case-normalized by the anchor style.
104    fn effective_anchor(&self, text: &str, custom_id: Option<&str>, anchor_style: AnchorStyle) -> String {
105        match custom_id {
106            Some(id) => id.to_string(),
107            None => anchor_style.generate_fragment(text),
108        }
109    }
110
111    /// Resolve a heading's anchor and either record it as the first occurrence
112    /// or, if some earlier heading already produced the same anchor, emit a
113    /// collision warning pointing back at that first heading. The anchor comes
114    /// from `slug_text`; the warning covers the heading text, which the
115    /// underline of a Setext heading can stretch across several lines.
116    fn record(
117        &self,
118        parsed: &ParsedHeading<'_>,
119        ctx: &LintContext,
120        anchor_style: AnchorStyle,
121        seen: &mut HashMap<String, usize>,
122        warnings: &mut Vec<LintWarning>,
123    ) {
124        let heading = parsed.heading;
125        if !self.config.levels.contains(&heading.level) {
126            return;
127        }
128
129        let anchor = self.effective_anchor(&heading.slug_text, heading.custom_id.as_deref(), anchor_style);
130        if anchor.is_empty() {
131            return;
132        }
133
134        if let Some(&first_line) = seen.get(&anchor) {
135            let (start_line, start_col, end_line, end_col) = parsed.text_position_range(ctx);
136            warnings.push(LintWarning {
137                rule_name: Some(self.name().to_string()),
138                severity: Severity::Warning,
139                line: start_line,
140                column: start_col,
141                end_line,
142                end_column: end_col,
143                message: format!(
144                    "Heading anchor '{anchor}' collides with the heading at line {first_line}; \
145                     fragment links and any derived page identifier resolve only to the first occurrence"
146                ),
147                fix: None,
148            });
149        } else {
150            seen.insert(anchor, parsed.first_line_num());
151        }
152    }
153}
154
155impl Rule for MD080HeadingAnchorCollision {
156    fn name(&self) -> &'static str {
157        "MD080"
158    }
159
160    fn description(&self) -> &'static str {
161        "Heading anchors must be unique"
162    }
163
164    fn check(&self, ctx: &LintContext) -> LintResult {
165        let mut warnings = Vec::new();
166        // anchor -> 1-based line of the first heading that produced it.
167        let mut seen: HashMap<String, usize> = HashMap::new();
168        let anchor_style = self.anchor_style(ctx);
169
170        for parsed in ctx.headings() {
171            let heading = parsed.heading;
172            if !heading.is_valid || heading.text.is_empty() {
173                continue;
174            }
175            self.record(&parsed, ctx, anchor_style, &mut seen, &mut warnings);
176        }
177
178        Ok(warnings)
179    }
180
181    fn fix_capability(&self) -> FixCapability {
182        // Renaming a heading (and every link that targets it) is a semantic
183        // decision the linter must not make automatically, so the fix
184        // coordinator must treat MD080 as diagnostic-only.
185        FixCapability::Unfixable
186    }
187
188    fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
189        Err(LintError::FixFailed("MD080 has no auto-fix".to_string()))
190    }
191
192    fn category(&self) -> RuleCategory {
193        RuleCategory::Heading
194    }
195
196    fn as_any(&self) -> &dyn std::any::Any {
197        self
198    }
199
200    crate::impl_rule_config_sections!(MD080Config);
201
202    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
203    where
204        Self: Sized,
205    {
206        let mut rule_config = crate::rule_config_serde::load_rule_config::<MD080Config>(config);
207
208        // Mirror MD051: when the user has not pinned an anchor style, follow
209        // the active flavor's native anchor generation. The global flavor settles
210        // it here for `rumdl config`; a file `per-file-flavor` gives another flavor
211        // re-derives it in `anchor_style()`.
212        let explicit_style_present = config
213            .rules
214            .get("MD080")
215            .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
216        if !explicit_style_present {
217            rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
218        }
219
220        Box::new(MD080HeadingAnchorCollision {
221            config: rule_config,
222            anchor_style_pinned: explicit_style_present,
223        })
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::config::MarkdownFlavor;
231
232    fn check(content: &str) -> Vec<LintWarning> {
233        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
234        MD080HeadingAnchorCollision::new().check(&ctx).unwrap()
235    }
236
237    fn check_with(config: MD080Config, content: &str) -> Vec<LintWarning> {
238        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
239        MD080HeadingAnchorCollision::from_config_struct(config)
240            .check(&ctx)
241            .unwrap()
242    }
243
244    /// Python-Markdown collapses the doubled hyphen, so these two headings share
245    /// `#test-double`; GitHub keeps it, so they do not collide. The count alone
246    /// therefore names the anchor style in force.
247    const ANCHOR_STYLE_PROBE: &str = "# Test--Double\n\n## Test Double\n";
248
249    fn collisions(rule: &dyn Rule, flavor: MarkdownFlavor) -> usize {
250        let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
251        rule.check(&ctx).unwrap().len()
252    }
253
254    /// An unpinned anchor style follows the flavor of the file being checked,
255    /// not the global flavor the rule was constructed with. `per-file-flavor`
256    /// makes those differ, and the style is decided per file.
257    #[test]
258    fn test_unpinned_anchor_style_follows_the_file_flavor() {
259        let rule_from_global = |flavor| {
260            let mut config = crate::config::Config::default();
261            config.global.flavor = flavor;
262            MD080HeadingAnchorCollision::from_config(&config)
263        };
264
265        // Global standard: construction settles on GitHub anchors, under which
266        // the two headings keep distinct slugs.
267        let standard_global = rule_from_global(MarkdownFlavor::Standard);
268        assert_eq!(
269            collisions(standard_global.as_ref(), MarkdownFlavor::Standard),
270            0,
271            "GitHub anchors keep the doubled hyphen, so there is no collision"
272        );
273        // A file `per-file-flavor` parses as MkDocs is checked against
274        // Python-Markdown anchors, where the two slugs are the same.
275        assert_eq!(
276            collisions(standard_global.as_ref(), MarkdownFlavor::MkDocs),
277            1,
278            "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
279        );
280
281        // The same in reverse: a standard file under a MkDocs global flavor.
282        let mkdocs_global = rule_from_global(MarkdownFlavor::MkDocs);
283        assert_eq!(collisions(mkdocs_global.as_ref(), MarkdownFlavor::MkDocs), 1);
284        assert_eq!(
285            collisions(mkdocs_global.as_ref(), MarkdownFlavor::Standard),
286            0,
287            "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
288        );
289    }
290
291    /// Control for the above: a style the user pinned is theirs, and applies to
292    /// every file whatever flavor it is parsed with.
293    #[test]
294    fn test_pinned_anchor_style_ignores_the_file_flavor() {
295        let mut config = crate::config::Config::default();
296        config.global.flavor = MarkdownFlavor::MkDocs;
297        let mut rule_config = crate::config::RuleConfig::default();
298        rule_config
299            .values
300            .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
301        config.rules.insert("MD080".to_string(), rule_config);
302        let rule = MD080HeadingAnchorCollision::from_config(&config);
303
304        for flavor in [
305            MarkdownFlavor::Standard,
306            MarkdownFlavor::MkDocs,
307            MarkdownFlavor::Kramdown,
308        ] {
309            assert_eq!(
310                collisions(rule.as_ref(), flavor),
311                0,
312                "pinned github anchors must survive a {flavor:?} file"
313            );
314        }
315    }
316
317    /// Directly constructed rules are pinned: nothing derived their style from a
318    /// flavor, so there is nothing to re-derive.
319    #[test]
320    fn test_directly_constructed_rule_keeps_its_anchor_style() {
321        let rule = MD080HeadingAnchorCollision::from_config_struct(MD080Config {
322            anchor_style: AnchorStyle::PythonMarkdown,
323            ..Default::default()
324        });
325        assert_eq!(
326            collisions(&rule, MarkdownFlavor::Standard),
327            1,
328            "an explicitly constructed Python-Markdown rule must not follow the file flavor"
329        );
330    }
331
332    #[test]
333    fn flags_distinct_text_same_github_slug() {
334        // "Setup & Run" and "Setup Run" both slugify to `setup--run` /
335        // `setup-run` family; under GitHub they collide on `setup--run`.
336        let w = check("# Setup & Run\n\n# Setup  Run\n");
337        assert_eq!(w.len(), 1, "got: {w:?}");
338        assert!(w[0].message.contains("collides with the heading at line 1"));
339        assert_eq!(w[0].line, 3);
340    }
341
342    #[test]
343    fn flags_punctuation_only_difference() {
344        // "C++" -> "c", "C" -> "c" under GitHub.
345        let w = check("# C++\n\n## C\n");
346        assert_eq!(w.len(), 1, "got: {w:?}");
347    }
348
349    #[test]
350    fn flags_same_text_across_levels() {
351        // Same text at different levels: MD024 with allow_different_nesting
352        // would NOT flag this, but the anchor `#intro` is genuinely ambiguous.
353        let w = check("# Intro\n\nbody\n\n## Intro\n");
354        assert_eq!(w.len(), 1, "distinct-level slug collision must flag: {w:?}");
355        assert_eq!(w[0].line, 5);
356    }
357
358    #[test]
359    fn no_warning_when_slugs_differ() {
360        assert!(check("# Alpha\n\n## Beta\n\n### Gamma\n").is_empty());
361    }
362
363    #[test]
364    fn flags_three_way_collision_once_per_extra() {
365        let w = check("# Dup\n\n## Dup\n\n### Dup\n");
366        assert_eq!(w.len(), 2, "first defines, each later collides: {w:?}");
367        assert_eq!(w[0].line, 3);
368        assert_eq!(w[1].line, 5);
369    }
370
371    #[test]
372    fn flags_colliding_custom_ids() {
373        let w = check("# Alpha {#dup}\n\n## Beta {#dup}\n");
374        assert_eq!(w.len(), 1, "got: {w:?}");
375        assert!(w[0].message.contains("'dup'"));
376    }
377
378    #[test]
379    fn custom_id_disambiguates_same_text() {
380        // Same visible text but explicit distinct ids => no collision.
381        let w = check("# Repeat {#first}\n\n## Repeat {#second}\n");
382        assert!(w.is_empty(), "explicit ids disambiguate: {w:?}");
383    }
384
385    #[test]
386    fn ignores_headings_in_code_fences() {
387        let w = check("# Title\n\n```\n# Title\n```\n");
388        assert!(w.is_empty(), "fenced `# Title` is not a heading: {w:?}");
389    }
390
391    #[test]
392    fn ignores_front_matter() {
393        let w = check("---\ntitle: Title\n---\n\n# Title\n\n## Title\n");
394        // Two real headings still collide; front matter must not add a third.
395        assert_eq!(w.len(), 1, "got: {w:?}");
396        assert_eq!(w[0].line, 7);
397    }
398
399    #[test]
400    fn levels_filter_restricts_scope() {
401        // H3 collision is ignored when only H1/H2 page ids are checked.
402        let cfg = MD080Config {
403            anchor_style: AnchorStyle::GitHub,
404            levels: vec![1, 2],
405        };
406        let w = check_with(cfg, "# Page\n\n### Dup\n\n### Dup\n");
407        assert!(w.is_empty(), "H3 collisions excluded by levels=[1,2]: {w:?}");
408    }
409
410    #[test]
411    fn anchor_style_changes_collision_outcome() {
412        // "a_b" vs "ab": GitHub preserves `_` (slugs `a_b` / `ab`, distinct),
413        // Kramdown strips `_` (both become `ab`, a collision).
414        let content = "# a_b\n\n## ab\n";
415        assert!(
416            check_with(
417                MD080Config {
418                    anchor_style: AnchorStyle::GitHub,
419                    levels: default_levels()
420                },
421                content
422            )
423            .is_empty(),
424            "GitHub keeps the underscore, slugs stay distinct"
425        );
426        assert_eq!(
427            check_with(
428                MD080Config {
429                    anchor_style: AnchorStyle::Kramdown,
430                    levels: default_levels()
431                },
432                content
433            )
434            .len(),
435            1,
436            "Kramdown removes `_`, so both headings slug to `ab`"
437        );
438    }
439
440    #[test]
441    fn flags_setext_heading_collision() {
442        // Setext headings produce fragment anchors too; a Setext H1 and an
443        // ATX H2 with the same slug collide just like two ATX headings.
444        let w = check("Intro\n=====\n\nbody\n\n## Intro\n");
445        assert_eq!(w.len(), 1, "setext + atx slug collision must flag: {w:?}");
446        assert_eq!(w[0].line, 6);
447    }
448
449    #[test]
450    fn custom_id_case_is_significant() {
451        // HTML id matching is case-sensitive: {#API} and {#api} are distinct
452        // anchors, so they must NOT be reported as a collision.
453        let w = check("# Alpha {#API}\n\n## Beta {#api}\n");
454        assert!(w.is_empty(), "custom ids differing only in case are distinct: {w:?}");
455    }
456
457    #[test]
458    fn flags_blockquote_heading_collision() {
459        // A blockquoted ATX heading still emits a fragment anchor (mirrors
460        // MD051), so it collides with a same-slug top-level heading.
461        let w = check("> ## Intro\n\n## Intro\n");
462        assert_eq!(w.len(), 1, "blockquote heading slug collision must flag: {w:?}");
463        assert_eq!(w[0].line, 3);
464    }
465
466    #[test]
467    fn flags_blockquote_setext_heading_collision() {
468        // A quoted setext heading emits an anchor just like a quoted ATX heading.
469        let w = check("> Intro\n> -----\n\n## Intro\n");
470        assert_eq!(w.len(), 1, "quoted setext heading slug collision must flag: {w:?}");
471        assert_eq!(w[0].line, 4);
472    }
473
474    #[test]
475    fn blockquote_in_html_block_is_not_a_heading() {
476        // A CommonMark HTML block is raw content. Its blockquote-like line does
477        // not render as Markdown and therefore emits no heading anchor.
478        let w = check("<div>\n> ## Intro\n</div>\n\n## Intro\n");
479        assert!(w.is_empty(), "raw HTML must not create a heading collision: {w:?}");
480    }
481
482    #[test]
483    fn no_auto_fix_offered() {
484        let w = check("# Dup\n\n## Dup\n");
485        assert!(w[0].fix.is_none());
486        let ctx = LintContext::new("# Dup\n\n## Dup\n", MarkdownFlavor::Standard, None);
487        assert!(MD080HeadingAnchorCollision::new().fix(&ctx).is_err());
488    }
489
490    #[test]
491    fn empty_document_is_clean() {
492        assert!(check("").is_empty());
493        assert!(check("Just prose, no headings.\n").is_empty());
494    }
495
496    #[test]
497    fn flags_multi_line_setext_heading_colliding_with_an_atx_heading() {
498        // The setext heading's anchor comes from the joined text of both its
499        // lines, so it collides with the ATX heading above. The warning covers
500        // the whole heading text: from its first line to the end of its last.
501        let w = check("# First line second line\n\nFirst line\nsecond line\n===\n");
502        assert_eq!(w.len(), 1, "the joined text collides: {w:?}");
503        assert_eq!(w[0].line, 3, "reported on the heading's first text line");
504        assert_eq!(w[0].column, 1);
505        assert_eq!(w[0].end_line, 4, "the range ends on the last text line");
506        assert_eq!(w[0].end_column, 12, "one past the end of `second line`");
507    }
508}