Skip to main content

mdwright_lint/stdlib/
duplicate_heading.rs

1//! Two headings at the same level under the same parent with
2//! identical trimmed text.
3//!
4//! A table of contents collapses duplicate entries silently; anchor
5//! links resolve to the first occurrence only. Both outcomes are
6//! usually wrong. Parent is taken to be the most recent
7//! strictly-shallower heading; under no parent (top level), siblings
8//! are all top-level headings of the same level.
9
10use std::collections::HashMap;
11
12use crate::diagnostic::Diagnostic;
13use crate::rule::LintRule;
14use mdwright_document::Document;
15
16pub struct DuplicateHeading;
17
18impl LintRule for DuplicateHeading {
19    fn name(&self) -> &str {
20        "duplicate-heading"
21    }
22
23    fn description(&self) -> &str {
24        "Two headings at the same level under the same parent with the same text."
25    }
26
27    fn explain(&self) -> &str {
28        include_str!("explain/duplicate_heading.md")
29    }
30
31    fn is_advisory(&self) -> bool {
32        // Math / theorem documents legitimately repeat `### Proof`,
33        // `### Corollary`, etc. under one chapter heading. Useful
34        // signal for prose; noisy for math. Advisory by default so
35        // it still surfaces but doesn't fail `--check`.
36        true
37    }
38
39    fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
40        // `path[i]` is the currently-open heading at level (i+1), or
41        // None if no heading at that level is currently open. When a
42        // heading at level L is seen, levels >= L are closed.
43        let mut path: [Option<String>; 6] = Default::default();
44        let mut seen: HashMap<(usize, String, String), usize> = HashMap::new();
45        for h in doc.headings() {
46            let level = h.level as usize;
47            // Parent = closest non-None slot strictly above this level.
48            let parent_key = (0..level.saturating_sub(1))
49                .rev()
50                .find_map(|i| path.get(i).and_then(Option::as_ref).cloned())
51                .unwrap_or_default();
52            let key = (level, parent_key, h.text.to_ascii_lowercase());
53            if let Some(&first_offset) = seen.get(&key) {
54                let message = format!(
55                    "duplicate heading `{}` at level {level}; first defined at byte {first_offset}",
56                    h.text
57                );
58                let local = 0..(h.raw_range.end.saturating_sub(h.raw_range.start));
59                if let Some(d) = Diagnostic::at(doc, h.raw_range.start, local, message, None) {
60                    out.push(d);
61                }
62            } else {
63                seen.insert(key, h.raw_range.start);
64            }
65            // Open this heading at its level; close deeper levels.
66            if let Some(slot) = path.get_mut(level.saturating_sub(1)) {
67                *slot = Some(h.text.clone());
68            }
69            for i in level..path.len() {
70                if let Some(slot) = path.get_mut(i) {
71                    *slot = None;
72                }
73            }
74        }
75    }
76}