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