quickmark_core/rules/
md024.rs

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// MD024-specific configuration types
12#[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>, // Path from root to parent heading
32}
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        // Extract text content from heading node
45        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        // Remove markdown syntax and trim
51        match node.kind() {
52            "atx_heading" => {
53                // Remove leading #s and trailing #s if present
54                let text = full_text
55                    .trim_start_matches('#')
56                    .trim()
57                    .trim_end_matches('#')
58                    .trim();
59                // Normalize whitespace: replace multiple spaces with single space
60                text.split_whitespace().collect::<Vec<_>>().join(" ")
61            }
62            "setext_heading" => {
63                // For setext, take first line (before underline)
64                if let Some(line) = full_text.lines().next() {
65                    let trimmed = line.trim();
66                    // Normalize whitespace: replace multiple spaces with single space
67                    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 // fallback
86            }
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 // fallback
97            }
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        // Find all headings at levels less than current_level, in reverse order
106        for heading in self.headings.iter().rev() {
107            if heading.level < current_level {
108                parent_path.insert(0, heading.content.clone());
109                // Continue looking for higher-level parents
110                if heading.level == 1 {
111                    break; // Reached the top level
112                }
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                    // Only report duplicates within same parent
126                    existing_heading.parent_path == current_heading.parent_path
127                } else if config.allow_different_nesting {
128                    // Allow duplicates at different levels
129                    existing_heading.level == current_heading.level
130                } else {
131                    // Standard behavior: any duplicate is a violation
132                    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(&current_heading.node_range),
144                    ));
145                    break; // Only report once per duplicate
146                }
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); // Should allow duplicate under different parents
263    }
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); // Should detect duplicate under same parent
277    }
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); // Should allow duplicate at different levels
289    }
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); // Should detect duplicate at same level
301    }
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        // With siblings_only: "Introduction" under different chapters should be allowed
362        // "Chapter 1" under different parts should be allowed
363        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); // Should normalize whitespace and detect duplicate
376    }
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); // Empty headings should be treated as duplicates
390    }
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        // siblings_only=true allows "Introduction" under different parents
423        // allow_different_nesting=true allows same name at different levels within same parent
424        assert_eq!(violations.len(), 0);
425    }
426}