1use std::rc::Rc;
2use tree_sitter::Node;
3
4use crate::linter::{range_from_tree_sitter, Context, RuleLinter, RuleViolation};
5
6use super::{Rule, RuleType};
7
8pub(crate) struct MD023Linter {
9 context: Rc<Context>,
10 violations: Vec<RuleViolation>,
11}
12
13impl MD023Linter {
14 pub fn new(context: Rc<Context>) -> Self {
15 Self {
16 context,
17 violations: Vec::new(),
18 }
19 }
20
21 fn check_atx_heading_indentation(&mut self, node: &Node) {
22 let lines = self.context.lines.borrow();
23 if let Some(violation) = self.check_line_for_indentation(node.start_position().row, &lines)
24 {
25 self.violations.push(violation);
26 }
27 }
28
29 fn check_setext_heading_indentation(&mut self, node: &Node) {
30 let lines = self.context.lines.borrow();
31
32 let mut cursor = node.walk();
33 let mut text_line_num = None;
34 let mut underline_line_num = None;
35
36 for child in node.children(&mut cursor) {
37 match child.kind() {
38 "paragraph" => {
39 text_line_num = Some(child.start_position().row);
40 }
41 "setext_h1_underline" | "setext_h2_underline" => {
42 underline_line_num = Some(child.start_position().row);
43 }
44 _ => {}
45 }
46 }
47
48 if let Some(line_num) = text_line_num {
49 if let Some(violation) = self.check_line_for_indentation(line_num, &lines) {
50 self.violations.push(violation);
51 return; }
53 }
54
55 if let Some(line_num) = underline_line_num {
56 if let Some(violation) = self.check_line_for_indentation(line_num, &lines) {
57 self.violations.push(violation);
58 }
59 }
60 }
61
62 fn check_line_for_indentation(
64 &self,
65 line_num: usize,
66 lines: &[String],
67 ) -> Option<RuleViolation> {
68 if let Some(line) = lines.get(line_num) {
69 let leading_spaces = line.len() - line.trim_start().len();
70
71 if leading_spaces > 0 {
72 let range = tree_sitter::Range {
73 start_byte: 0, end_byte: 0, start_point: tree_sitter::Point {
76 row: line_num,
77 column: 0,
78 },
79 end_point: tree_sitter::Point {
80 row: line_num,
81 column: leading_spaces,
82 },
83 };
84
85 return Some(RuleViolation::new(
86 &MD023,
87 MD023.description.to_string(),
88 self.context.file_path.clone(),
89 range_from_tree_sitter(&range),
90 ));
91 }
92 }
93 None
94 }
95}
96
97impl RuleLinter for MD023Linter {
98 fn feed(&mut self, node: &Node) {
99 match node.kind() {
100 "atx_heading" => self.check_atx_heading_indentation(node),
101 "setext_heading" => self.check_setext_heading_indentation(node),
102 _ => {
103 }
106 }
107 }
108
109 fn finalize(&mut self) -> Vec<RuleViolation> {
110 std::mem::take(&mut self.violations)
111 }
112}
113
114pub const MD023: Rule = Rule {
115 id: "MD023",
116 alias: "heading-start-left",
117 tags: &["headings", "spaces"],
118 description: "Headings must start at the beginning of the line",
119 rule_type: RuleType::Hybrid,
120 required_nodes: &["atx_heading", "setext_heading"],
121 new_linter: |context| Box::new(MD023Linter::new(context)),
122};
123
124#[cfg(test)]
125mod test {
126 use std::path::PathBuf;
127
128 use crate::config::RuleSeverity;
129 use crate::linter::MultiRuleLinter;
130 use crate::test_utils::test_helpers::test_config_with_rules;
131
132 fn test_config() -> crate::config::QuickmarkConfig {
133 test_config_with_rules(vec![
134 ("heading-start-left", RuleSeverity::Error),
135 ("heading-style", RuleSeverity::Off),
136 ("heading-increment", RuleSeverity::Off),
137 ])
138 }
139
140 #[test]
141 fn test_atx_heading_indented() {
142 let input = "Some text
143
144 # Indented heading
145
146More text";
147
148 let config = test_config();
149 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
150 let violations = linter.analyze();
151 assert_eq!(1, violations.len());
152
153 let violation = &violations[0];
154 assert_eq!(2, violation.location().range.start.line);
155 assert_eq!(0, violation.location().range.start.character);
156 assert_eq!(2, violation.location().range.end.line);
157 assert_eq!(1, violation.location().range.end.character);
158 }
159
160 #[test]
161 fn test_atx_heading_not_indented() {
162 let input = "Some text
163
164# Not indented heading
165
166More text";
167
168 let config = test_config();
169 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
170 let violations = linter.analyze();
171 assert_eq!(0, violations.len());
172 }
173
174 #[test]
175 fn test_multiple_spaces_indentation() {
176 let input = "Some text
177
178 # Heading with 3 spaces
179
180More text";
181
182 let config = test_config();
183 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
184 let violations = linter.analyze();
185 assert_eq!(1, violations.len());
186
187 let violation = &violations[0];
188 assert_eq!(2, violation.location().range.start.line);
189 assert_eq!(0, violation.location().range.start.character);
190 assert_eq!(2, violation.location().range.end.line);
191 assert_eq!(3, violation.location().range.end.character);
192 }
193
194 #[test]
195 fn test_setext_heading_indented_text() {
196 let input = "Some text
197
198 Indented setext heading
199========================
200
201More text";
202
203 let config = test_config();
204 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
205 let violations = linter.analyze();
206 assert_eq!(1, violations.len());
207 }
208
209 #[test]
210 fn test_setext_heading_indented_underline() {
211 let input = "Some text
212
213Setext heading
214 ==============
215
216More text";
217
218 let config = test_config();
219 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
220 let violations = linter.analyze();
221 assert_eq!(1, violations.len());
222 }
223
224 #[test]
225 fn test_setext_heading_both_indented() {
226 let input = "Some text
227
228 Setext heading
229 ==============
230
231More text";
232
233 let config = test_config();
234 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
235 let violations = linter.analyze();
236 assert_eq!(1, violations.len());
237 }
238
239 #[test]
240 fn test_setext_heading_not_indented() {
241 let input = "Some text
242
243Setext heading
244==============
245
246More text";
247
248 let config = test_config();
249 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
250 let violations = linter.analyze();
251 assert_eq!(0, violations.len());
252 }
253
254 #[test]
255 fn test_heading_in_list_item() {
256 let input = "* List item
257 # Heading in list (should trigger)
258
259* Another item";
260
261 let config = test_config();
262 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
263 let violations = linter.analyze();
264 assert_eq!(1, violations.len());
265 }
266
267 #[test]
268 fn test_heading_in_blockquote() {
269 let input = "> # Heading in blockquote (should NOT trigger)
270
271> More blockquote content";
272
273 let config = test_config();
274 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
275 let violations = linter.analyze();
276 assert_eq!(0, violations.len());
277 }
278
279 #[test]
280 fn test_hash_in_code_block() {
281 let input = "```
282# This is code, not a heading
283 # This should also not trigger
284```";
285
286 let config = test_config();
287 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
288 let violations = linter.analyze();
289 assert_eq!(0, violations.len());
290 }
291
292 #[test]
293 fn test_hash_in_inline_code() {
294 let input = "Text with `# inline code` and more text";
295
296 let config = test_config();
297 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
298 let violations = linter.analyze();
299 assert_eq!(0, violations.len());
300 }
301
302 #[test]
303 fn test_multiple_indented_headings() {
304 let input = " # First indented heading
305
306 ## Second indented heading
307
308### Not indented
309
310 #### Third indented heading";
311
312 let config = test_config();
313 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
314 let violations = linter.analyze();
315 assert_eq!(3, violations.len());
316
317 assert_eq!(0, violations[0].location().range.start.line);
319
320 assert_eq!(2, violations[1].location().range.start.line);
322
323 assert_eq!(6, violations[2].location().range.start.line);
325 }
326}