rumdl_lib/rules/
md004_unordered_list_style.rs

1use crate::LintContext;
2/// Rule MD004: Use consistent style for unordered list markers
3///
4/// See [docs/md004.md](../../docs/md004.md) for full documentation, configuration, and examples.
5///
6/// Enforces that all unordered list items in a Markdown document use the same marker style ("*", "+", or "-") or are consistent with the first marker used, depending on configuration.
7///
8/// ## Purpose
9///
10/// Ensures visual and stylistic consistency for unordered lists, making documents easier to read and maintain.
11///
12/// ## Configuration Options
13///
14/// The rule supports configuring the required marker style:
15/// ```yaml
16/// MD004:
17///   style: dash      # Options: "dash", "asterisk", "plus", or "consistent" (default)
18/// ```
19///
20/// ## Examples
21///
22/// ### Correct (with style: dash)
23/// ```markdown
24/// - Item 1
25/// - Item 2
26///   - Nested item
27/// - Item 3
28/// ```
29///
30/// ### Incorrect (with style: dash)
31/// ```markdown
32/// * Item 1
33/// - Item 2
34/// + Item 3
35/// ```
36///
37/// ## Behavior
38///
39/// - Checks each unordered list item for its marker character.
40/// - In "consistent" mode, the most prevalent marker sets the style for the document (in case of tie, prefers dash).
41/// - Skips code blocks and front matter.
42/// - Reports a warning if a list item uses a different marker than the configured or detected style.
43///
44/// ## Fix Behavior
45///
46/// - Rewrites all unordered list markers to match the configured or detected style.
47/// - Preserves indentation and content after the marker.
48///
49/// ## Rationale
50///
51/// Consistent list markers improve readability and reduce distraction, especially in large documents or when collaborating with others. This rule helps enforce a uniform style across all unordered lists.
52use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
53use toml;
54
55mod md004_config;
56use md004_config::MD004Config;
57use serde::{Deserialize, Serialize};
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum UnorderedListStyle {
62    Asterisk, // "*"
63    Plus,     // "+"
64    Dash,     // "-"
65    #[default]
66    Consistent, // Use the first marker in a file consistently
67    Sublist,  // Each nesting level uses a different marker (*, +, -, cycling)
68}
69
70/// Rule MD004: Unordered list style
71#[derive(Clone, Default)]
72pub struct MD004UnorderedListStyle {
73    config: MD004Config,
74}
75
76impl MD004UnorderedListStyle {
77    pub fn new(style: UnorderedListStyle) -> Self {
78        Self {
79            config: MD004Config { style },
80        }
81    }
82
83    pub fn from_config_struct(config: MD004Config) -> Self {
84        Self { config }
85    }
86}
87
88impl Rule for MD004UnorderedListStyle {
89    fn name(&self) -> &'static str {
90        "MD004"
91    }
92
93    fn description(&self) -> &'static str {
94        "Use consistent style for unordered list markers"
95    }
96
97    fn check(&self, ctx: &LintContext) -> LintResult {
98        // Early returns for performance
99        if ctx.content.is_empty() {
100            return Ok(Vec::new());
101        }
102
103        // Quick check for any list markers before processing
104        if !ctx.likely_has_lists() {
105            return Ok(Vec::new());
106        }
107
108        let mut warnings = Vec::new();
109
110        // For consistent mode, count occurrences of each marker (prevalence-based approach)
111        let target_marker_for_consistent = if self.config.style == UnorderedListStyle::Consistent {
112            let mut asterisk_count = 0;
113            let mut dash_count = 0;
114            let mut plus_count = 0;
115
116            for list_block in &ctx.list_blocks {
117                for &item_line in &list_block.item_lines {
118                    if let Some(line_info) = ctx.line_info(item_line)
119                        && let Some(list_item) = &line_info.list_item
120                        && !list_item.is_ordered
121                    {
122                        match list_item.marker.chars().next().unwrap() {
123                            '*' => asterisk_count += 1,
124                            '-' => dash_count += 1,
125                            '+' => plus_count += 1,
126                            _ => {}
127                        }
128                    }
129                }
130            }
131
132            // Use the most prevalent marker as the target style
133            // In case of a tie, prefer dash (most common, GitHub default)
134            if dash_count >= asterisk_count && dash_count >= plus_count {
135                Some('-')
136            } else if asterisk_count >= plus_count {
137                Some('*')
138            } else {
139                Some('+')
140            }
141        } else {
142            None
143        };
144
145        // Use centralized list blocks for better performance and accuracy
146        for list_block in &ctx.list_blocks {
147            // Check each list item in this block
148            // We need to check individual items even in mixed lists (ordered with nested unordered)
149            for &item_line in &list_block.item_lines {
150                if let Some(line_info) = ctx.line_info(item_line)
151                    && let Some(list_item) = &line_info.list_item
152                {
153                    // Skip ordered list items - we only care about unordered ones
154                    if list_item.is_ordered {
155                        continue;
156                    }
157
158                    // Get the marker character
159                    let marker = list_item.marker.chars().next().unwrap();
160
161                    // Calculate offset for the marker position
162                    let offset = line_info.byte_offset + list_item.marker_column;
163
164                    match self.config.style {
165                        UnorderedListStyle::Consistent => {
166                            // For consistent mode, check against the most prevalent marker
167                            if let Some(target) = target_marker_for_consistent
168                                && marker != target
169                            {
170                                let (line, col) = ctx.offset_to_line_col(offset);
171                                warnings.push(LintWarning {
172                                    line,
173                                    column: col,
174                                    end_line: line,
175                                    end_column: col + 1,
176                                    message: format!("List marker '{marker}' does not match expected style '{target}'"),
177                                    severity: Severity::Warning,
178                                    rule_name: Some(self.name().to_string()),
179                                    fix: Some(Fix {
180                                        range: offset..offset + 1,
181                                        replacement: target.to_string(),
182                                    }),
183                                });
184                            }
185                        }
186                        UnorderedListStyle::Sublist => {
187                            // Calculate expected marker based on indentation level
188                            // Each 2 spaces of indentation represents a nesting level
189                            let nesting_level = list_item.marker_column / 2;
190                            let expected_marker = match nesting_level % 3 {
191                                0 => '*',
192                                1 => '+',
193                                2 => '-',
194                                _ => {
195                                    // This should never happen as % 3 only returns 0, 1, or 2
196                                    // but fallback to asterisk for safety
197                                    '*'
198                                }
199                            };
200                            if marker != expected_marker {
201                                let (line, col) = ctx.offset_to_line_col(offset);
202                                warnings.push(LintWarning {
203                                        line,
204                                        column: col,
205                                        end_line: line,
206                                        end_column: col + 1,
207                                        message: format!(
208                                            "List marker '{marker}' does not match expected style '{expected_marker}' for nesting level {nesting_level}"
209                                        ),
210                                        severity: Severity::Warning,
211                                        rule_name: Some(self.name().to_string()),
212                                        fix: Some(Fix {
213                                            range: offset..offset + 1,
214                                            replacement: expected_marker.to_string(),
215                                        }),
216                                    });
217                            }
218                        }
219                        _ => {
220                            // Handle specific style requirements (asterisk, dash, plus)
221                            let target_marker = match self.config.style {
222                                UnorderedListStyle::Asterisk => '*',
223                                UnorderedListStyle::Dash => '-',
224                                UnorderedListStyle::Plus => '+',
225                                UnorderedListStyle::Consistent | UnorderedListStyle::Sublist => {
226                                    // These cases are handled separately above
227                                    // but fallback to asterisk for safety
228                                    '*'
229                                }
230                            };
231                            if marker != target_marker {
232                                let (line, col) = ctx.offset_to_line_col(offset);
233                                warnings.push(LintWarning {
234                                    line,
235                                    column: col,
236                                    end_line: line,
237                                    end_column: col + 1,
238                                    message: format!(
239                                        "List marker '{marker}' does not match expected style '{target_marker}'"
240                                    ),
241                                    severity: Severity::Warning,
242                                    rule_name: Some(self.name().to_string()),
243                                    fix: Some(Fix {
244                                        range: offset..offset + 1,
245                                        replacement: target_marker.to_string(),
246                                    }),
247                                });
248                            }
249                        }
250                    }
251                }
252            }
253        }
254
255        Ok(warnings)
256    }
257
258    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
259        let mut lines: Vec<String> = ctx.content.lines().map(String::from).collect();
260
261        // For consistent mode, count occurrences of each marker (prevalence-based approach)
262        let target_marker_for_consistent = if self.config.style == UnorderedListStyle::Consistent {
263            let mut asterisk_count = 0;
264            let mut dash_count = 0;
265            let mut plus_count = 0;
266
267            for list_block in &ctx.list_blocks {
268                for &item_line in &list_block.item_lines {
269                    if let Some(line_info) = ctx.line_info(item_line)
270                        && let Some(list_item) = &line_info.list_item
271                        && !list_item.is_ordered
272                    {
273                        match list_item.marker.chars().next().unwrap() {
274                            '*' => asterisk_count += 1,
275                            '-' => dash_count += 1,
276                            '+' => plus_count += 1,
277                            _ => {}
278                        }
279                    }
280                }
281            }
282
283            // Use the most prevalent marker as the target style
284            // In case of a tie, prefer dash (most common, GitHub default)
285            if dash_count >= asterisk_count && dash_count >= plus_count {
286                Some('-')
287            } else if asterisk_count >= plus_count {
288                Some('*')
289            } else {
290                Some('+')
291            }
292        } else {
293            None
294        };
295
296        // Use centralized list blocks
297        for list_block in &ctx.list_blocks {
298            // Process each list item in this block
299            // We need to check individual items even in mixed lists
300            for &item_line in &list_block.item_lines {
301                if let Some(line_info) = ctx.line_info(item_line)
302                    && let Some(list_item) = &line_info.list_item
303                {
304                    // Skip ordered list items - we only care about unordered ones
305                    if list_item.is_ordered {
306                        continue;
307                    }
308
309                    let line_idx = item_line - 1;
310                    if line_idx >= lines.len() {
311                        continue;
312                    }
313
314                    let line = &lines[line_idx];
315                    let marker = list_item.marker.chars().next().unwrap();
316
317                    // Determine the target marker
318                    let target_marker = match self.config.style {
319                        UnorderedListStyle::Consistent => target_marker_for_consistent.unwrap_or(marker),
320                        UnorderedListStyle::Sublist => {
321                            // Calculate expected marker based on indentation level
322                            // Each 2 spaces of indentation represents a nesting level
323                            let nesting_level = list_item.marker_column / 2;
324                            match nesting_level % 3 {
325                                0 => '*',
326                                1 => '+',
327                                2 => '-',
328                                _ => {
329                                    // This should never happen as % 3 only returns 0, 1, or 2
330                                    // but fallback to asterisk for safety
331                                    '*'
332                                }
333                            }
334                        }
335                        UnorderedListStyle::Asterisk => '*',
336                        UnorderedListStyle::Dash => '-',
337                        UnorderedListStyle::Plus => '+',
338                    };
339
340                    // Replace the marker if needed
341                    if marker != target_marker {
342                        let marker_pos = list_item.marker_column;
343                        if marker_pos < line.len() {
344                            let mut new_line = String::new();
345                            new_line.push_str(&line[..marker_pos]);
346                            new_line.push(target_marker);
347                            new_line.push_str(&line[marker_pos + 1..]);
348                            lines[line_idx] = new_line;
349                        }
350                    }
351                }
352            }
353        }
354
355        let mut result = lines.join("\n");
356        if ctx.content.ends_with('\n') {
357            result.push('\n');
358        }
359        Ok(result)
360    }
361
362    /// Get the category of this rule for selective processing
363    fn category(&self) -> RuleCategory {
364        RuleCategory::List
365    }
366
367    /// Check if this rule should be skipped
368    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
369        ctx.content.is_empty() || !ctx.likely_has_lists()
370    }
371
372    fn as_any(&self) -> &dyn std::any::Any {
373        self
374    }
375
376    fn default_config_section(&self) -> Option<(String, toml::Value)> {
377        let mut map = toml::map::Map::new();
378        map.insert(
379            "style".to_string(),
380            toml::Value::String(match self.config.style {
381                UnorderedListStyle::Asterisk => "asterisk".to_string(),
382                UnorderedListStyle::Dash => "dash".to_string(),
383                UnorderedListStyle::Plus => "plus".to_string(),
384                UnorderedListStyle::Consistent => "consistent".to_string(),
385                UnorderedListStyle::Sublist => "sublist".to_string(),
386            }),
387        );
388        Some((self.name().to_string(), toml::Value::Table(map)))
389    }
390
391    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
392    where
393        Self: Sized,
394    {
395        let style = crate::config::get_rule_config_value::<String>(config, "MD004", "style")
396            .unwrap_or_else(|| "consistent".to_string());
397        let style = match style.as_str() {
398            "asterisk" => UnorderedListStyle::Asterisk,
399            "dash" => UnorderedListStyle::Dash,
400            "plus" => UnorderedListStyle::Plus,
401            "sublist" => UnorderedListStyle::Sublist,
402            _ => UnorderedListStyle::Consistent,
403        };
404        Box::new(MD004UnorderedListStyle::new(style))
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::lint_context::LintContext;
412    use crate::rule::Rule;
413
414    #[test]
415    fn test_consistent_asterisk_style() {
416        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
417        let content = "* Item 1\n* Item 2\n  * Nested\n* Item 3";
418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
419        let result = rule.check(&ctx).unwrap();
420        assert!(result.is_empty());
421    }
422
423    #[test]
424    fn test_consistent_dash_style() {
425        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
426        let content = "- Item 1\n- Item 2\n  - Nested\n- Item 3";
427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428        let result = rule.check(&ctx).unwrap();
429        assert!(result.is_empty());
430    }
431
432    #[test]
433    fn test_consistent_plus_style() {
434        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
435        let content = "+ Item 1\n+ Item 2\n  + Nested\n+ Item 3";
436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
437        let result = rule.check(&ctx).unwrap();
438        assert!(result.is_empty());
439    }
440
441    #[test]
442    fn test_inconsistent_style_tie_prefers_dash() {
443        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
444        // All markers appear once - tie should prefer dash
445        let content = "* Item 1\n- Item 2\n+ Item 3";
446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
447        let result = rule.check(&ctx).unwrap();
448        assert_eq!(result.len(), 2);
449        // Both asterisk and plus are flagged as wrong (dash is preferred on tie)
450        assert_eq!(result[0].line, 1);
451        assert_eq!(result[1].line, 3);
452    }
453
454    #[test]
455    fn test_asterisk_style_enforced() {
456        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
457        let content = "* Item 1\n- Item 2\n+ Item 3";
458        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
459        let result = rule.check(&ctx).unwrap();
460        assert_eq!(result.len(), 2);
461        assert_eq!(result[0].message, "List marker '-' does not match expected style '*'");
462        assert_eq!(result[1].message, "List marker '+' does not match expected style '*'");
463    }
464
465    #[test]
466    fn test_dash_style_enforced() {
467        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
468        let content = "* Item 1\n- Item 2\n+ Item 3";
469        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
470        let result = rule.check(&ctx).unwrap();
471        assert_eq!(result.len(), 2);
472        assert_eq!(result[0].message, "List marker '*' does not match expected style '-'");
473        assert_eq!(result[1].message, "List marker '+' does not match expected style '-'");
474    }
475
476    #[test]
477    fn test_plus_style_enforced() {
478        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
479        let content = "* Item 1\n- Item 2\n+ Item 3";
480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
481        let result = rule.check(&ctx).unwrap();
482        assert_eq!(result.len(), 2);
483        assert_eq!(result[0].message, "List marker '*' does not match expected style '+'");
484        assert_eq!(result[1].message, "List marker '-' does not match expected style '+'");
485    }
486
487    #[test]
488    fn test_fix_consistent_style_tie_prefers_dash() {
489        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
490        // All markers appear once - tie should prefer dash
491        let content = "* Item 1\n- Item 2\n+ Item 3";
492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
493        let fixed = rule.fix(&ctx).unwrap();
494        assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
495    }
496
497    #[test]
498    fn test_fix_asterisk_style() {
499        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
500        let content = "- Item 1\n+ Item 2\n- Item 3";
501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
502        let fixed = rule.fix(&ctx).unwrap();
503        assert_eq!(fixed, "* Item 1\n* Item 2\n* Item 3");
504    }
505
506    #[test]
507    fn test_fix_dash_style() {
508        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
509        let content = "* Item 1\n+ Item 2\n* Item 3";
510        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
511        let fixed = rule.fix(&ctx).unwrap();
512        assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
513    }
514
515    #[test]
516    fn test_fix_plus_style() {
517        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
518        let content = "* Item 1\n- Item 2\n* Item 3";
519        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
520        let fixed = rule.fix(&ctx).unwrap();
521        assert_eq!(fixed, "+ Item 1\n+ Item 2\n+ Item 3");
522    }
523
524    #[test]
525    fn test_nested_lists() {
526        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
527        let content = "* Item 1\n  * Nested 1\n    * Double nested\n  - Wrong marker\n* Item 2";
528        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
529        let result = rule.check(&ctx).unwrap();
530        assert_eq!(result.len(), 1);
531        assert_eq!(result[0].line, 4);
532    }
533
534    #[test]
535    fn test_fix_nested_lists() {
536        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
537        // * appears 2 times, - appears 2 times, + appears 1 time
538        // Tie between * and - should prefer dash
539        let content = "* Item 1\n  - Nested 1\n    + Double nested\n  - Nested 2\n* Item 2";
540        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
541        let fixed = rule.fix(&ctx).unwrap();
542        assert_eq!(
543            fixed,
544            "- Item 1\n  - Nested 1\n    - Double nested\n  - Nested 2\n- Item 2"
545        );
546    }
547
548    #[test]
549    fn test_with_code_blocks() {
550        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
551        let content = "* Item 1\n\n```\n- This is in code\n+ Not a list\n```\n\n- Item 2";
552        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
553        let result = rule.check(&ctx).unwrap();
554        assert_eq!(result.len(), 1);
555        assert_eq!(result[0].line, 8);
556    }
557
558    #[test]
559    fn test_with_blockquotes() {
560        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
561        let content = "> * Item 1\n> - Item 2\n\n* Regular item\n+ Different marker";
562        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
563        let result = rule.check(&ctx).unwrap();
564        // Should detect inconsistencies both in blockquote and regular content
565        assert!(result.len() >= 2);
566    }
567
568    #[test]
569    fn test_empty_document() {
570        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
571        let content = "";
572        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
573        let result = rule.check(&ctx).unwrap();
574        assert!(result.is_empty());
575    }
576
577    #[test]
578    fn test_no_lists() {
579        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
580        let content = "This is a paragraph.\n\nAnother paragraph.";
581        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
582        let result = rule.check(&ctx).unwrap();
583        assert!(result.is_empty());
584    }
585
586    #[test]
587    fn test_ordered_lists_ignored() {
588        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
589        let content = "1. Item 1\n2. Item 2\n   1. Nested\n3. Item 3";
590        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
591        let result = rule.check(&ctx).unwrap();
592        assert!(result.is_empty());
593    }
594
595    #[test]
596    fn test_mixed_ordered_unordered() {
597        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
598        let content = "1. Ordered\n   * Unordered nested\n   - Wrong marker\n2. Another ordered";
599        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
600        let result = rule.check(&ctx).unwrap();
601        assert_eq!(result.len(), 1);
602        assert_eq!(result[0].line, 3);
603    }
604
605    #[test]
606    fn test_fix_preserves_content() {
607        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
608        let content = "* Item with **bold** and *italic*\n+ Item with `code`\n* Item with [link](url)";
609        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
610        let fixed = rule.fix(&ctx).unwrap();
611        assert_eq!(
612            fixed,
613            "- Item with **bold** and *italic*\n- Item with `code`\n- Item with [link](url)"
614        );
615    }
616
617    #[test]
618    fn test_fix_preserves_indentation() {
619        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
620        let content = "  - Indented item\n    + Nested item\n  - Another indented";
621        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
622        let fixed = rule.fix(&ctx).unwrap();
623        assert_eq!(fixed, "  * Indented item\n    * Nested item\n  * Another indented");
624    }
625
626    #[test]
627    fn test_multiple_spaces_after_marker() {
628        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
629        // All markers appear once - tie should prefer dash
630        let content = "*   Item 1\n-   Item 2\n+   Item 3";
631        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
632        let result = rule.check(&ctx).unwrap();
633        assert_eq!(result.len(), 2);
634        let fixed = rule.fix(&ctx).unwrap();
635        assert_eq!(fixed, "-   Item 1\n-   Item 2\n-   Item 3");
636    }
637
638    #[test]
639    fn test_tab_after_marker() {
640        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
641        // Both markers appear once - tie should prefer dash
642        let content = "*\tItem 1\n-\tItem 2";
643        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
644        let result = rule.check(&ctx).unwrap();
645        assert_eq!(result.len(), 1);
646        let fixed = rule.fix(&ctx).unwrap();
647        assert_eq!(fixed, "-\tItem 1\n-\tItem 2");
648    }
649
650    #[test]
651    fn test_edge_case_marker_at_end() {
652        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
653        // These are valid list items with minimal content (just a space)
654        let content = "* \n- \n+ ";
655        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
656        let result = rule.check(&ctx).unwrap();
657        assert_eq!(result.len(), 2); // Should flag - and + as wrong markers
658        let fixed = rule.fix(&ctx).unwrap();
659        assert_eq!(fixed, "* \n* \n* ");
660    }
661
662    #[test]
663    fn test_from_config() {
664        let mut config = crate::config::Config::default();
665        let mut rule_config = crate::config::RuleConfig::default();
666        rule_config
667            .values
668            .insert("style".to_string(), toml::Value::String("plus".to_string()));
669        config.rules.insert("MD004".to_string(), rule_config);
670
671        let rule = MD004UnorderedListStyle::from_config(&config);
672        let content = "* Item 1\n- Item 2";
673        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
674        let result = rule.check(&ctx).unwrap();
675        assert_eq!(result.len(), 2);
676    }
677
678    #[test]
679    fn test_default_config_section() {
680        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
681        let config = rule.default_config_section();
682        assert!(config.is_some());
683        let (name, value) = config.unwrap();
684        assert_eq!(name, "MD004");
685        if let toml::Value::Table(table) = value {
686            assert_eq!(table.get("style").and_then(|v| v.as_str()), Some("dash"));
687        } else {
688            panic!("Expected table");
689        }
690    }
691
692    #[test]
693    fn test_sublist_style() {
694        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
695        // Level 0 should use *, level 1 should use +, level 2 should use -
696        let content = "* Item 1\n  + Item 2\n    - Item 3\n      * Item 4\n  + Item 5";
697        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
698        let result = rule.check(&ctx).unwrap();
699        assert!(result.is_empty(), "Sublist style should accept cycling markers");
700    }
701
702    #[test]
703    fn test_sublist_style_incorrect() {
704        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
705        // Wrong markers for each level
706        let content = "- Item 1\n  * Item 2\n    + Item 3";
707        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
708        let result = rule.check(&ctx).unwrap();
709        assert_eq!(result.len(), 3);
710        assert_eq!(
711            result[0].message,
712            "List marker '-' does not match expected style '*' for nesting level 0"
713        );
714        assert_eq!(
715            result[1].message,
716            "List marker '*' does not match expected style '+' for nesting level 1"
717        );
718        assert_eq!(
719            result[2].message,
720            "List marker '+' does not match expected style '-' for nesting level 2"
721        );
722    }
723
724    #[test]
725    fn test_fix_sublist_style() {
726        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
727        let content = "- Item 1\n  - Item 2\n    - Item 3\n      - Item 4";
728        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
729        let fixed = rule.fix(&ctx).unwrap();
730        assert_eq!(fixed, "* Item 1\n  + Item 2\n    - Item 3\n      * Item 4");
731    }
732
733    #[test]
734    fn test_performance_large_document() {
735        let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
736        let mut content = String::new();
737        for i in 0..1000 {
738            content.push_str(&format!(
739                "{}Item {}\n",
740                if i % 3 == 0 {
741                    "* "
742                } else if i % 3 == 1 {
743                    "- "
744                } else {
745                    "+ "
746                },
747                i
748            ));
749        }
750        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
751        let result = rule.check(&ctx).unwrap();
752        // Should detect all non-asterisk markers
753        assert!(result.len() > 600);
754    }
755}