Skip to main content

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