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