quickmark_core/rules/
md030.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// MD030-specific configuration types
12#[derive(Debug, PartialEq, Clone, Deserialize)]
13pub struct MD030ListMarkerSpaceTable {
14    #[serde(default)]
15    pub ul_single: usize,
16    #[serde(default)]
17    pub ol_single: usize,
18    #[serde(default)]
19    pub ul_multi: usize,
20    #[serde(default)]
21    pub ol_multi: usize,
22}
23
24impl Default for MD030ListMarkerSpaceTable {
25    fn default() -> Self {
26        Self {
27            ul_single: 1,
28            ol_single: 1,
29            ul_multi: 1,
30            ol_multi: 1,
31        }
32    }
33}
34
35pub(crate) struct MD030Linter {
36    context: Rc<Context>,
37    violations: Vec<RuleViolation>,
38}
39
40impl MD030Linter {
41    pub fn new(context: Rc<Context>) -> Self {
42        Self {
43            context,
44            violations: Vec::new(),
45        }
46    }
47}
48
49impl RuleLinter for MD030Linter {
50    fn feed(&mut self, node: &Node) {
51        if node.kind() == "list" {
52            self.check_list_marker_spacing(node);
53        }
54    }
55
56    fn finalize(&mut self) -> Vec<RuleViolation> {
57        std::mem::take(&mut self.violations)
58    }
59}
60
61impl MD030Linter {
62    fn check_list_marker_spacing(&mut self, list_node: &Node) {
63        let list_items: Vec<Node> = {
64            let mut cursor = list_node.walk();
65            list_node
66                .children(&mut cursor)
67                .filter(|c| c.kind() == "list_item")
68                .collect()
69        };
70
71        if list_items.is_empty() {
72            return;
73        }
74
75        let is_ordered = self.is_ordered_list(&list_items[0]);
76        let is_single_line = self.is_single_line_list(&list_items);
77
78        let expected_spaces = self.get_expected_spaces(is_ordered, is_single_line);
79
80        for list_item in &list_items {
81            self.check_list_item_spacing(list_item, expected_spaces);
82        }
83    }
84
85    fn is_ordered_list(&self, list_item_node: &Node) -> bool {
86        let mut cursor = list_item_node.walk();
87        let result = list_item_node
88            .children(&mut cursor)
89            .find(|c| c.kind().starts_with("list_marker"))
90            .is_some_and(|marker_node| {
91                let kind = marker_node.kind();
92                kind == "list_marker_dot" || kind == "list_marker_parenthesis"
93            });
94        result
95    }
96
97    fn is_single_line_list(&self, list_items: &[Node]) -> bool {
98        // A list is single-line if all its items are single-line
99        // (i.e., each item starts and ends on the same line)
100        list_items
101            .iter()
102            .all(|item| item.start_position().row == item.end_position().row)
103    }
104
105    fn get_expected_spaces(&self, is_ordered: bool, is_single_line: bool) -> usize {
106        let config = &self.context.config.linters.settings.list_marker_space;
107        match (is_ordered, is_single_line) {
108            (true, true) => config.ol_single,
109            (true, false) => config.ol_multi,
110            (false, true) => config.ul_single,
111            (false, false) => config.ul_multi,
112        }
113    }
114
115    fn check_list_item_spacing(&mut self, list_item: &Node, expected_spaces: usize) {
116        let content = self.context.document_content.borrow();
117        let item_text = match list_item.utf8_text(content.as_bytes()) {
118            Ok(text) => text,
119            Err(_) => return, // Ignore if text cannot be decoded
120        };
121
122        if let Some(first_line) = item_text.lines().next() {
123            if let Some(actual_spaces) = self.extract_spaces_after_marker(first_line) {
124                if actual_spaces != expected_spaces {
125                    let message = format!(
126                        "{} [Expected: {}; Actual: {}]",
127                        MD030.description, expected_spaces, actual_spaces
128                    );
129
130                    self.violations.push(RuleViolation::new(
131                        &MD030,
132                        message,
133                        self.context.file_path.clone(),
134                        range_from_tree_sitter(&list_item.range()),
135                    ));
136                }
137            }
138        }
139    }
140
141    fn extract_spaces_after_marker(&self, line: &str) -> Option<usize> {
142        let line = line.trim_start(); // Remove leading indentation
143
144        // Handle unordered lists: *, +, -
145        if line.starts_with(['*', '+', '-']) {
146            let after_marker = &line[1..];
147            return Some(after_marker.chars().take_while(|&c| c == ' ').count());
148        }
149
150        // Handle ordered lists: 1., 2., etc.
151        if let Some(dot_pos) = line.find('.') {
152            let before_dot = &line[..dot_pos];
153            if !before_dot.is_empty() && before_dot.chars().all(|c| c.is_ascii_digit()) {
154                let after_marker = &line[dot_pos + 1..];
155                return Some(after_marker.chars().take_while(|&c| c == ' ').count());
156            }
157        }
158
159        None
160    }
161}
162
163pub const MD030: Rule = Rule {
164    id: "MD030",
165    alias: "list-marker-space",
166    tags: &["ol", "ul", "whitespace"],
167    description: "Spaces after list markers",
168    rule_type: RuleType::Token,
169    required_nodes: &["list"],
170    new_linter: |context| Box::new(MD030Linter::new(context)),
171};
172
173#[cfg(test)]
174mod test {
175    use std::path::PathBuf;
176
177    use crate::config::{QuickmarkConfig, RuleSeverity};
178    use crate::linter::MultiRuleLinter;
179    use crate::test_utils::test_helpers::test_config_with_rules;
180
181    fn test_config() -> QuickmarkConfig {
182        test_config_with_rules(vec![("list-marker-space", RuleSeverity::Error)])
183    }
184
185    #[test]
186    fn test_default_unordered_list_single_space_no_violations() {
187        let input = "* Item 1\n* Item 2\n* Item 3\n";
188
189        let config = test_config();
190        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
191        let violations = linter.analyze();
192        assert_eq!(
193            0,
194            violations.len(),
195            "Default single space after unordered list marker should have no violations"
196        );
197    }
198
199    #[test]
200    fn test_default_ordered_list_single_space_no_violations() {
201        let input = "1. Item 1\n2. Item 2\n3. Item 3\n";
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!(
207            0,
208            violations.len(),
209            "Default single space after ordered list marker should have no violations"
210        );
211    }
212
213    #[test]
214    fn test_unordered_list_double_space_has_violations() {
215        let input = "*  Item 1\n*  Item 2\n*  Item 3\n";
216
217        let config = test_config();
218        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
219        let violations = linter.analyze();
220        assert!(
221            !violations.is_empty(),
222            "Double space after unordered list marker should have violations"
223        );
224    }
225
226    #[test]
227    fn test_ordered_list_double_space_has_violations() {
228        let input = "1.  Item 1\n2.  Item 2\n3.  Item 3\n";
229
230        let config = test_config();
231        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
232        let violations = linter.analyze();
233        assert!(
234            !violations.is_empty(),
235            "Double space after ordered list marker should have violations"
236        );
237    }
238
239    #[test]
240    fn test_mixed_list_types_independent() {
241        let input = "* Item 1\n* Item 2\n\n1. Item 1\n2. Item 2\n";
242
243        let config = test_config();
244        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
245        let violations = linter.analyze();
246        assert_eq!(
247            0,
248            violations.len(),
249            "Mixed list types with correct spacing should have no violations"
250        );
251    }
252
253    #[test]
254    fn test_single_line_vs_multi_line_lists() {
255        // Single-line list - each item is on one line
256        let input_single = "* Item 1\n* Item 2\n* Item 3\n";
257
258        // Multi-line list - has content that spans multiple lines
259        let input_multi = "*   Item 1\n\n    Second paragraph\n\n*   Item 2\n";
260
261        let config = test_config();
262
263        // Single-line list with default spacing (1 space)
264        let mut linter = MultiRuleLinter::new_for_document(
265            PathBuf::from("test.md"),
266            config.clone(),
267            input_single,
268        );
269        let violations = linter.analyze();
270        assert_eq!(
271            0,
272            violations.len(),
273            "Single-line list with 1 space should be valid"
274        );
275
276        // Multi-line list with 3 spaces (will fail with default config expecting 1 space)
277        let mut linter =
278            MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input_multi);
279        let violations = linter.analyze();
280        assert!(
281            !violations.is_empty(),
282            "Multi-line list with 3 spaces should have violations when expecting 1"
283        );
284    }
285
286    #[test]
287    fn test_nested_lists_not_affected() {
288        let input = "* Item 1\n  * Nested item 1\n  * Nested item 2\n* Item 2\n";
289
290        let config = test_config();
291        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
292        let violations = linter.analyze();
293        assert_eq!(
294            0,
295            violations.len(),
296            "Nested lists with correct spacing should have no violations"
297        );
298    }
299
300    #[test]
301    fn test_no_space_after_marker_has_violations() {
302        // This test is invalid because "*Item 1" without space is not a valid list item
303        // according to CommonMark specification. Tree-sitter correctly doesn't parse it as a list.
304        // Instead, let's test a case with too few spaces compared to expectation.
305
306        // Using a multi-line list where config expects 1 space but we have 0 would be invalid markdown.
307        // So let's skip this test or modify it to test a valid but incorrect case.
308        // For now, let's test double spaces which we know should fail:
309        let input = "*  Item 1\n*  Item 2\n";
310
311        let config = test_config();
312        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
313        let violations = linter.analyze();
314        assert!(
315            !violations.is_empty(),
316            "Double space after list marker should have violations with default config expecting 1 space"
317        );
318    }
319
320    #[test]
321    fn test_three_spaces_after_marker_has_violations() {
322        let input = "*   Item 1\n*   Item 2\n";
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!(
328            !violations.is_empty(),
329            "Three spaces after list marker should have violations with default config"
330        );
331    }
332
333    #[test]
334    fn test_plus_marker_type() {
335        let input = "+ Item 1\n+ Item 2\n";
336
337        let config = test_config();
338        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
339        let violations = linter.analyze();
340        assert_eq!(
341            0,
342            violations.len(),
343            "Plus marker with single space should have no violations"
344        );
345    }
346
347    #[test]
348    fn test_dash_marker_type() {
349        let input = "- Item 1\n- Item 2\n";
350
351        let config = test_config();
352        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
353        let violations = linter.analyze();
354        assert_eq!(
355            0,
356            violations.len(),
357            "Dash marker with single space should have no violations"
358        );
359    }
360}