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