quickmark_core/rules/
md023.rs

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; // Report one violation per heading
52            }
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    /// Checks a single line for indentation and returns a RuleViolation if it's indented.
63    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, // Not used by range_from_tree_sitter
74                    end_byte: 0,   // Not used by range_from_tree_sitter
75                    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                // Ignore other nodes. It seems the linter is not filtering nodes
104                // based on `required_nodes` before feeding them to the rule.
105            }
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        // First violation
318        assert_eq!(0, violations[0].location().range.start.line);
319
320        // Second violation
321        assert_eq!(2, violations[1].location().range.start.line);
322
323        // Third violation
324        assert_eq!(6, violations[2].location().range.start.line);
325    }
326}