quickmark_core/rules/
md007.rs

1use serde::Deserialize;
2use std::rc::Rc;
3
4use tree_sitter::Node;
5
6use crate::{
7    linter::{range_from_tree_sitter, RuleViolation},
8    rules::{Context, Rule, RuleLinter, RuleType},
9};
10
11// MD007-specific configuration types
12#[derive(Debug, PartialEq, Clone, Deserialize)]
13pub struct MD007UlIndentTable {
14    #[serde(default)]
15    pub indent: usize,
16    #[serde(default)]
17    pub start_indent: usize,
18    #[serde(default)]
19    pub start_indented: bool,
20}
21
22impl Default for MD007UlIndentTable {
23    fn default() -> Self {
24        Self {
25            indent: 2,
26            start_indent: 2,
27            start_indented: false,
28        }
29    }
30}
31
32pub(crate) struct MD007Linter {
33    context: Rc<Context>,
34    violations: Vec<RuleViolation>,
35}
36
37impl MD007Linter {
38    pub fn new(context: Rc<Context>) -> Self {
39        Self {
40            context,
41            violations: Vec::new(),
42        }
43    }
44}
45
46impl RuleLinter for MD007Linter {
47    fn feed(&mut self, node: &Node) {
48        if node.kind() == "list" && self.is_unordered_list(node) {
49            self.check_list_indentation(node);
50        }
51    }
52
53    fn finalize(&mut self) -> Vec<RuleViolation> {
54        std::mem::take(&mut self.violations)
55    }
56}
57
58impl MD007Linter {
59    /// Check if a list node is an unordered list by examining its first marker
60    fn is_unordered_list(&self, list_node: &Node) -> bool {
61        let mut list_cursor = list_node.walk();
62        if let Some(first_item) = list_node
63            .children(&mut list_cursor)
64            .find(|c| c.kind() == "list_item")
65        {
66            let mut item_cursor = first_item.walk();
67            for child in first_item.children(&mut item_cursor) {
68                if child.kind().starts_with("list_marker") {
69                    let content = self.context.document_content.borrow();
70                    if let Ok(text) = child.utf8_text(content.as_bytes()) {
71                        // Check if it's an unordered list marker
72                        if let Some(marker_char) = text.trim().chars().next() {
73                            return matches!(marker_char, '*' | '+' | '-');
74                        }
75                    }
76                    // If marker is found but unreadable, assume not unordered
77                    return false;
78                }
79            }
80        }
81        false
82    }
83
84    fn check_list_indentation(&mut self, list_node: &Node) {
85        let nesting_level = self.calculate_nesting_level(list_node);
86
87        // Only check unordered sublists if all parent lists are also unordered
88        if nesting_level > 0 && !self.all_parents_unordered(list_node) {
89            return;
90        }
91
92        let mut cursor = list_node.walk();
93        for list_item in list_node.children(&mut cursor) {
94            if list_item.kind() == "list_item" {
95                // List items are indented at the same level as their parent list
96                // The nesting level of a list item is the number of ancestor lists it has
97                let item_nesting_level = self.calculate_list_item_nesting_level(&list_item);
98                self.check_list_item_indentation(list_item, item_nesting_level);
99            }
100        }
101    }
102
103    fn check_list_item_indentation(&mut self, list_item: Node, nesting_level: usize) {
104        let config = &self.context.config.linters.settings.ul_indent;
105        let actual_indent = self.get_list_item_indentation(&list_item);
106        let expected_indent = self.calculate_expected_indent(nesting_level, config);
107
108        if actual_indent != expected_indent {
109            let message = format!(
110                "{} [Expected: {}; Actual: {}]",
111                MD007.description, expected_indent, actual_indent
112            );
113
114            self.violations.push(RuleViolation::new(
115                &MD007,
116                message,
117                self.context.file_path.clone(),
118                range_from_tree_sitter(&list_item.range()),
119            ));
120        }
121    }
122
123    fn get_list_item_indentation(&self, list_item: &Node) -> usize {
124        let content = self.context.document_content.borrow();
125        let start_line = list_item.start_position().row;
126
127        if let Some(line) = content.lines().nth(start_line) {
128            // Count leading spaces/tabs (treating tabs as single characters for now)
129            line.chars().take_while(|&c| c == ' ' || c == '\t').count()
130        } else {
131            0
132        }
133    }
134
135    fn calculate_expected_indent(
136        &self,
137        nesting_level: usize,
138        config: &MD007UlIndentTable,
139    ) -> usize {
140        if nesting_level == 0 {
141            // Top level
142            if config.start_indented {
143                config.start_indent
144            } else {
145                0
146            }
147        } else {
148            // Nested levels
149            let base_indent = if config.start_indented {
150                config.start_indent
151            } else {
152                0
153            };
154            base_indent + (nesting_level * config.indent)
155        }
156    }
157
158    fn calculate_nesting_level(&self, list_node: &Node) -> usize {
159        let mut nesting_level = 0;
160        let mut current_node = *list_node;
161
162        // Walk up the tree looking for parent list nodes (any kind)
163        while let Some(parent) = current_node.parent() {
164            if parent.kind() == "list" {
165                nesting_level += 1;
166            }
167            current_node = parent;
168        }
169
170        nesting_level
171    }
172
173    fn calculate_list_item_nesting_level(&self, list_item: &Node) -> usize {
174        let mut nesting_level: usize = 0;
175        let mut current_node = *list_item;
176
177        // Walk up the tree looking for ancestor list nodes (any kind)
178        while let Some(parent) = current_node.parent() {
179            if parent.kind() == "list" {
180                nesting_level += 1;
181            }
182            current_node = parent;
183        }
184
185        // List items are indented one level less than the number of ancestor lists
186        // because the immediate parent list determines the indentation level
187        nesting_level.saturating_sub(1)
188    }
189
190    fn all_parents_unordered(&self, list_node: &Node) -> bool {
191        let mut current_node = *list_node;
192
193        // Walk up the tree checking all parent list nodes
194        while let Some(parent) = current_node.parent() {
195            if parent.kind() == "list" && !self.is_unordered_list(&parent) {
196                return false;
197            }
198            current_node = parent;
199        }
200
201        true
202    }
203}
204
205pub const MD007: Rule = Rule {
206    id: "MD007",
207    alias: "ul-indent",
208    tags: &["bullet", "indentation", "ul"],
209    description: "Unordered list indentation",
210    rule_type: RuleType::Token,
211    required_nodes: &["list"],
212    new_linter: |context| Box::new(MD007Linter::new(context)),
213};
214
215#[cfg(test)]
216mod test {
217    use std::path::PathBuf;
218
219    use super::MD007UlIndentTable; // Local import
220    use crate::config::{LintersSettingsTable, LintersTable, QuickmarkConfig, RuleSeverity};
221    use crate::linter::MultiRuleLinter;
222    use crate::test_utils::test_helpers::test_config_with_rules;
223    use std::collections::HashMap;
224
225    fn test_config() -> QuickmarkConfig {
226        test_config_with_rules(vec![("ul-indent", RuleSeverity::Error)])
227    }
228
229    fn test_config_custom(
230        indent: usize,
231        start_indent: usize,
232        start_indented: bool,
233    ) -> QuickmarkConfig {
234        let severity: HashMap<String, RuleSeverity> =
235            vec![("ul-indent".to_string(), RuleSeverity::Error)]
236                .into_iter()
237                .collect();
238
239        QuickmarkConfig::new(LintersTable {
240            severity,
241            settings: LintersSettingsTable {
242                ul_indent: MD007UlIndentTable {
243                    indent,
244                    start_indent,
245                    start_indented,
246                },
247                ..Default::default()
248            },
249        })
250    }
251
252    #[test]
253    fn test_default_settings_values() {
254        let config = test_config();
255        assert_eq!(2, config.linters.settings.ul_indent.indent);
256        assert_eq!(2, config.linters.settings.ul_indent.start_indent);
257        assert!(!config.linters.settings.ul_indent.start_indented);
258    }
259
260    #[test]
261    fn test_custom_settings_values() {
262        let config = test_config_custom(4, 3, true);
263        assert_eq!(4, config.linters.settings.ul_indent.indent);
264        assert_eq!(3, config.linters.settings.ul_indent.start_indent);
265        assert!(config.linters.settings.ul_indent.start_indented);
266    }
267
268    #[test]
269    fn test_proper_indentation_default_settings() {
270        let input = "* Item 1
271  * Item 2
272    * Item 3
273  * Item 4
274* Item 5
275";
276
277        let config = test_config();
278        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
279        let violations = linter.analyze();
280        assert_eq!(0, violations.len());
281    }
282
283    #[test]
284    fn test_improper_indentation_default_settings() {
285        let input = "* Item 1
286 * Item 2 (1 space, should be 2)
287   * Item 3 (3 spaces, should be 2)
288    * Item 4 (4 spaces, should be 4 for level 2)
289* Item 5
290";
291
292        let config = test_config();
293        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
294        let violations = linter.analyze();
295        assert!(
296            !violations.is_empty(),
297            "Should have violations for improper indentation"
298        );
299    }
300
301    #[test]
302    fn test_start_indented_false_default() {
303        let input = "* Item 1
304  * Item 2
305* Item 3
306";
307
308        let config = test_config();
309        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
310        let violations = linter.analyze();
311        assert_eq!(
312            0,
313            violations.len(),
314            "Top-level items should not be indented by default"
315        );
316    }
317
318    #[test]
319    fn test_start_indented_true() {
320        let input = "  * Item 1
321    * Item 2
322  * Item 3
323";
324
325        let config = test_config_custom(2, 2, true);
326        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
327        let violations = linter.analyze();
328        assert_eq!(
329            0,
330            violations.len(),
331            "Top-level items should be indented when start_indented=true"
332        );
333    }
334
335    #[test]
336    fn test_start_indented_true_wrong_indentation() {
337        let input = "* Item 1 (should be indented by start_indent=2)
338  * Item 2
339";
340
341        let config = test_config_custom(2, 2, true);
342        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
343        let violations = linter.analyze();
344        assert!(
345            !violations.is_empty(),
346            "Should have violations when start_indented=true but top-level not indented"
347        );
348    }
349
350    #[test]
351    fn test_different_start_indent_value() {
352        let input = "   * Item 1
353     * Item 2
354   * Item 3
355";
356
357        let config = test_config_custom(2, 3, true);
358        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
359        let violations = linter.analyze();
360        assert_eq!(
361            0,
362            violations.len(),
363            "Should use start_indent=3 for first level when start_indented=true"
364        );
365    }
366
367    #[test]
368    fn test_custom_indent_value() {
369        let input = "* Item 1
370    * Item 2 (4 spaces for indent=4)
371        * Item 3 (8 spaces for level 2 with indent=4)
372    * Item 4
373* Item 5
374";
375
376        let config = test_config_custom(4, 2, false);
377        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
378        let violations = linter.analyze();
379        assert_eq!(0, violations.len(), "Should accept custom indent=4");
380    }
381
382    #[test]
383    fn test_mixed_lists_only_ul() {
384        let input = "* Unordered item 1
385  * Unordered item 2
386
3871. Ordered item 1
388   2. Ordered item 2 (this should be ignored)
389";
390
391        let config = test_config();
392        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
393        let violations = linter.analyze();
394        assert_eq!(
395            0,
396            violations.len(),
397            "Should only check unordered lists, ignore ordered lists"
398        );
399    }
400
401    #[test]
402    fn test_nested_unordered_in_ordered() {
403        let input = "1. Ordered item
404   * Unordered nested (should be checked for indentation)
405     * Deeper unordered nested
4062. Another ordered item
407";
408
409        let config = test_config();
410        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
411        let violations = linter.analyze();
412        // The rule should only check unordered sublists if all parent lists are unordered
413        // In this case, the parent is ordered, so it should be ignored
414        assert_eq!(
415            0,
416            violations.len(),
417            "Should ignore unordered lists nested in ordered lists"
418        );
419    }
420
421    #[test]
422    fn test_single_item_list() {
423        let input = "* Single item
424";
425
426        let config = test_config();
427        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
428        let violations = linter.analyze();
429        assert_eq!(0, violations.len());
430    }
431
432    #[test]
433    fn test_empty_document() {
434        let input = "";
435
436        let config = test_config();
437        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
438        let violations = linter.analyze();
439        assert_eq!(0, violations.len());
440    }
441
442    #[test]
443    fn test_multiple_list_blocks() {
444        let input = "* List 1 item 1
445  * List 1 item 2
446
447Some text
448
449* List 2 item 1
450  * List 2 item 2
451";
452
453        let config = test_config();
454        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
455        let violations = linter.analyze();
456        assert_eq!(0, violations.len());
457    }
458}