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