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