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