Skip to main content

mdlint/lint/rules/
md024.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use pulldown_cmark::{Event, HeadingLevel, Tag, TagEnd};
5use serde_json::Value;
6use std::collections::HashMap;
7
8pub struct MD024;
9
10impl Rule for MD024 {
11    fn name(&self) -> &'static str {
12        "MD024"
13    }
14
15    fn description(&self) -> &'static str {
16        "Multiple headings with the same content"
17    }
18
19    fn tags(&self) -> &[&str] {
20        &["headings"]
21    }
22
23    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
24        let siblings_only = config
25            .and_then(|c| c.get("siblings_only"))
26            .and_then(serde_json::Value::as_bool)
27            .unwrap_or(false);
28
29        let mut violations = Vec::new();
30        let mut heading_texts: HashMap<String, (usize, HeadingLevel)> = HashMap::new();
31        let mut sibling_headings: HashMap<(HeadingLevel, String), usize> = HashMap::new();
32        let mut last_heading_level: Option<HeadingLevel> = None;
33        let mut in_heading = false;
34        let mut current_heading_text = String::new();
35        let mut current_heading_line = 0;
36        let mut current_heading_level = HeadingLevel::H1;
37
38        for (event, range) in parser.parse_with_offsets() {
39            match event {
40                Event::Start(Tag::Heading { level, .. }) => {
41                    in_heading = true;
42                    current_heading_text.clear();
43                    current_heading_line = parser.offset_to_line(range.start);
44                    current_heading_level = level;
45                }
46                Event::Text(text) if in_heading => {
47                    current_heading_text.push_str(&text);
48                }
49                Event::Code(code) if in_heading => {
50                    // Include inline code in heading text
51                    current_heading_text.push('`');
52                    current_heading_text.push_str(&code);
53                    current_heading_text.push('`');
54                }
55                Event::End(TagEnd::Heading(_)) if in_heading => {
56                    let text = current_heading_text.trim().to_owned();
57
58                    if siblings_only {
59                        // Check if same level heading with same text exists
60                        if let Some(&prev_level) = last_heading_level.as_ref()
61                            && prev_level != current_heading_level
62                        {
63                            // Different level, clear sibling tracking
64                            sibling_headings.clear();
65                        }
66
67                        if let Some(&first_line) =
68                            sibling_headings.get(&(current_heading_level, text.clone()))
69                        {
70                            violations.push(Violation {
71                                line: current_heading_line,
72                                column: Some(1),
73                                rule: self.name().to_owned(),
74                                message: format!(
75                                    "Multiple sibling headings with the same content: \"{text}\" (first at line {first_line})"
76                                ),
77                                fix: None,
78                            });
79                        } else {
80                            sibling_headings.insert(
81                                (current_heading_level, text.clone()),
82                                current_heading_line,
83                            );
84                        }
85                    } else {
86                        // Check globally
87                        if let Some(&(first_line, _first_level)) = heading_texts.get(&text) {
88                            violations.push(Violation {
89                                line: current_heading_line,
90                                column: Some(1),
91                                rule: self.name().to_owned(),
92                                message: format!(
93                                    "Multiple headings with the same content: \"{text}\" (first at line {first_line})"
94                                ),
95                                fix: None,
96                            });
97                        } else {
98                            heading_texts
99                                .insert(text, (current_heading_line, current_heading_level));
100                        }
101                    }
102
103                    last_heading_level = Some(current_heading_level);
104                    in_heading = false;
105                }
106                _ => {}
107            }
108        }
109
110        violations
111    }
112
113    fn fixable(&self) -> bool {
114        false
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn test_unique_headings() {
124        let content = "# Heading 1\n## Heading 2\n### Heading 3";
125        let parser = MarkdownParser::new(content);
126        let rule = MD024;
127        let violations = rule.check(&parser, None);
128
129        assert_eq!(violations.len(), 0);
130    }
131
132    #[test]
133    fn test_duplicate_headings() {
134        let content = "# Heading\n## Content\n# Heading";
135        let parser = MarkdownParser::new(content);
136        let rule = MD024;
137        let violations = rule.check(&parser, None);
138
139        assert_eq!(violations.len(), 1);
140        assert!(violations[0].message.contains("Heading"));
141    }
142
143    #[test]
144    fn test_siblings_only_different_levels() {
145        let content = "# Heading\n## Heading\n### Heading";
146        let parser = MarkdownParser::new(content);
147        let rule = MD024;
148        let config = serde_json::json!({ "siblings_only": true });
149        let violations = rule.check(&parser, Some(&config));
150
151        assert_eq!(violations.len(), 0); // Different levels, so OK with siblings_only
152    }
153
154    #[test]
155    fn test_siblings_only_same_level() {
156        let content = "## Heading\n## Content\n## Heading";
157        let parser = MarkdownParser::new(content);
158        let rule = MD024;
159        let config = serde_json::json!({ "siblings_only": true });
160        let violations = rule.check(&parser, Some(&config));
161
162        assert_eq!(violations.len(), 1);
163    }
164
165    #[test]
166    fn test_headings_with_inline_code() {
167        // Headings with different inline code should not be duplicates
168        let content = "#### `mdlint check`\n\nSome text\n\n#### `mdlint format`";
169        let parser = MarkdownParser::new(content);
170        let rule = MD024;
171        let violations = rule.check(&parser, None);
172
173        assert_eq!(
174            violations.len(),
175            0,
176            "Different code headings should not be duplicates"
177        );
178    }
179
180    #[test]
181    fn test_duplicate_code_headings() {
182        // Headings with same inline code should be duplicates
183        let content = "#### `mdlint check`\n\nSome text\n\n#### `mdlint check`";
184        let parser = MarkdownParser::new(content);
185        let rule = MD024;
186        let violations = rule.check(&parser, None);
187
188        assert_eq!(
189            violations.len(),
190            1,
191            "Same code headings should be duplicates"
192        );
193        assert!(violations[0].message.contains("`mdlint check`"));
194    }
195}