mdlint/lint/rules/
md024.rs1use 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 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 if let Some(&prev_level) = last_heading_level.as_ref()
61 && prev_level != current_heading_level
62 {
63 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 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); }
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 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 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}