1use serde::Deserialize;
2use std::rc::Rc;
3
4use tree_sitter::Node;
5
6use crate::{
7 linter::{range_from_tree_sitter, Context, RuleLinter, RuleViolation},
8 rules::{Rule, RuleType},
9};
10
11#[derive(Debug, PartialEq, Clone, Deserialize, Default)]
13pub struct MD024MultipleHeadingsTable {
14 #[serde(default)]
15 pub siblings_only: bool,
16 #[serde(default)]
17 pub allow_different_nesting: bool,
18}
19
20pub(crate) struct MD024Linter {
21 context: Rc<Context>,
22 violations: Vec<RuleViolation>,
23 headings: Vec<HeadingInfo>,
24}
25
26#[derive(Debug, Clone)]
27struct HeadingInfo {
28 content: String,
29 level: u8,
30 node_range: tree_sitter::Range,
31 parent_path: Vec<String>, }
33
34impl MD024Linter {
35 pub fn new(context: Rc<Context>) -> Self {
36 Self {
37 context,
38 violations: Vec::new(),
39 headings: Vec::new(),
40 }
41 }
42
43 fn extract_heading_content(&self, node: &Node) -> String {
44 let source = self.context.get_document_content();
46 let start_byte = node.start_byte();
47 let end_byte = node.end_byte();
48 let full_text = &source[start_byte..end_byte];
49
50 match node.kind() {
52 "atx_heading" => {
53 let text = full_text
55 .trim_start_matches('#')
56 .trim()
57 .trim_end_matches('#')
58 .trim();
59 text.split_whitespace().collect::<Vec<_>>().join(" ")
61 }
62 "setext_heading" => {
63 if let Some(line) = full_text.lines().next() {
65 let trimmed = line.trim();
66 trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
68 } else {
69 String::new()
70 }
71 }
72 _ => String::new(),
73 }
74 }
75
76 fn extract_heading_level(&self, node: &Node) -> u8 {
77 match node.kind() {
78 "atx_heading" => {
79 for i in 0..node.child_count() {
80 let child = node.child(i).unwrap();
81 if child.kind().starts_with("atx_h") && child.kind().ends_with("_marker") {
82 return child.kind().chars().nth(5).unwrap().to_digit(10).unwrap() as u8;
83 }
84 }
85 1 }
87 "setext_heading" => {
88 for i in 0..node.child_count() {
89 let child = node.child(i).unwrap();
90 if child.kind() == "setext_h1_underline" {
91 return 1;
92 } else if child.kind() == "setext_h2_underline" {
93 return 2;
94 }
95 }
96 1 }
98 _ => 1,
99 }
100 }
101
102 fn build_parent_path(&self, current_level: u8) -> Vec<String> {
103 let mut parent_path = Vec::new();
104
105 for heading in self.headings.iter().rev() {
107 if heading.level < current_level {
108 parent_path.insert(0, heading.content.clone());
109 if heading.level == 1 {
111 break; }
113 }
114 }
115
116 parent_path
117 }
118
119 fn check_for_duplicate(&mut self, current_heading: &HeadingInfo) {
120 let config = &self.context.config.linters.settings.multiple_headings;
121
122 for existing_heading in &self.headings {
123 if existing_heading.content == current_heading.content {
124 let is_violation = if config.siblings_only {
125 existing_heading.parent_path == current_heading.parent_path
127 } else if config.allow_different_nesting {
128 existing_heading.level == current_heading.level
130 } else {
131 true
133 };
134
135 if is_violation {
136 self.violations.push(RuleViolation::new(
137 &MD024,
138 format!(
139 "{} [Duplicate heading: '{}']",
140 MD024.description, current_heading.content
141 ),
142 self.context.file_path.clone(),
143 range_from_tree_sitter(¤t_heading.node_range),
144 ));
145 break; }
147 }
148 }
149 }
150}
151
152impl RuleLinter for MD024Linter {
153 fn feed(&mut self, node: &Node) {
154 if node.kind() == "atx_heading" || node.kind() == "setext_heading" {
155 let content = self.extract_heading_content(node);
156 let level = self.extract_heading_level(node);
157 let parent_path = self.build_parent_path(level);
158
159 let heading_info = HeadingInfo {
160 content: content.clone(),
161 level,
162 node_range: node.range(),
163 parent_path,
164 };
165
166 self.check_for_duplicate(&heading_info);
167 self.headings.push(heading_info);
168 }
169 }
170
171 fn finalize(&mut self) -> Vec<RuleViolation> {
172 std::mem::take(&mut self.violations)
173 }
174}
175
176pub const MD024: Rule = Rule {
177 id: "MD024",
178 alias: "no-duplicate-heading",
179 tags: &["headings"],
180 description: "Multiple headings with the same content",
181 rule_type: RuleType::Document,
182 required_nodes: &["atx_heading", "setext_heading"],
183 new_linter: |context| Box::new(MD024Linter::new(context)),
184};
185
186#[cfg(test)]
187mod test {
188 use std::path::PathBuf;
189
190 use crate::config::{LintersSettingsTable, MD024MultipleHeadingsTable, RuleSeverity};
191 use crate::linter::MultiRuleLinter;
192 use crate::test_utils::test_helpers::test_config_with_settings;
193
194 fn test_config(
195 siblings_only: bool,
196 allow_different_nesting: bool,
197 ) -> crate::config::QuickmarkConfig {
198 test_config_with_settings(
199 vec![("no-duplicate-heading", RuleSeverity::Error)],
200 LintersSettingsTable {
201 multiple_headings: MD024MultipleHeadingsTable {
202 siblings_only,
203 allow_different_nesting,
204 },
205 ..Default::default()
206 },
207 )
208 }
209
210 #[test]
211 fn test_basic_duplicate_headings() {
212 let config = test_config(false, false);
213 let input = "# Introduction
214
215Some text
216
217## Section 1
218
219Content
220
221## Section 1
222
223More content";
224
225 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
226 let violations = linter.analyze();
227 assert_eq!(violations.len(), 1);
228 assert!(violations[0].message().contains("Section 1"));
229 }
230
231 #[test]
232 fn test_no_duplicates() {
233 let config = test_config(false, false);
234 let input = "# Introduction
235
236## Section 1
237
238### Subsection A
239
240## Section 2
241
242### Subsection B";
243
244 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
245 let violations = linter.analyze();
246 assert_eq!(violations.len(), 0);
247 }
248
249 #[test]
250 fn test_siblings_only_different_parents() {
251 let config = test_config(true, false);
252 let input = "# Chapter 1
253
254## Introduction
255
256# Chapter 2
257
258## Introduction";
259
260 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
261 let violations = linter.analyze();
262 assert_eq!(violations.len(), 0); }
264
265 #[test]
266 fn test_siblings_only_same_parent() {
267 let config = test_config(true, false);
268 let input = "# Chapter 1
269
270## Introduction
271
272## Introduction";
273
274 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
275 let violations = linter.analyze();
276 assert_eq!(violations.len(), 1); }
278
279 #[test]
280 fn test_allow_different_nesting_levels() {
281 let config = test_config(false, true);
282 let input = "# Introduction
283
284## Introduction";
285
286 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
287 let violations = linter.analyze();
288 assert_eq!(violations.len(), 0); }
290
291 #[test]
292 fn test_allow_different_nesting_same_level() {
293 let config = test_config(false, true);
294 let input = "# Introduction
295
296# Introduction";
297
298 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
299 let violations = linter.analyze();
300 assert_eq!(violations.len(), 1); }
302
303 #[test]
304 fn test_setext_headings() {
305 let config = test_config(false, false);
306 let input = "Introduction
307============
308
309Section 1
310---------
311
312Section 1
313---------
314";
315
316 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
317 let violations = linter.analyze();
318 assert_eq!(violations.len(), 1);
319 assert!(violations[0].message().contains("Section 1"));
320 }
321
322 #[test]
323 fn test_mixed_heading_styles() {
324 let config = test_config(false, false);
325 let input = "Introduction
326============
327
328## Section 1
329
330Section 1
331---------
332";
333
334 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
335 let violations = linter.analyze();
336 assert_eq!(violations.len(), 1);
337 assert!(violations[0].message().contains("Section 1"));
338 }
339
340 #[test]
341 fn test_complex_hierarchy() {
342 let config = test_config(true, false);
343 let input = "# Part 1
344
345## Chapter 1
346
347### Introduction
348
349## Chapter 2
350
351### Introduction
352
353# Part 2
354
355## Chapter 1
356
357### Introduction";
358
359 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
360 let violations = linter.analyze();
361 assert_eq!(violations.len(), 0);
364 }
365
366 #[test]
367 fn test_whitespace_normalization() {
368 let config = test_config(false, false);
369 let input = "# Section 1
370
371## Section 1 ";
372
373 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
374 let violations = linter.analyze();
375 assert_eq!(violations.len(), 1); }
377
378 #[test]
379 fn test_empty_headings() {
380 let config = test_config(false, false);
381 let input = "#
382
383##
384
385##";
386
387 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
388 let violations = linter.analyze();
389 assert_eq!(violations.len(), 1); }
391
392 #[test]
393 fn test_atx_closed_headings() {
394 let config = test_config(false, false);
395 let input = "# Introduction #
396
397## Section 1 ##
398
399## Section 1 ##";
400
401 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
402 let violations = linter.analyze();
403 assert_eq!(violations.len(), 1);
404 assert!(violations[0].message().contains("Section 1"));
405 }
406
407 #[test]
408 fn test_both_options_enabled() {
409 let config = test_config(true, true);
410 let input = "# Chapter 1
411
412## Introduction
413
414# Chapter 2
415
416## Introduction
417
418### Introduction";
419
420 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
421 let violations = linter.analyze();
422 assert_eq!(violations.len(), 0);
425 }
426}