quickmark_core/rules/
md019.rs

1use std::rc::Rc;
2use tree_sitter::Node;
3
4use crate::linter::{Context, RuleLinter, RuleViolation};
5
6use super::{Rule, RuleType};
7
8pub(crate) struct MD019Linter {
9    context: Rc<Context>,
10    violations: Vec<RuleViolation>,
11}
12
13impl MD019Linter {
14    pub fn new(context: Rc<Context>) -> Self {
15        Self {
16            context,
17            violations: Vec::new(),
18        }
19    }
20
21    fn check_heading_spaces(&mut self, node: &Node) {
22        let source = self.context.get_document_content();
23
24        // Different approach: analyze the raw text between marker and content
25        if let (Some(marker_child), Some(content_child)) = (node.child(0), node.child(1)) {
26            if marker_child.kind().starts_with("atx_h") && marker_child.kind().ends_with("_marker")
27            {
28                let marker_end = marker_child.end_byte();
29                let content_start = content_child.start_byte();
30
31                // Extract the whitespace between marker and content
32                if content_start > marker_end {
33                    let whitespace_text = &source[marker_end..content_start];
34
35                    // Check if more than one whitespace character
36                    if whitespace_text.len() > 1 {
37                        // Create a range for the excess whitespace (after the first character)
38                        let line_num = node.start_position().row;
39                        let start_col = node.start_position().column
40                            + marker_child
41                                .utf8_text(source.as_bytes())
42                                .unwrap_or("")
43                                .len()
44                            + 1;
45
46                        self.violations.push(RuleViolation::new(
47                            &MD019,
48                            format!(
49                                "Multiple spaces after hash on atx style heading [Expected: 1; Actual: {}]",
50                                whitespace_text.len()
51                            ),
52                            self.context.file_path.clone(),
53                            crate::linter::Range {
54                                start: crate::linter::CharPosition { line: line_num, character: start_col },
55                                end: crate::linter::CharPosition { line: line_num, character: content_child.start_position().column },
56                            },
57                        ));
58                    }
59                }
60            }
61        }
62    }
63}
64
65impl RuleLinter for MD019Linter {
66    fn feed(&mut self, node: &Node) {
67        if node.kind() == "atx_heading" {
68            self.check_heading_spaces(node);
69        }
70    }
71
72    fn finalize(&mut self) -> Vec<RuleViolation> {
73        std::mem::take(&mut self.violations)
74    }
75}
76
77pub const MD019: Rule = Rule {
78    id: "MD019",
79    alias: "no-multiple-space-atx",
80    tags: &["headings", "atx", "spaces"],
81    description: "Multiple spaces after hash on atx style heading",
82    rule_type: RuleType::Token,
83    required_nodes: &["atx_heading"],
84    new_linter: |context| Box::new(MD019Linter::new(context)),
85};
86
87#[cfg(test)]
88mod test {
89    use std::path::PathBuf;
90
91    use crate::config::RuleSeverity;
92    use crate::linter::MultiRuleLinter;
93    use crate::test_utils::test_helpers::test_config_with_rules;
94
95    fn test_config() -> crate::config::QuickmarkConfig {
96        test_config_with_rules(vec![
97            ("no-multiple-space-atx", RuleSeverity::Error),
98            ("heading-style", RuleSeverity::Off),
99            ("heading-increment", RuleSeverity::Off),
100        ])
101    }
102
103    #[test]
104    fn test_md019_multiple_spaces_violations() {
105        let config = test_config();
106
107        let input = "##  Heading 2
108###   Heading 3
109####    Heading 4
110";
111        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
112        let violations = linter.analyze();
113
114        // Should detect 3 violations for multiple spaces after hash
115        assert_eq!(violations.len(), 3);
116
117        for violation in &violations {
118            assert_eq!(violation.rule().id, "MD019");
119        }
120    }
121
122    #[test]
123    fn test_md019_single_space_no_violations() {
124        let config = test_config();
125
126        let input = "# Heading 1
127## Heading 2
128### Heading 3
129#### Heading 4
130";
131        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
132        let violations = linter.analyze();
133
134        // Should have no violations - single space after hash is correct
135        assert_eq!(violations.len(), 0);
136    }
137
138    #[test]
139    fn test_md019_tabs_and_spaces_violations() {
140        let config = test_config();
141
142        let input = "##\t\tHeading with tabs
143###  \tHeading with space and tab
144####   Heading with multiple spaces
145";
146        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
147        let violations = linter.analyze();
148
149        // Should detect 3 violations for multiple whitespace chars after hash
150        assert_eq!(violations.len(), 3);
151
152        for violation in &violations {
153            assert_eq!(violation.rule().id, "MD019");
154        }
155    }
156
157    #[test]
158    fn test_md019_mixed_valid_and_invalid() {
159        let config = test_config();
160
161        let input = "# Valid heading 1
162##  Invalid heading 2
163### Valid heading 3
164####   Invalid heading 4
165##### Valid heading 5
166";
167        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
168        let violations = linter.analyze();
169
170        // Should detect 2 violations (lines 2 and 4)
171        assert_eq!(violations.len(), 2);
172
173        for violation in &violations {
174            assert_eq!(violation.rule().id, "MD019");
175        }
176    }
177
178    #[test]
179    fn test_md019_no_space_violations() {
180        let config = test_config();
181
182        let input = "#Heading with no space
183##Heading with no space
184###Heading with no space
185";
186        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
187        let violations = linter.analyze();
188
189        // Should have no violations - MD019 only cares about multiple spaces, not missing spaces
190        assert_eq!(violations.len(), 0);
191    }
192
193    #[test]
194    fn test_md019_closed_atx_violations() {
195        let config = test_config();
196
197        let input = "##  Closed heading with multiple spaces ##
198###   Another closed heading ###
199";
200        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
201        let violations = linter.analyze();
202
203        // Should detect 2 violations for multiple spaces after opening hash
204        assert_eq!(violations.len(), 2);
205
206        for violation in &violations {
207            assert_eq!(violation.rule().id, "MD019");
208        }
209    }
210
211    #[test]
212    fn test_md019_only_atx_headings() {
213        let config = test_config();
214
215        let input = "Setext Heading 1
216================
217
218Setext Heading 2
219----------------
220
221##  ATX heading with multiple spaces
222";
223        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
224        let violations = linter.analyze();
225
226        // Should only detect 1 violation for the ATX heading, not setext headings
227        assert_eq!(violations.len(), 1);
228        assert_eq!(violations[0].rule().id, "MD019");
229    }
230}