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