quickmark_core/rules/
md004.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3use std::rc::Rc;
4
5use tree_sitter::Node;
6
7use crate::{
8    linter::{range_from_tree_sitter, RuleViolation},
9    rules::{Context, Rule, RuleLinter, RuleType},
10};
11
12// MD004-specific configuration types
13#[derive(Debug, PartialEq, Clone, Deserialize)]
14pub enum UlStyle {
15    #[serde(rename = "asterisk")]
16    Asterisk,
17    #[serde(rename = "consistent")]
18    Consistent,
19    #[serde(rename = "dash")]
20    Dash,
21    #[serde(rename = "plus")]
22    Plus,
23    #[serde(rename = "sublist")]
24    Sublist,
25}
26
27impl Default for UlStyle {
28    fn default() -> Self {
29        Self::Consistent
30    }
31}
32
33#[derive(Debug, PartialEq, Clone, Deserialize)]
34pub struct MD004UlStyleTable {
35    #[serde(default)]
36    pub style: UlStyle,
37}
38
39impl Default for MD004UlStyleTable {
40    fn default() -> Self {
41        Self {
42            style: UlStyle::Consistent,
43        }
44    }
45}
46
47pub(crate) struct MD004Linter {
48    context: Rc<Context>,
49    violations: Vec<RuleViolation>,
50    nesting_styles: HashMap<usize, char>, // Track expected markers by nesting level for sublist style
51    document_expected_style: Option<char>, // Track expected style for the entire document in consistent mode
52}
53
54impl MD004Linter {
55    pub fn new(context: Rc<Context>) -> Self {
56        Self {
57            context,
58            violations: Vec::new(),
59            nesting_styles: HashMap::new(),
60            document_expected_style: None,
61        }
62    }
63
64    /// Extract marker character from a text node
65    fn extract_marker(text: &str) -> Option<char> {
66        text.trim()
67            .chars()
68            .next()
69            .filter(|&c| c == '*' || c == '+' || c == '-')
70    }
71
72    /// Convert marker character to style name for error messages
73    fn marker_to_style_name(marker: char) -> &'static str {
74        match marker {
75            '*' => "asterisk",
76            '+' => "plus",
77            '-' => "dash",
78            _ => "unknown",
79        }
80    }
81
82    /// Get expected marker for a given style
83    fn style_to_marker(style: &UlStyle) -> Option<char> {
84        match style {
85            UlStyle::Asterisk => Some('*'),
86            UlStyle::Dash => Some('-'),
87            UlStyle::Plus => Some('+'),
88            UlStyle::Consistent | UlStyle::Sublist => None, // These are determined dynamically
89        }
90    }
91
92    /// Find list item markers within a list node
93    fn find_list_item_markers<'a>(&self, list_node: &Node<'a>) -> Vec<(Node<'a>, char)> {
94        let mut markers = Vec::new();
95        let content = self.context.document_content.borrow();
96        let source_bytes = content.as_bytes();
97        let mut list_cursor = list_node.walk();
98
99        for list_item in list_node.children(&mut list_cursor) {
100            if list_item.kind() == "list_item" {
101                // This is the key: we need a new cursor for the sub-iteration
102                let mut item_cursor = list_item.walk();
103                for child in list_item.children(&mut item_cursor) {
104                    if child.kind().starts_with("list_marker") {
105                        if let Some(marker_char) = child
106                            .utf8_text(source_bytes)
107                            .ok()
108                            .and_then(Self::extract_marker)
109                        {
110                            markers.push((child, marker_char));
111                        }
112                        // Once we find a marker for a list_item, we can stop searching its children.
113                        break;
114                    }
115                }
116            }
117        }
118        markers
119    }
120
121    /// Calculate nesting level of a list within other lists
122    fn calculate_nesting_level(&self, list_node: &Node) -> usize {
123        let mut nesting_level = 0;
124        let mut current_node = *list_node;
125
126        // Walk up the tree looking for parent list nodes
127        while let Some(parent) = current_node.parent() {
128            if parent.kind() == "list" {
129                nesting_level += 1;
130            }
131            current_node = parent;
132        }
133
134        nesting_level
135    }
136
137    fn check_list(&mut self, node: &Node) {
138        let style = &self.context.config.linters.settings.ul_style.style;
139
140        // Extract marker information immediately to avoid lifetime issues
141        let marker_info: Vec<(tree_sitter::Range, char)> = {
142            let markers = self.find_list_item_markers(node);
143            markers
144                .into_iter()
145                .map(|(node, marker)| (node.range(), marker))
146                .collect()
147        };
148
149        if marker_info.is_empty() {
150            return; // No markers found, nothing to check
151        }
152
153        let nesting_level = self.calculate_nesting_level(node);
154
155        // Debug: print found markers
156        // eprintln!("Found {} markers: {:?}", marker_info.len(), marker_info.iter().map(|(_, c)| c).collect::<Vec<_>>());
157        // eprintln!("Nesting level: {}", nesting_level);
158        let expected_marker: Option<char>;
159
160        match style {
161            UlStyle::Consistent => {
162                // For consistent style, first marker in document sets the expected style
163                if let Some(document_style) = self.document_expected_style {
164                    expected_marker = Some(document_style);
165                } else {
166                    // First list in document - set the expected style
167                    expected_marker = Some(marker_info[0].1);
168                    self.document_expected_style = expected_marker;
169                }
170            }
171            UlStyle::Asterisk | UlStyle::Dash | UlStyle::Plus => {
172                expected_marker = Self::style_to_marker(style);
173            }
174            UlStyle::Sublist => {
175                // Handle sublist style - each nesting level should differ from its parent
176                if let Some(&parent_marker) =
177                    self.nesting_styles.get(&nesting_level.saturating_sub(1))
178                {
179                    // Choose a different marker from parent
180                    expected_marker = Some(match parent_marker {
181                        '*' => '+',
182                        '+' => '-',
183                        '-' => '*',
184                        _ => '*',
185                    });
186                } else {
187                    // Top level - use first marker found or default to asterisk
188                    expected_marker = Some(
189                        marker_info
190                            .first()
191                            .map(|(_, marker)| *marker)
192                            .unwrap_or('*'),
193                    );
194                }
195
196                // Remember this nesting level's marker
197                if let Some(marker) = expected_marker {
198                    self.nesting_styles.insert(nesting_level, marker);
199                }
200            }
201        }
202
203        // Check all markers against expected and collect violations
204        if let Some(expected) = expected_marker {
205            for (range, actual_marker) in marker_info {
206                if actual_marker != expected {
207                    let message = format!(
208                        "{} [Expected: {}; Actual: {}]",
209                        MD004.description,
210                        Self::marker_to_style_name(expected),
211                        Self::marker_to_style_name(actual_marker)
212                    );
213
214                    self.violations.push(RuleViolation::new(
215                        &MD004,
216                        message,
217                        self.context.file_path.clone(),
218                        range_from_tree_sitter(&range),
219                    ));
220                }
221            }
222        }
223    }
224}
225
226impl RuleLinter for MD004Linter {
227    fn feed(&mut self, node: &Node) {
228        if node.kind() == "list" {
229            // Only check unordered lists, not ordered lists
230            if self.is_unordered_list(node) {
231                self.check_list(node);
232            }
233        }
234    }
235
236    fn finalize(&mut self) -> Vec<RuleViolation> {
237        std::mem::take(&mut self.violations)
238    }
239}
240
241impl MD004Linter {
242    /// Check if a list node is an unordered list by examining its first marker
243    fn is_unordered_list(&self, list_node: &Node) -> bool {
244        let mut list_cursor = list_node.walk();
245        for list_item in list_node.children(&mut list_cursor) {
246            if list_item.kind() == "list_item" {
247                let mut item_cursor = list_item.walk();
248                for child in list_item.children(&mut item_cursor) {
249                    if child.kind().starts_with("list_marker") {
250                        let content = self.context.document_content.borrow();
251                        if let Ok(text) = child.utf8_text(content.as_bytes()) {
252                            if let Some(marker_char) = text.trim().chars().next() {
253                                return matches!(marker_char, '*' | '+' | '-');
254                            }
255                        }
256                        return false; // Found marker, but failed to parse
257                    }
258                }
259            }
260        }
261        false
262    }
263}
264
265pub const MD004: Rule = Rule {
266    id: "MD004",
267    alias: "ul-style",
268    tags: &["bullet", "ul"],
269    description: "Unordered list style",
270    rule_type: RuleType::Token,
271    required_nodes: &["list"],
272    new_linter: |context| Box::new(MD004Linter::new(context)),
273};
274
275#[cfg(test)]
276mod test {
277    use std::path::PathBuf;
278
279    use crate::config::RuleSeverity;
280    use crate::linter::MultiRuleLinter;
281    use crate::test_utils::test_helpers::test_config_with_rules;
282
283    fn test_config() -> crate::config::QuickmarkConfig {
284        test_config_with_rules(vec![("ul-style", RuleSeverity::Error)])
285    }
286
287    fn test_config_sublist() -> crate::config::QuickmarkConfig {
288        use super::{MD004UlStyleTable, UlStyle}; // Local import
289        use crate::config::{LintersSettingsTable, LintersTable, QuickmarkConfig, RuleSeverity};
290        use std::collections::HashMap;
291
292        let severity: HashMap<String, RuleSeverity> =
293            vec![("ul-style".to_string(), RuleSeverity::Error)]
294                .into_iter()
295                .collect();
296
297        QuickmarkConfig::new(LintersTable {
298            severity,
299            settings: LintersSettingsTable {
300                ul_style: MD004UlStyleTable {
301                    style: UlStyle::Sublist,
302                },
303                ..Default::default()
304            },
305        })
306    }
307
308    fn test_config_asterisk() -> crate::config::QuickmarkConfig {
309        use super::{MD004UlStyleTable, UlStyle}; // Local import
310        use crate::config::{LintersSettingsTable, LintersTable, QuickmarkConfig, RuleSeverity};
311        use std::collections::HashMap;
312
313        let severity: HashMap<String, RuleSeverity> =
314            vec![("ul-style".to_string(), RuleSeverity::Error)]
315                .into_iter()
316                .collect();
317
318        QuickmarkConfig::new(LintersTable {
319            severity,
320            settings: LintersSettingsTable {
321                ul_style: MD004UlStyleTable {
322                    style: UlStyle::Asterisk,
323                },
324                ..Default::default()
325            },
326        })
327    }
328
329    fn test_config_dash() -> crate::config::QuickmarkConfig {
330        use super::{MD004UlStyleTable, UlStyle}; // Local import
331        use crate::config::{LintersSettingsTable, LintersTable, QuickmarkConfig, RuleSeverity};
332        use std::collections::HashMap;
333
334        let severity: HashMap<String, RuleSeverity> =
335            vec![("ul-style".to_string(), RuleSeverity::Error)]
336                .into_iter()
337                .collect();
338
339        QuickmarkConfig::new(LintersTable {
340            severity,
341            settings: LintersSettingsTable {
342                ul_style: MD004UlStyleTable {
343                    style: UlStyle::Dash,
344                },
345                ..Default::default()
346            },
347        })
348    }
349
350    fn test_config_plus() -> crate::config::QuickmarkConfig {
351        use super::{MD004UlStyleTable, UlStyle}; // Local import
352        use crate::config::{LintersSettingsTable, LintersTable, QuickmarkConfig, RuleSeverity};
353        use std::collections::HashMap;
354
355        let severity: HashMap<String, RuleSeverity> =
356            vec![("ul-style".to_string(), RuleSeverity::Error)]
357                .into_iter()
358                .collect();
359
360        QuickmarkConfig::new(LintersTable {
361            severity,
362            settings: LintersSettingsTable {
363                ul_style: MD004UlStyleTable {
364                    style: UlStyle::Plus,
365                },
366                ..Default::default()
367            },
368        })
369    }
370
371    #[test]
372    fn test_consistent_asterisk_passes() {
373        let input = "* Item 1
374* Item 2
375* Item 3
376";
377
378        let config = test_config();
379        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
380        let violations = linter.analyze();
381        assert_eq!(0, violations.len());
382    }
383
384    #[test]
385    fn test_consistent_dash_passes() {
386        let input = "- Item 1
387- Item 2
388- Item 3
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!(0, violations.len());
395    }
396
397    #[test]
398    fn test_consistent_plus_passes() {
399        let input = "+ Item 1
400+ Item 2
401+ Item 3
402";
403
404        let config = test_config();
405        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
406        let violations = linter.analyze();
407        assert_eq!(0, violations.len());
408    }
409
410    #[test]
411    fn test_inconsistent_mixed_fails() {
412        let input = "* Item 1
413+ Item 2
414- Item 3
415";
416
417        let config = test_config();
418        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
419        let violations = linter.analyze();
420        // Should have violations for items 2 and 3 (inconsistent with item 1's asterisk)
421        assert_eq!(2, violations.len());
422    }
423
424    #[test]
425    fn test_asterisk_style_enforced() {
426        let input = "- Item 1
427- Item 2
428";
429
430        let config = test_config_asterisk();
431        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
432        let violations = linter.analyze();
433        // Should have violations for both items using dash instead of asterisk
434        assert_eq!(2, violations.len());
435        assert!(violations[0].message().contains("Expected: asterisk"));
436        assert!(violations[0].message().contains("Actual: dash"));
437    }
438
439    #[test]
440    fn test_dash_style_enforced() {
441        let input = "* Item 1
442+ Item 2
443* Item 3
444";
445
446        let config = test_config_dash();
447        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
448        let violations = linter.analyze();
449        // Should have violations for all items not using dash
450        assert_eq!(3, violations.len());
451        assert!(violations[0].message().contains("Expected: dash"));
452        assert!(violations[0].message().contains("Actual: asterisk"));
453    }
454
455    #[test]
456    fn test_plus_style_enforced() {
457        let input = "- Item 1
458* Item 2
459";
460
461        let config = test_config_plus();
462        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
463        let violations = linter.analyze();
464        // Should have violations for both items not using plus
465        assert_eq!(2, violations.len());
466        assert!(violations[0].message().contains("Expected: plus"));
467        assert!(violations[0].message().contains("Actual: dash"));
468    }
469
470    #[test]
471    fn test_asterisk_style_passes() {
472        let input = "* Item 1
473* Item 2
474* Item 3
475";
476
477        let config = test_config_asterisk();
478        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
479        let violations = linter.analyze();
480        assert_eq!(0, violations.len());
481    }
482
483    #[test]
484    fn test_dash_style_passes() {
485        let input = "- Item 1
486- Item 2
487- Item 3
488";
489
490        let config = test_config_dash();
491        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
492        let violations = linter.analyze();
493        assert_eq!(0, violations.len());
494    }
495
496    #[test]
497    fn test_plus_style_passes() {
498        let input = "+ Item 1
499+ Item 2
500+ Item 3
501";
502
503        let config = test_config_plus();
504        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
505        let violations = linter.analyze();
506        assert_eq!(0, violations.len());
507    }
508
509    #[test]
510    fn test_nested_lists_sublist_style() {
511        let input = "* Item 1
512  + Item 2
513    - Item 3
514  + Item 4
515* Item 5
516  + Item 6
517";
518
519        let config = test_config_sublist();
520        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
521        let violations = linter.analyze();
522        // This should be valid for sublist style - each level uses different markers
523        assert_eq!(0, violations.len());
524    }
525
526    #[test]
527    fn test_nested_lists_consistent_within_level() {
528        let input = "* Item 1
529  * Item 2  
530    * Item 3
531  * Item 4
532* Item 5
533  * Item 6
534";
535
536        let config = test_config();
537        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
538        let violations = linter.analyze();
539        assert_eq!(0, violations.len());
540    }
541
542    #[test]
543    fn test_nested_lists_inconsistent_within_level_fails() {
544        let input = "* Item 1
545  + Item 2  
546    - Item 3
547  + Item 4
548* Item 5
549  - Item 6  // This should fail - inconsistent with level 1 asterisks
550";
551
552        let config = test_config();
553        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
554        let violations = linter.analyze();
555        // In consistent mode, all non-asterisk markers should violate (4 total: 2 plus, 1 dash, 1 dash)
556        assert_eq!(4, violations.len());
557    }
558
559    #[test]
560    fn test_single_item_list() {
561        let input = "* Single item
562";
563
564        let config = test_config();
565        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
566        let violations = linter.analyze();
567        assert_eq!(0, violations.len());
568    }
569
570    #[test]
571    fn test_empty_document() {
572        let input = "";
573
574        let config = test_config();
575        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
576        let violations = linter.analyze();
577        assert_eq!(0, violations.len());
578    }
579
580    #[test]
581    fn test_lists_separated_by_content() {
582        let input = "* Item 1
583* Item 2
584
585Some paragraph text
586
587- Item 3
588- Item 4
589";
590
591        let config = test_config();
592        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
593        let violations = linter.analyze();
594        // In consistent mode, all lists in document should use same style
595        // First list uses asterisk, so second list using dash should violate
596        assert_eq!(2, violations.len());
597    }
598}