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) -> &str {
12        "MD024"
13    }
14
15    fn description(&self) -> &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(|v| v.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_string();
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_string(),
74                                message: format!(
75                                    "Multiple sibling headings with the same content: \"{}\" (first at line {})",
76                                    text, first_line
77                                ),
78                                fix: None,
79                            });
80                        } else {
81                            sibling_headings.insert(
82                                (current_heading_level, text.clone()),
83                                current_heading_line,
84                            );
85                        }
86                    } else {
87                        // Check globally
88                        if let Some(&(first_line, _first_level)) = heading_texts.get(&text) {
89                            violations.push(Violation {
90                                line: current_heading_line,
91                                column: Some(1),
92                                rule: self.name().to_string(),
93                                message: format!(
94                                    "Multiple headings with the same content: \"{}\" (first at line {})",
95                                    text, first_line
96                                ),
97                                fix: None,
98                            });
99                        } else {
100                            heading_texts
101                                .insert(text, (current_heading_line, current_heading_level));
102                        }
103                    }
104
105                    last_heading_level = Some(current_heading_level);
106                    in_heading = false;
107                }
108                _ => {}
109            }
110        }
111
112        violations
113    }
114
115    fn fixable(&self) -> bool {
116        false
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_unique_headings() {
126        let content = "# Heading 1\n## Heading 2\n### Heading 3";
127        let parser = MarkdownParser::new(content);
128        let rule = MD024;
129        let violations = rule.check(&parser, None);
130
131        assert_eq!(violations.len(), 0);
132    }
133
134    #[test]
135    fn test_duplicate_headings() {
136        let content = "# Heading\n## Content\n# Heading";
137        let parser = MarkdownParser::new(content);
138        let rule = MD024;
139        let violations = rule.check(&parser, None);
140
141        assert_eq!(violations.len(), 1);
142        assert!(violations[0].message.contains("Heading"));
143    }
144
145    #[test]
146    fn test_siblings_only_different_levels() {
147        let content = "# Heading\n## Heading\n### Heading";
148        let parser = MarkdownParser::new(content);
149        let rule = MD024;
150        let config = serde_json::json!({ "siblings_only": true });
151        let violations = rule.check(&parser, Some(&config));
152
153        assert_eq!(violations.len(), 0); // Different levels, so OK with siblings_only
154    }
155
156    #[test]
157    fn test_siblings_only_same_level() {
158        let content = "## Heading\n## Content\n## Heading";
159        let parser = MarkdownParser::new(content);
160        let rule = MD024;
161        let config = serde_json::json!({ "siblings_only": true });
162        let violations = rule.check(&parser, Some(&config));
163
164        assert_eq!(violations.len(), 1);
165    }
166
167    #[test]
168    fn test_headings_with_inline_code() {
169        // Headings with different inline code should not be duplicates
170        let content = "#### `mdlint check`\n\nSome text\n\n#### `mdlint format`";
171        let parser = MarkdownParser::new(content);
172        let rule = MD024;
173        let violations = rule.check(&parser, None);
174
175        assert_eq!(
176            violations.len(),
177            0,
178            "Different code headings should not be duplicates"
179        );
180    }
181
182    #[test]
183    fn test_duplicate_code_headings() {
184        // Headings with same inline code should be duplicates
185        let content = "#### `mdlint check`\n\nSome text\n\n#### `mdlint check`";
186        let parser = MarkdownParser::new(content);
187        let rule = MD024;
188        let violations = rule.check(&parser, None);
189
190        assert_eq!(
191            violations.len(),
192            1,
193            "Same code headings should be duplicates"
194        );
195        assert!(violations[0].message.contains("`mdlint check`"));
196    }
197}