quickmark_core/rules/
md005.rs

1use std::rc::Rc;
2
3use tree_sitter::Node;
4
5use crate::{
6    linter::{range_from_tree_sitter, RuleViolation},
7    rules::{Context, Rule, RuleLinter, RuleType},
8};
9
10pub(crate) struct MD005Linter {
11    context: Rc<Context>,
12    violations: Vec<RuleViolation>,
13}
14
15impl MD005Linter {
16    pub fn new(context: Rc<Context>) -> Self {
17        Self {
18            context,
19            violations: Vec::new(),
20        }
21    }
22}
23
24impl RuleLinter for MD005Linter {
25    fn feed(&mut self, node: &Node) {
26        if node.kind() == "list" {
27            self.check_list_indentation(node);
28        }
29    }
30
31    fn finalize(&mut self) -> Vec<RuleViolation> {
32        std::mem::take(&mut self.violations)
33    }
34}
35
36impl MD005Linter {
37    fn check_list_indentation(&mut self, list_node: &Node) {
38        let list_items = Self::get_direct_list_items_static(list_node);
39        if list_items.len() < 2 {
40            // Need at least 2 items to compare indentation
41            return;
42        }
43
44        let is_ordered = Self::is_ordered_list_static(
45            list_node,
46            self.context.document_content.borrow().as_bytes(),
47        );
48
49        if is_ordered {
50            self.check_ordered_list_indentation(list_node, &list_items);
51        } else {
52            self.check_unordered_list_indentation(list_node, &list_items);
53        }
54    }
55
56    fn get_direct_list_items_static<'a>(list_node: &Node<'a>) -> Vec<Node<'a>> {
57        let mut cursor = list_node.walk();
58        list_node
59            .children(&mut cursor)
60            .filter(|c| c.kind() == "list_item")
61            .collect()
62    }
63
64    fn is_ordered_list_static(list_node: &Node, content: &[u8]) -> bool {
65        let mut list_cursor = list_node.walk();
66        if let Some(first_item) = list_node
67            .children(&mut list_cursor)
68            .find(|c| c.kind() == "list_item")
69        {
70            let mut item_cursor = first_item.walk();
71            // Use a for loop to make lifetimes explicit and avoid borrow checker issues.
72            for child in first_item.children(&mut item_cursor) {
73                if child.kind().starts_with("list_marker") {
74                    if let Ok(text) = child.utf8_text(content) {
75                        return text.contains('.');
76                    }
77                    // If a marker is found but its text cannot be read, assume it's not an ordered list.
78                    return false;
79                }
80            }
81        }
82        false
83    }
84
85    fn check_unordered_list_indentation(&mut self, _list_node: &Node, list_items: &[Node]) {
86        let expected_indent = self.get_list_item_indentation(&list_items[0]);
87
88        for item in list_items.iter().skip(1) {
89            let actual_indent = self.get_list_item_indentation(item);
90
91            if actual_indent != expected_indent {
92                let message = format!(
93                    "{} [Expected: {}; Actual: {}]",
94                    MD005.description, expected_indent, actual_indent
95                );
96
97                self.violations.push(RuleViolation::new(
98                    &MD005,
99                    message,
100                    self.context.file_path.clone(),
101                    range_from_tree_sitter(&item.range()),
102                ));
103            }
104        }
105    }
106
107    fn check_ordered_list_indentation(&mut self, _list_node: &Node, list_items: &[Node]) {
108        // Mimic the original markdownlint algorithm more closely
109        let expected_indent = self.get_list_item_indentation(&list_items[0]);
110        let mut expected_end = 0;
111        let mut end_matching = false;
112
113        for item in list_items {
114            let actual_indent = self.get_list_item_indentation(item);
115            let marker_length = self.get_list_marker_text_length(item);
116            let actual_end = actual_indent + marker_length;
117
118            expected_end = if expected_end == 0 {
119                actual_end
120            } else {
121                expected_end
122            };
123
124            if expected_indent != actual_indent || end_matching {
125                if expected_end == actual_end {
126                    end_matching = true;
127                } else {
128                    let detail = if end_matching {
129                        format!("Expected: ({expected_end}); Actual: ({actual_end})")
130                    } else {
131                        format!("Expected: {expected_indent}; Actual: {actual_indent}")
132                    };
133
134                    self.violations.push(RuleViolation::new(
135                        &MD005,
136                        format!("{} [{}]", MD005.description, detail),
137                        self.context.file_path.clone(),
138                        range_from_tree_sitter(&item.range()),
139                    ));
140                }
141            }
142        }
143    }
144
145    fn get_list_marker_text_length(&self, list_item: &Node) -> usize {
146        let mut cursor = list_item.walk();
147        if let Some(marker_node) = list_item
148            .children(&mut cursor)
149            .find(|c| c.kind().starts_with("list_marker"))
150        {
151            let content = self.context.document_content.borrow();
152            if let Ok(text) = marker_node.utf8_text(content.as_bytes()) {
153                return text.trim().len();
154            }
155        }
156        0
157    }
158
159    fn get_list_item_indentation(&self, list_item: &Node) -> usize {
160        let content = self.context.document_content.borrow();
161        let start_line = list_item.start_position().row;
162
163        if let Some(line) = content.lines().nth(start_line) {
164            // Count leading spaces/tabs (treating tabs as single characters for now)
165            line.chars().take_while(|&c| c == ' ' || c == '\t').count()
166        } else {
167            0
168        }
169    }
170}
171
172pub const MD005: Rule = Rule {
173    id: "MD005",
174    alias: "list-indent",
175    tags: &["bullet", "ul", "indentation"],
176    description: "Inconsistent indentation for list items at the same level",
177    rule_type: RuleType::Token,
178    required_nodes: &["list"],
179    new_linter: |context| Box::new(MD005Linter::new(context)),
180};
181
182#[cfg(test)]
183mod test {
184    use std::path::PathBuf;
185
186    use crate::config::{QuickmarkConfig, RuleSeverity};
187    use crate::linter::MultiRuleLinter;
188    use crate::test_utils::test_helpers::test_config_with_rules;
189
190    fn test_config() -> QuickmarkConfig {
191        test_config_with_rules(vec![("list-indent", RuleSeverity::Error)])
192    }
193
194    #[test]
195    fn test_consistent_unordered_list_indentation_no_violations() {
196        let input = "* Item 1
197* Item 2
198* Item 3
199";
200
201        let config = test_config();
202        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
203        let violations = linter.analyze();
204        assert_eq!(
205            0,
206            violations.len(),
207            "Consistent indentation should have no violations"
208        );
209    }
210
211    #[test]
212    fn test_inconsistent_unordered_list_indentation_has_violations() {
213        let input = "* Item 1
214 * Item 2 (1 space instead of 0)
215* Item 3
216";
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!(
222            !violations.is_empty(),
223            "Inconsistent indentation should have violations"
224        );
225    }
226
227    #[test]
228    fn test_consistent_ordered_list_left_aligned_no_violations() {
229        let input = "1. Item 1
2302. Item 2
23110. Item 10
23211. Item 11
233";
234
235        let config = test_config();
236        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
237        let violations = linter.analyze();
238        assert_eq!(
239            0,
240            violations.len(),
241            "Left-aligned ordered list should have no violations"
242        );
243    }
244
245    #[test]
246    fn test_consistent_ordered_list_right_aligned_no_violations() {
247        let input = " 1. Item 1
248 2. Item 2
24910. Item 10
25011. Item 11
251";
252
253        let config = test_config();
254        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
255        let violations = linter.analyze();
256        assert_eq!(
257            0,
258            violations.len(),
259            "Right-aligned ordered list should have no violations"
260        );
261    }
262
263    #[test]
264    fn test_inconsistent_ordered_list_has_violations() {
265        let input = "1. Item 1
266 2. Item 2 (should be at same indent as item 1)
2673. Item 3
268";
269
270        let config = test_config();
271        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
272        let violations = linter.analyze();
273        assert!(
274            !violations.is_empty(),
275            "Inconsistent ordered list indentation should have violations"
276        );
277    }
278
279    #[test]
280    fn test_nested_lists_different_levels_no_violations() {
281        let input = "* Item 1
282  * Nested item 1
283  * Nested item 2
284* Item 2
285  * Nested item 3
286";
287
288        let config = test_config();
289        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
290        let violations = linter.analyze();
291        assert_eq!(
292            0,
293            violations.len(),
294            "Items at different nesting levels should not be compared"
295        );
296    }
297
298    #[test]
299    fn test_nested_lists_same_level_inconsistent() {
300        let input = "* Item 1
301  * Nested item 1
302   * Nested item 2 (should be 2 spaces like item 1)
303* Item 2
304";
305
306        let config = test_config();
307        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
308        let violations = linter.analyze();
309        assert!(
310            !violations.is_empty(),
311            "Nested items at same level with inconsistent indent should have violations"
312        );
313    }
314
315    #[test]
316    fn test_mixed_ordered_unordered_lists() {
317        let input = "1. Ordered item 1
3182. Ordered item 2
319
320* Unordered item 1  
321* Unordered item 2
322";
323
324        let config = test_config();
325        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
326        let violations = linter.analyze();
327        assert_eq!(
328            0,
329            violations.len(),
330            "Different list types should not interfere with each other"
331        );
332    }
333
334    #[test]
335    fn test_single_item_list_no_violations() {
336        let input = "* Single item
337";
338
339        let config = test_config();
340        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
341        let violations = linter.analyze();
342        assert_eq!(
343            0,
344            violations.len(),
345            "Single item lists should not have violations"
346        );
347    }
348
349    #[test]
350    fn test_empty_document_no_violations() {
351        let input = "";
352
353        let config = test_config();
354        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
355        let violations = linter.analyze();
356        assert_eq!(
357            0,
358            violations.len(),
359            "Empty documents should not have violations"
360        );
361    }
362
363    #[test]
364    fn test_ordered_list_with_different_number_lengths() {
365        let input = " 1. Item 1
366 2. Item 2
367 3. Item 3
368 4. Item 4
369 5. Item 5
370 6. Item 6
371 7. Item 7
372 8. Item 8
373 9. Item 9
37410. Item 10
37511. Item 11
37612. Item 12
377";
378
379        let config = test_config();
380        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
381        let violations = linter.analyze();
382        assert_eq!(
383            0,
384            violations.len(),
385            "Right-aligned numbers should be consistent"
386        );
387    }
388
389    #[test]
390    fn test_ordered_list_inconsistent_right_alignment() {
391        let input = " 1. Item 1
392 2. Item 2
39310. Item 10
394 11. Item 11 (should align with 10, not with 1/2)
395";
396
397        let config = test_config();
398        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
399        let violations = linter.analyze();
400        assert!(
401            !violations.is_empty(),
402            "Inconsistent right alignment should have violations"
403        );
404    }
405}