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) -> &'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 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 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_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 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); }
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 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 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}