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#[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 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, HeadingStyle::SetextWithATXClosed => None, _ => 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 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 if let Ok(heading_text) = node.utf8_text(self.context.get_document_content().as_bytes()) {
120 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 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 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 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 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 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 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 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 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 assert_eq!(violations.len(), 4);
451
452 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 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 assert_eq!(violations.len(), 4);
504
505 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 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 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 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 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 assert_eq!(violations.len(), 2); 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 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 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 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 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 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}