quickmark_core/rules/
md003.rs

1use core::fmt;
2use serde::Deserialize;
3use std::rc::Rc;
4use tree_sitter::Node;
5
6use crate::linter::{range_from_tree_sitter, Context, RuleLinter, RuleViolation};
7
8use super::{Rule, RuleType};
9
10// MD003-specific configuration types
11#[derive(Debug, PartialEq, Clone, Deserialize)]
12pub enum HeadingStyle {
13    #[serde(rename = "consistent")]
14    Consistent,
15    #[serde(rename = "atx")]
16    ATX,
17    #[serde(rename = "setext")]
18    Setext,
19    #[serde(rename = "atx_closed")]
20    ATXClosed,
21    #[serde(rename = "setext_with_atx")]
22    SetextWithATX,
23    #[serde(rename = "setext_with_atx_closed")]
24    SetextWithATXClosed,
25}
26
27impl Default for HeadingStyle {
28    fn default() -> Self {
29        Self::Consistent
30    }
31}
32
33#[derive(Debug, PartialEq, Clone, Deserialize)]
34pub struct MD003HeadingStyleTable {
35    #[serde(default)]
36    pub style: HeadingStyle,
37}
38
39impl Default for MD003HeadingStyleTable {
40    fn default() -> Self {
41        Self {
42            style: HeadingStyle::Consistent,
43        }
44    }
45}
46
47#[derive(PartialEq, Debug)]
48enum Style {
49    Setext,
50    Atx,
51    AtxClosed,
52}
53
54impl fmt::Display for Style {
55    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
56        match self {
57            Style::Setext => write!(f, "setext"),
58            Style::Atx => write!(f, "atx"),
59            Style::AtxClosed => write!(f, "atx_closed"),
60        }
61    }
62}
63
64pub(crate) struct MD003Linter {
65    context: Rc<Context>,
66    enforced_style: Option<Style>,
67    violations: Vec<RuleViolation>,
68}
69
70impl MD003Linter {
71    pub fn new(context: Rc<Context>) -> Self {
72        // Access MD003 config through the centralized config structure
73        let md003_config = &context.config.linters.settings.heading_style;
74        let enforced_style = match md003_config.style {
75            HeadingStyle::ATX => Some(Style::Atx),
76            HeadingStyle::Setext => Some(Style::Setext),
77            HeadingStyle::ATXClosed => Some(Style::AtxClosed),
78            HeadingStyle::SetextWithATX => None, // Allow both setext and atx
79            HeadingStyle::SetextWithATXClosed => None, // Allow setext and atx_closed
80            _ => None,
81        };
82        Self {
83            context,
84            enforced_style,
85            violations: Vec::new(),
86        }
87    }
88
89    fn get_heading_level(&self, node: &Node) -> u8 {
90        let mut cursor = node.walk();
91        match node.kind() {
92            "atx_heading" => node
93                .children(&mut cursor)
94                .find_map(|child| {
95                    let kind = child.kind();
96                    if kind.starts_with("atx_h") && kind.ends_with("_marker") {
97                        // "atx_h3_marker" -> 3
98                        kind.get(5..6)?.parse::<u8>().ok()
99                    } else {
100                        None
101                    }
102                })
103                .unwrap_or(1),
104            "setext_heading" => node
105                .children(&mut cursor)
106                .find_map(|child| match child.kind() {
107                    "setext_h1_underline" => Some(1),
108                    "setext_h2_underline" => Some(2),
109                    _ => None,
110                })
111                .unwrap_or(1),
112            _ => 1,
113        }
114    }
115
116    fn is_atx_closed(&self, node: &Node) -> bool {
117        // Use the idiomatic tree-sitter way to get the node's text.
118        // This is more efficient than slicing the whole document manually.
119        if let Ok(heading_text) = node.utf8_text(self.context.get_document_content().as_bytes()) {
120            // Trim trailing whitespace and check if the heading ends with '#'.
121            heading_text.trim_end().ends_with('#')
122        } else {
123            false
124        }
125    }
126
127    fn add_violation(&mut self, node: &Node, expected: &str, actual: &Style) {
128        self.violations.push(RuleViolation::new(
129            &MD003,
130            format!(
131                "{} [Expected: {}; Actual: {}]",
132                MD003.description, expected, actual
133            ),
134            self.context.file_path.clone(),
135            range_from_tree_sitter(&node.range()),
136        ));
137    }
138}
139
140impl RuleLinter for MD003Linter {
141    fn feed(&mut self, node: &Node) {
142        let style = match node.kind() {
143            "atx_heading" => {
144                // Check if it's closed (has closing hashes)
145                if self.is_atx_closed(node) {
146                    Some(Style::AtxClosed)
147                } else {
148                    Some(Style::Atx)
149                }
150            }
151            "setext_heading" => Some(Style::Setext),
152            _ => None,
153        };
154
155        if let Some(style) = style {
156            let level = self.get_heading_level(node);
157            let config_style = &self.context.config.linters.settings.heading_style.style;
158
159            match config_style {
160                HeadingStyle::SetextWithATX => {
161                    // Levels 1-2: must be setext, Levels 3+: must be atx (open), not atx_closed
162                    if level <= 2 {
163                        if style != Style::Setext {
164                            self.add_violation(node, "setext", &style);
165                        }
166                    } else if style != Style::Atx {
167                        self.add_violation(node, "atx", &style);
168                    }
169                }
170                HeadingStyle::SetextWithATXClosed => {
171                    // Levels 1-2: must be setext, Levels 3+: must be atx_closed, not plain atx
172                    if level <= 2 {
173                        if style != Style::Setext {
174                            self.add_violation(node, "setext", &style);
175                        }
176                    } else if style != Style::AtxClosed {
177                        self.add_violation(node, "atx_closed", &style);
178                    }
179                }
180                _ => {
181                    // For single-style configurations, check against enforced style
182                    if let Some(enforced_style) = &self.enforced_style {
183                        if style != *enforced_style {
184                            self.add_violation(node, &enforced_style.to_string(), &style);
185                        }
186                    } else {
187                        self.enforced_style = Some(style);
188                    }
189                }
190            }
191        }
192    }
193
194    fn finalize(&mut self) -> Vec<RuleViolation> {
195        std::mem::take(&mut self.violations)
196    }
197}
198
199pub const MD003: Rule = Rule {
200    id: "MD003",
201    alias: "heading-style",
202    tags: &["headings"],
203    description: "Heading style",
204    rule_type: RuleType::Token,
205    required_nodes: &["atx_heading", "setext_heading"],
206    new_linter: |context| Box::new(MD003Linter::new(context)),
207};
208
209#[cfg(test)]
210mod test {
211    use std::path::PathBuf;
212
213    use super::{HeadingStyle, MD003HeadingStyleTable};
214    use crate::config::{LintersSettingsTable, RuleSeverity};
215    use crate::linter::MultiRuleLinter;
216    use crate::test_utils::test_helpers::test_config_with_settings;
217
218    fn test_config(style: HeadingStyle) -> crate::config::QuickmarkConfig {
219        test_config_with_settings(
220            vec![
221                ("heading-style", RuleSeverity::Error),
222                ("heading-increment", RuleSeverity::Off),
223            ],
224            LintersSettingsTable {
225                heading_style: MD003HeadingStyleTable { style },
226                ..Default::default()
227            },
228        )
229    }
230
231    #[test]
232    fn test_heading_style_consistent_positive() {
233        let config = test_config(HeadingStyle::Consistent);
234
235        let input = "
236Setext level 1
237--------------
238Setext level 2
239==============
240### ATX header level 3
241#### ATX header level 4
242";
243        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
244        let violations = linter.analyze();
245        assert_eq!(violations.len(), 2);
246    }
247
248    #[test]
249    fn test_heading_style_consistent_negative_setext() {
250        let config = test_config(HeadingStyle::Consistent);
251
252        let input = "
253Setext level 1
254--------------
255Setext level 2
256==============
257";
258        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
259        let violations = linter.analyze();
260        assert_eq!(violations.len(), 0);
261    }
262
263    #[test]
264    fn test_heading_style_consistent_negative_atx() {
265        let config = test_config(HeadingStyle::Consistent);
266
267        let input = "
268# Atx heading 1
269## Atx heading 2
270### Atx heading 3
271";
272        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
273        let violations = linter.analyze();
274        assert_eq!(violations.len(), 0);
275    }
276
277    #[test]
278    fn test_heading_style_atx_positive() {
279        let config = test_config(HeadingStyle::ATX);
280
281        let input = "
282Setext heading 1
283----------------
284Setext heading 2
285================
286### Atx heading 3
287";
288        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
289        let violations = linter.analyze();
290        assert_eq!(violations.len(), 2);
291    }
292
293    #[test]
294    fn test_heading_style_atx_negative() {
295        let config = test_config(HeadingStyle::ATX);
296
297        let input = "
298# Atx heading 1
299## Atx heading 2
300### Atx heading 3
301";
302        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
303        let violations = linter.analyze();
304        assert_eq!(violations.len(), 0);
305    }
306
307    #[test]
308    fn test_heading_style_setext_positive() {
309        let config = test_config(HeadingStyle::Setext);
310
311        let input = "
312# Atx heading 1
313Setext heading 1
314----------------
315Setext heading 2
316================
317### Atx heading 3
318";
319        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
320        let violations = linter.analyze();
321        assert_eq!(violations.len(), 2);
322    }
323
324    #[test]
325    fn test_heading_style_setext_negative() {
326        let config = test_config(HeadingStyle::Setext);
327
328        let input = "
329Setext heading 1
330----------------
331Setext heading 2
332================
333Setext heading 2
334================
335";
336        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
337        let violations = linter.analyze();
338        assert_eq!(violations.len(), 0);
339    }
340
341    #[test]
342    fn test_heading_style_atx_closed_positive() {
343        let config = test_config(HeadingStyle::ATXClosed);
344
345        let input = "
346# Open ATX heading 1
347## Open ATX heading 2 ##
348### ATX closed heading 3 ###
349";
350        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
351        let violations = linter.analyze();
352        assert_eq!(violations.len(), 1);
353    }
354
355    #[test]
356    fn test_heading_style_atx_closed_negative() {
357        let config = test_config(HeadingStyle::ATXClosed);
358
359        let input = "
360# ATX closed heading 1 #
361## ATX closed heading 2 ##
362### ATX closed heading 3 ###
363";
364        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
365        let violations = linter.analyze();
366        assert_eq!(violations.len(), 0);
367    }
368
369    #[test]
370    fn test_heading_style_setext_with_atx_positive() {
371        let config = test_config(HeadingStyle::SetextWithATX);
372
373        let input = "
374Setext heading 1
375----------------
376# Open ATX heading 2
377## ATX closed heading 3 ##
378";
379        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
380        let violations = linter.analyze();
381        // Level-based: setext h2 should be used for level 2, open ATX for level 3
382        // Violations: ATX heading at level 2, closed ATX at level 3
383        assert_eq!(violations.len(), 2);
384    }
385
386    #[test]
387    fn test_heading_style_setext_with_atx_negative() {
388        let config = test_config(HeadingStyle::SetextWithATX);
389
390        let input = "
391Setext heading 1
392----------------
393Setext heading 2
394----------------
395### Open ATX heading 3
396";
397        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
398        let violations = linter.analyze();
399        // Level-based: setext for 1-2, open ATX for 3+ - all correct
400        assert_eq!(violations.len(), 0);
401    }
402
403    #[test]
404    fn test_heading_style_setext_with_atx_closed_positive() {
405        let config = test_config(HeadingStyle::SetextWithATXClosed);
406
407        let input = "
408Setext heading 1
409----------------
410# Open ATX heading 2
411### Open ATX heading 3
412";
413        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
414        let violations = linter.analyze();
415        // Level-based: setext for 1-2, closed ATX for 3+
416        // Violations: open ATX at level 2, open ATX at level 3 (should be closed)
417        assert_eq!(violations.len(), 2);
418    }
419
420    #[test]
421    fn test_heading_style_setext_with_atx_closed_negative() {
422        let config = test_config(HeadingStyle::SetextWithATXClosed);
423
424        let input = "
425Setext heading 1
426----------------
427Setext heading 2
428----------------
429### ATX closed heading 3 ###
430";
431        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
432        let violations = linter.analyze();
433        // Level-based: setext for 1-2, closed ATX for 3+ - all correct
434        assert_eq!(violations.len(), 0);
435    }
436
437    #[test]
438    fn test_setext_with_atx_level_violations_comprehensive() {
439        let config = test_config(HeadingStyle::SetextWithATX);
440
441        let input = "
442# Level 1 ATX (should be setext)
443## Level 2 ATX (should be setext)
444### Level 3 ATX closed (should be open ATX) ###
445#### Level 4 ATX closed (should be open ATX) ####
446";
447        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
448        let violations = linter.analyze();
449        // Expect 4 violations: 2 for wrong style at levels 1-2, 2 for closed ATX at levels 3-4
450        assert_eq!(violations.len(), 4);
451
452        // Check specific violation messages
453        assert!(violations[0]
454            .message()
455            .contains("Expected: setext; Actual: atx"));
456        assert!(violations[1]
457            .message()
458            .contains("Expected: setext; Actual: atx"));
459        assert!(violations[2]
460            .message()
461            .contains("Expected: atx; Actual: atx_closed"));
462        assert!(violations[3]
463            .message()
464            .contains("Expected: atx; Actual: atx_closed"));
465    }
466
467    #[test]
468    fn test_setext_with_atx_correct_level_usage() {
469        let config = test_config(HeadingStyle::SetextWithATX);
470
471        let input = "
472Main Title
473==========
474
475Subtitle
476--------
477
478### Level 3 Open ATX
479#### Level 4 Open ATX
480##### Level 5 Open ATX
481###### Level 6 Open ATX
482";
483        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
484        let violations = linter.analyze();
485        // Should have no violations - correct level-based usage
486        assert_eq!(violations.len(), 0);
487    }
488
489    #[test]
490    fn test_setext_with_atx_closed_level_violations_comprehensive() {
491        let config = test_config(HeadingStyle::SetextWithATXClosed);
492
493        let input = "
494# Level 1 ATX (should be setext)
495## Level 2 ATX (should be setext)
496### Level 3 open ATX (should be closed ATX)
497#### Level 4 open ATX (should be closed ATX)
498##### Level 5 closed ATX is correct #####
499";
500        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
501        let violations = linter.analyze();
502        // Expect 4 violations: 2 for wrong style at levels 1-2, 2 for open ATX at levels 3-4
503        assert_eq!(violations.len(), 4);
504
505        // Check specific violation messages
506        assert!(violations[0]
507            .message()
508            .contains("Expected: setext; Actual: atx"));
509        assert!(violations[1]
510            .message()
511            .contains("Expected: setext; Actual: atx"));
512        assert!(violations[2]
513            .message()
514            .contains("Expected: atx_closed; Actual: atx"));
515        assert!(violations[3]
516            .message()
517            .contains("Expected: atx_closed; Actual: atx"));
518    }
519
520    #[test]
521    fn test_setext_with_atx_closed_correct_level_usage() {
522        let config = test_config(HeadingStyle::SetextWithATXClosed);
523
524        let input = "
525Main Title
526==========
527
528Subtitle
529--------
530
531### Level 3 Closed ATX ###
532#### Level 4 Closed ATX ####
533##### Level 5 Closed ATX #####
534###### Level 6 Closed ATX ######
535";
536        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
537        let violations = linter.analyze();
538        // Should have no violations - correct level-based usage
539        assert_eq!(violations.len(), 0);
540    }
541
542    #[test]
543    fn test_mixed_atx_styles_comprehensive() {
544        let config = test_config(HeadingStyle::ATXClosed);
545
546        let input = "
547# Open ATX 1
548## Closed ATX 2 ##
549### Open ATX 3
550#### Closed ATX 4 ####
551##### Open ATX 5
552###### Closed ATX 6 ######
553";
554        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
555        let violations = linter.analyze();
556        // Expect 3 violations for open ATX headings (levels 1, 3, 5)
557        assert_eq!(violations.len(), 3);
558
559        for violation in &violations {
560            assert!(violation
561                .message()
562                .contains("Expected: atx_closed; Actual: atx"));
563        }
564    }
565
566    #[test]
567    fn test_consistent_style_with_mixed_atx_variations() {
568        let config = test_config(HeadingStyle::Consistent);
569
570        let input = "
571# First heading (sets the standard)
572## Open ATX 2
573### Closed ATX 3 ###
574#### Open ATX 4
575Setext heading
576==============
577";
578        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
579        let violations = linter.analyze();
580        // Expect 2 violations: closed ATX and setext (both different from first open ATX)
581        assert_eq!(violations.len(), 2);
582
583        assert!(violations[0]
584            .message()
585            .contains("Expected: atx; Actual: atx_closed"));
586        assert!(violations[1]
587            .message()
588            .contains("Expected: atx; Actual: setext"));
589    }
590
591    #[test]
592    fn test_file_without_trailing_newline_edge_case() {
593        let config = test_config(HeadingStyle::Setext);
594
595        // Test string without trailing newline (like our original issue)
596        let input = "# ATX heading 1
597## ATX heading 2
598Final setext heading
599--------------------";
600
601        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
602        let violations = linter.analyze();
603        // Should catch all 3 violations, including the final setext heading
604        assert_eq!(violations.len(), 2); // Only ATX headings violate setext rule
605
606        for violation in &violations {
607            assert!(violation
608                .message()
609                .contains("Expected: setext; Actual: atx"));
610        }
611    }
612
613    #[test]
614    fn test_mix_of_styles() {
615        let config = test_config(HeadingStyle::SetextWithATX);
616
617        let input = "# Open ATX heading level 1
618
619## Open ATX heading level 2
620
621### Open ATX heading level 3 ###
622
623#### Closed ATX heading level 4 ####
624
625Setext heading level 1
626======================
627
628Setext heading level 2
629----------------------
630
631Another setext heading
632======================
633
634# Another open ATX
635
636## Another closed ATX ##
637
638Final setext heading
639--------------------
640";
641        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
642        let violations = linter.analyze();
643        // - Level 1 ATX should be setext (1 violation)
644        // - Level 2 ATX should be setext (2 violations)
645        // - Level 3+ closed ATX should be open ATX (2 violations)
646        // - Level 2 closed ATX should be setext (1 violation)
647        // Total: 6 violations
648        assert_eq!(violations.len(), 6);
649    }
650
651    #[test]
652    fn test_atx_closed_detection_comprehensive() {
653        let config = test_config(HeadingStyle::ATXClosed);
654
655        let input = "# Open ATX
656# Open ATX with spaces
657## Open ATX level 2
658### Closed ATX level 3 ###
659#### Closed ATX with spaces ####
660##### Closed ATX no spaces #####
661###### Mixed closing hashes ##########
662";
663        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
664        let violations = linter.analyze();
665
666        // Should detect 3 open ATX violations (lines 1, 2, 3)
667        assert_eq!(violations.len(), 3);
668
669        for violation in &violations {
670            assert!(violation
671                .message()
672                .contains("Expected: atx_closed; Actual: atx"));
673        }
674    }
675
676    #[test]
677    fn test_atx_closed_detection_edge_cases() {
678        let config = test_config(HeadingStyle::ATX);
679
680        let input = "# Regular ATX
681## Closed ATX ##
682### Unbalanced closing ########
683#### Text with hash # in middle
684##### Text ending with hash#
685###### Actually closed ######
686";
687        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
688        let violations = linter.analyze();
689
690        // Lines ending with # are considered closed: 2, 3, 5, 6
691        // So we expect 4 violations for closed ATX when expecting open ATX
692        assert_eq!(violations.len(), 4);
693
694        for violation in &violations {
695            assert!(violation
696                .message()
697                .contains("Expected: atx; Actual: atx_closed"));
698        }
699    }
700
701    #[test]
702    fn test_whitespace_handling_in_atx_closed_detection() {
703        let config = test_config(HeadingStyle::ATXClosed);
704
705        let input = "# Open ATX
706## Closed with trailing spaces ##
707### Closed with tabs ##
708#### Open with trailing spaces
709##### Closed no spaces #####
710";
711        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
712        let violations = linter.analyze();
713
714        // Should detect 2 open ATX violations (lines 1 and 4)
715        assert_eq!(violations.len(), 2);
716
717        for violation in &violations {
718            assert!(violation
719                .message()
720                .contains("Expected: atx_closed; Actual: atx"));
721        }
722    }
723
724    #[test]
725    fn test_setext_only_supports_levels_1_and_2() {
726        let config = test_config(HeadingStyle::Setext);
727
728        let input = "Setext Level 1
729==============
730
731Setext Level 2
732--------------
733
734### Level 3 must be ATX ###
735#### Level 4 must be ATX ####
736";
737        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
738        let violations = linter.analyze();
739
740        // Should detect 2 violations for ATX headings at levels 3-4
741        assert_eq!(violations.len(), 2);
742
743        for violation in &violations {
744            assert!(violation
745                .message()
746                .contains("Expected: setext; Actual: atx_closed"));
747        }
748    }
749}