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