quickmark_core/rules/
md019.rs1use 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 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 if content_start > marker_end {
33 let whitespace_text = &source[marker_end..content_start];
34
35 if whitespace_text.len() > 1 {
37 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 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 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 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 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 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 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 assert_eq!(violations.len(), 1);
228 assert_eq!(violations[0].rule().id, "MD019");
229 }
230}