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