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