rumdl_lib/rules/
md007_ul_indent.rs

1/// Rule MD007: Unordered list indentation
2///
3/// See [docs/md007.md](../../docs/md007.md) for full documentation, configuration, and examples.
4use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6
7mod md007_config;
8use md007_config::MD007Config;
9
10#[derive(Debug, Clone, Default)]
11pub struct MD007ULIndent {
12    config: MD007Config,
13}
14
15impl MD007ULIndent {
16    pub fn new(indent: usize) -> Self {
17        Self {
18            config: MD007Config {
19                indent: crate::types::IndentSize::from_const(indent as u8),
20                start_indented: false,
21                start_indent: crate::types::IndentSize::from_const(2),
22                style: md007_config::IndentStyle::TextAligned,
23                style_explicit: false, // Allow auto-detection for programmatic construction
24            },
25        }
26    }
27
28    pub fn from_config_struct(config: MD007Config) -> Self {
29        Self { config }
30    }
31
32    /// Convert character position to visual column (accounting for tabs)
33    fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
34        let mut visual_col = 0;
35
36        for (current_pos, ch) in content.chars().enumerate() {
37            if current_pos >= char_pos {
38                break;
39            }
40            if ch == '\t' {
41                // Tab moves to next multiple of 4
42                visual_col = (visual_col / 4 + 1) * 4;
43            } else {
44                visual_col += 1;
45            }
46        }
47        visual_col
48    }
49
50    /// Detect if the document has mixed ordered/unordered list nesting.
51    /// This is used for smart style selection: fixed style causes oscillation
52    /// with mixed lists, so we only auto-switch to fixed for pure unordered lists.
53    fn has_mixed_list_nesting(ctx: &crate::lint_context::LintContext) -> bool {
54        // Track parent list items by their marker position and type
55        // Using marker_column instead of indent because it works correctly
56        // for blockquoted content where indent doesn't account for the prefix
57        // Stack stores: (marker_column, is_ordered)
58        let mut stack: Vec<(usize, bool)> = Vec::new();
59        let mut last_was_blank = false;
60
61        for line_info in &ctx.lines {
62            // Skip non-content lines (code blocks, frontmatter, HTML comments, etc.)
63            if line_info.in_code_block
64                || line_info.in_front_matter
65                || line_info.in_mkdocstrings
66                || line_info.in_html_comment
67                || line_info.in_esm_block
68            {
69                continue;
70            }
71
72            // Check for blank lines to detect list separation
73            let content = line_info.content(ctx.content);
74            let is_blank = content.trim().is_empty();
75
76            if is_blank {
77                last_was_blank = true;
78                continue;
79            }
80
81            if let Some(list_item) = &line_info.list_item {
82                // Normalize column 1 to column 0 (consistent with check function)
83                let current_pos = if list_item.marker_column == 1 {
84                    0
85                } else {
86                    list_item.marker_column
87                };
88
89                // If there was a blank line and this item is at root level, reset stack
90                if last_was_blank && current_pos == 0 {
91                    stack.clear();
92                }
93                last_was_blank = false;
94
95                // Pop items at same or greater position (they're siblings or deeper, not parents)
96                while let Some(&(pos, _)) = stack.last() {
97                    if pos >= current_pos {
98                        stack.pop();
99                    } else {
100                        break;
101                    }
102                }
103
104                // Check if immediate parent has different type - this is mixed nesting
105                if let Some(&(_, parent_is_ordered)) = stack.last()
106                    && parent_is_ordered != list_item.is_ordered
107                {
108                    return true; // Found mixed nesting
109                }
110
111                stack.push((current_pos, list_item.is_ordered));
112            } else {
113                // Non-list line (but not blank) - could be paragraph or other content
114                last_was_blank = false;
115            }
116        }
117
118        false
119    }
120
121    /// Determine the effective style to use for this check.
122    /// When style is not explicitly set and indent != 2, we auto-select:
123    /// - Pure unordered lists → fixed style (markdownlint compatible)
124    /// - Mixed ordered/unordered → text-aligned (avoids oscillation)
125    fn effective_style(&self, ctx: &crate::lint_context::LintContext) -> md007_config::IndentStyle {
126        // If style was explicitly set, always use it
127        if self.config.style_explicit {
128            return self.config.style;
129        }
130
131        // If indent is the default (2), no need for auto-switch
132        // (text-aligned and fixed produce same results with indent=2)
133        if self.config.indent.get() == 2 {
134            return self.config.style;
135        }
136
137        // Auto-select: fixed for pure unordered lists, text-aligned for mixed
138        if Self::has_mixed_list_nesting(ctx) {
139            md007_config::IndentStyle::TextAligned
140        } else {
141            md007_config::IndentStyle::Fixed
142        }
143    }
144}
145
146impl Rule for MD007ULIndent {
147    fn name(&self) -> &'static str {
148        "MD007"
149    }
150
151    fn description(&self) -> &'static str {
152        "Unordered list indentation"
153    }
154
155    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
156        let mut warnings = Vec::new();
157        let mut list_stack: Vec<(usize, usize, bool, usize)> = Vec::new(); // Stack of (marker_visual_col, line_num, is_ordered, content_visual_col) for tracking nesting
158
159        // Determine effective style: auto-select based on document content when appropriate
160        let effective_style = self.effective_style(ctx);
161
162        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
163            // Skip if this line is in a code block, front matter, or mkdocstrings
164            if line_info.in_code_block || line_info.in_front_matter || line_info.in_mkdocstrings {
165                continue;
166            }
167
168            // Check if this line has a list item
169            if let Some(list_item) = &line_info.list_item {
170                // For blockquoted lists, we need to calculate indentation relative to the blockquote content
171                // not the full line. This is because blockquoted lists follow the same indentation rules
172                // as regular lists, just within their blockquote context.
173                let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
174                    // Find the position after ALL blockquote prefixes (handles nested > > > etc)
175                    let line_content = line_info.content(ctx.content);
176                    let mut remaining = line_content;
177                    let mut content_start = 0;
178
179                    loop {
180                        let trimmed = remaining.trim_start();
181                        if !trimmed.starts_with('>') {
182                            break;
183                        }
184                        // Account for leading whitespace
185                        content_start += remaining.len() - trimmed.len();
186                        // Account for '>'
187                        content_start += 1;
188                        let after_gt = &trimmed[1..];
189                        // Handle optional whitespace after '>' (space or tab)
190                        if let Some(stripped) = after_gt.strip_prefix(' ') {
191                            content_start += 1;
192                            remaining = stripped;
193                        } else if let Some(stripped) = after_gt.strip_prefix('\t') {
194                            content_start += 1;
195                            remaining = stripped;
196                        } else {
197                            remaining = after_gt;
198                        }
199                    }
200
201                    // Extract the content after the blockquote prefix
202                    let content_after_prefix = &line_content[content_start..];
203                    // Adjust the marker column to be relative to the content after the prefix
204                    let adjusted_col = if list_item.marker_column >= content_start {
205                        list_item.marker_column - content_start
206                    } else {
207                        // This shouldn't happen, but handle it gracefully
208                        list_item.marker_column
209                    };
210                    (content_after_prefix.to_string(), adjusted_col)
211                } else {
212                    (line_info.content(ctx.content).to_string(), list_item.marker_column)
213                };
214
215                // Convert marker position to visual column
216                let visual_marker_column =
217                    Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
218
219                // Calculate content visual column for text-aligned style
220                let visual_content_column = if line_info.blockquote.is_some() {
221                    // For blockquoted content, we already have the adjusted content
222                    let adjusted_content_col =
223                        if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
224                            list_item.content_column - (line_info.byte_len - content_for_calculation.len())
225                        } else {
226                            list_item.content_column
227                        };
228                    Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
229                } else {
230                    Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
231                };
232
233                // For nesting detection, treat 1-space indent as if it's at column 0
234                // because 1 space is insufficient to establish a nesting relationship
235                let visual_marker_for_nesting = if visual_marker_column == 1 {
236                    0
237                } else {
238                    visual_marker_column
239                };
240
241                // Clean up stack - remove items at same or deeper indentation
242                while let Some(&(indent, _, _, _)) = list_stack.last() {
243                    if indent >= visual_marker_for_nesting {
244                        list_stack.pop();
245                    } else {
246                        break;
247                    }
248                }
249
250                // For ordered list items, just track them in the stack
251                if list_item.is_ordered {
252                    // For ordered lists, we don't check indentation but we need to track for text-aligned children
253                    // Use the actual positions since we don't enforce indentation for ordered lists
254                    list_stack.push((visual_marker_column, line_idx, true, visual_content_column));
255                    continue;
256                }
257
258                // At this point, we know this is an unordered list item
259                // Now stack contains only parent items
260                let nesting_level = list_stack.len();
261
262                // Calculate expected indent first to determine expected content position
263                let expected_indent = if self.config.start_indented {
264                    self.config.start_indent.get() as usize + (nesting_level * self.config.indent.get() as usize)
265                } else {
266                    match effective_style {
267                        md007_config::IndentStyle::Fixed => {
268                            // Fixed style: simple multiples of indent
269                            nesting_level * self.config.indent.get() as usize
270                        }
271                        md007_config::IndentStyle::TextAligned => {
272                            // Text-aligned style: child's marker aligns with parent's text content
273                            if nesting_level > 0 {
274                                // Check if parent is an ordered list
275                                if let Some(&(_, _parent_line_idx, _is_ordered, parent_content_visual_col)) =
276                                    list_stack.get(nesting_level - 1)
277                                {
278                                    // Child marker is positioned where parent's text starts
279                                    parent_content_visual_col
280                                } else {
281                                    // No parent at that level - for text-aligned, use standard alignment
282                                    // Each level aligns with previous level's text position
283                                    nesting_level * 2
284                                }
285                            } else {
286                                0 // First level, no indentation needed
287                            }
288                        }
289                    }
290                };
291
292                // Add current item to stack
293                // Use actual marker position for cleanup logic
294                // For text-aligned children, store the EXPECTED content position after fix
295                // (not the actual position) to prevent error cascade
296                let expected_content_visual_col = expected_indent + 2; // where content SHOULD be after fix
297                list_stack.push((visual_marker_column, line_idx, false, expected_content_visual_col));
298
299                // Skip first level check if start_indented is false
300                // BUT always check items with 1 space indent (insufficient for nesting)
301                if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
302                    continue;
303                }
304
305                if visual_marker_column != expected_indent {
306                    // Generate fix for this list item
307                    let fix = {
308                        let correct_indent = " ".repeat(expected_indent);
309
310                        // Build the replacement string - need to preserve everything before the list marker
311                        // For blockquoted lines, this includes the blockquote prefix
312                        let replacement = if line_info.blockquote.is_some() {
313                            // Count the blockquote markers
314                            let mut blockquote_count = 0;
315                            for ch in line_info.content(ctx.content).chars() {
316                                if ch == '>' {
317                                    blockquote_count += 1;
318                                } else if ch != ' ' && ch != '\t' {
319                                    break;
320                                }
321                            }
322                            // Build the blockquote prefix (one '>' per level, with spaces between for nested)
323                            let blockquote_prefix = if blockquote_count > 1 {
324                                (0..blockquote_count)
325                                    .map(|_| "> ")
326                                    .collect::<String>()
327                                    .trim_end()
328                                    .to_string()
329                            } else {
330                                ">".to_string()
331                            };
332                            // Add correct indentation after the blockquote prefix
333                            // Include one space after the blockquote marker(s) as part of the indent
334                            format!("{blockquote_prefix} {correct_indent}")
335                        } else {
336                            correct_indent
337                        };
338
339                        // Calculate the byte positions
340                        // The range should cover from start of line to the marker position
341                        let start_byte = line_info.byte_offset;
342                        let mut end_byte = line_info.byte_offset;
343
344                        // Calculate where the marker starts
345                        for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
346                            if i >= list_item.marker_column {
347                                break;
348                            }
349                            end_byte += ch.len_utf8();
350                        }
351
352                        Some(crate::rule::Fix {
353                            range: start_byte..end_byte,
354                            replacement,
355                        })
356                    };
357
358                    warnings.push(LintWarning {
359                        rule_name: Some(self.name().to_string()),
360                        message: format!(
361                            "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
362                        ),
363                        line: line_idx + 1, // Convert to 1-indexed
364                        column: 1,          // Start of line
365                        end_line: line_idx + 1,
366                        end_column: visual_marker_column + 1, // End of visual indentation
367                        severity: Severity::Warning,
368                        fix,
369                    });
370                }
371            }
372        }
373        Ok(warnings)
374    }
375
376    /// Optimized check using document structure
377    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
378        // Get all warnings with their fixes
379        let warnings = self.check(ctx)?;
380
381        // If no warnings, return original content
382        if warnings.is_empty() {
383            return Ok(ctx.content.to_string());
384        }
385
386        // Collect all fixes and sort by range start (descending) to apply from end to beginning
387        let mut fixes: Vec<_> = warnings
388            .iter()
389            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
390            .collect();
391        fixes.sort_by(|a, b| b.0.cmp(&a.0));
392
393        // Apply fixes from end to beginning to preserve byte offsets
394        let mut result = ctx.content.to_string();
395        for (start, end, replacement) in fixes {
396            if start < result.len() && end <= result.len() && start <= end {
397                result.replace_range(start..end, replacement);
398            }
399        }
400
401        Ok(result)
402    }
403
404    /// Get the category of this rule for selective processing
405    fn category(&self) -> RuleCategory {
406        RuleCategory::List
407    }
408
409    /// Check if this rule should be skipped
410    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
411        // Fast path: check if document likely has lists
412        if ctx.content.is_empty() || !ctx.likely_has_lists() {
413            return true;
414        }
415        // Verify unordered list items actually exist
416        !ctx.lines
417            .iter()
418            .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
419    }
420
421    fn as_any(&self) -> &dyn std::any::Any {
422        self
423    }
424
425    fn default_config_section(&self) -> Option<(String, toml::Value)> {
426        let default_config = MD007Config::default();
427        let json_value = serde_json::to_value(&default_config).ok()?;
428        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
429
430        if let toml::Value::Table(table) = toml_value {
431            if !table.is_empty() {
432                Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
433            } else {
434                None
435            }
436        } else {
437            None
438        }
439    }
440
441    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
442    where
443        Self: Sized,
444    {
445        let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
446
447        // Check if style was explicitly set in the config
448        // This is used for smart auto-detection: when style is not explicit and indent != 2,
449        // we select style based on document content to provide markdownlint compatibility
450        // for pure unordered lists while avoiding oscillation for mixed lists
451        if let Some(rule_cfg) = config.rules.get("MD007") {
452            rule_config.style_explicit = rule_cfg.values.contains_key("style");
453        }
454
455        Box::new(Self::from_config_struct(rule_config))
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::lint_context::LintContext;
463    use crate::rule::Rule;
464
465    #[test]
466    fn test_valid_list_indent() {
467        let rule = MD007ULIndent::default();
468        let content = "* Item 1\n  * Item 2\n    * Item 3";
469        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470        let result = rule.check(&ctx).unwrap();
471        assert!(
472            result.is_empty(),
473            "Expected no warnings for valid indentation, but got {} warnings",
474            result.len()
475        );
476    }
477
478    #[test]
479    fn test_invalid_list_indent() {
480        let rule = MD007ULIndent::default();
481        let content = "* Item 1\n   * Item 2\n      * Item 3";
482        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483        let result = rule.check(&ctx).unwrap();
484        assert_eq!(result.len(), 2);
485        assert_eq!(result[0].line, 2);
486        assert_eq!(result[0].column, 1);
487        assert_eq!(result[1].line, 3);
488        assert_eq!(result[1].column, 1);
489    }
490
491    #[test]
492    fn test_mixed_indentation() {
493        let rule = MD007ULIndent::default();
494        let content = "* Item 1\n  * Item 2\n   * Item 3\n  * Item 4";
495        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496        let result = rule.check(&ctx).unwrap();
497        assert_eq!(result.len(), 1);
498        assert_eq!(result[0].line, 3);
499        assert_eq!(result[0].column, 1);
500    }
501
502    #[test]
503    fn test_fix_indentation() {
504        let rule = MD007ULIndent::default();
505        let content = "* Item 1\n   * Item 2\n      * Item 3";
506        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507        let result = rule.fix(&ctx).unwrap();
508        // With text-aligned style and non-cascade:
509        // Item 2 aligns with Item 1's text (2 spaces)
510        // Item 3 aligns with Item 2's expected text position (4 spaces)
511        let expected = "* Item 1\n  * Item 2\n    * Item 3";
512        assert_eq!(result, expected);
513    }
514
515    #[test]
516    fn test_md007_in_yaml_code_block() {
517        let rule = MD007ULIndent::default();
518        let content = r#"```yaml
519repos:
520-   repo: https://github.com/rvben/rumdl
521    rev: v0.5.0
522    hooks:
523    -   id: rumdl-check
524```"#;
525        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526        let result = rule.check(&ctx).unwrap();
527        assert!(
528            result.is_empty(),
529            "MD007 should not trigger inside a code block, but got warnings: {result:?}"
530        );
531    }
532
533    #[test]
534    fn test_blockquoted_list_indent() {
535        let rule = MD007ULIndent::default();
536        let content = "> * Item 1\n>   * Item 2\n>     * Item 3";
537        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538        let result = rule.check(&ctx).unwrap();
539        assert!(
540            result.is_empty(),
541            "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
542        );
543    }
544
545    #[test]
546    fn test_blockquoted_list_invalid_indent() {
547        let rule = MD007ULIndent::default();
548        let content = "> * Item 1\n>    * Item 2\n>       * Item 3";
549        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550        let result = rule.check(&ctx).unwrap();
551        assert_eq!(
552            result.len(),
553            2,
554            "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
555        );
556        assert_eq!(result[0].line, 2);
557        assert_eq!(result[1].line, 3);
558    }
559
560    #[test]
561    fn test_nested_blockquote_list_indent() {
562        let rule = MD007ULIndent::default();
563        let content = "> > * Item 1\n> >   * Item 2\n> >     * Item 3";
564        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565        let result = rule.check(&ctx).unwrap();
566        assert!(
567            result.is_empty(),
568            "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
569        );
570    }
571
572    #[test]
573    fn test_blockquote_list_with_code_block() {
574        let rule = MD007ULIndent::default();
575        let content = "> * Item 1\n>   * Item 2\n>   ```\n>   code\n>   ```\n>   * Item 3";
576        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577        let result = rule.check(&ctx).unwrap();
578        assert!(
579            result.is_empty(),
580            "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
581        );
582    }
583
584    #[test]
585    fn test_properly_indented_lists() {
586        let rule = MD007ULIndent::default();
587
588        // Test various properly indented lists
589        let test_cases = vec![
590            "* Item 1\n* Item 2",
591            "* Item 1\n  * Item 1.1\n    * Item 1.1.1",
592            "- Item 1\n  - Item 1.1",
593            "+ Item 1\n  + Item 1.1",
594            "* Item 1\n  * Item 1.1\n* Item 2\n  * Item 2.1",
595        ];
596
597        for content in test_cases {
598            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599            let result = rule.check(&ctx).unwrap();
600            assert!(
601                result.is_empty(),
602                "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
603                content,
604                result.len()
605            );
606        }
607    }
608
609    #[test]
610    fn test_under_indented_lists() {
611        let rule = MD007ULIndent::default();
612
613        let test_cases = vec![
614            ("* Item 1\n * Item 1.1", 1, 2),                   // Expected 2 spaces, got 1
615            ("* Item 1\n  * Item 1.1\n   * Item 1.1.1", 1, 3), // Expected 4 spaces, got 3
616        ];
617
618        for (content, expected_warnings, line) in test_cases {
619            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620            let result = rule.check(&ctx).unwrap();
621            assert_eq!(
622                result.len(),
623                expected_warnings,
624                "Expected {expected_warnings} warnings for under-indented list:\n{content}"
625            );
626            if expected_warnings > 0 {
627                assert_eq!(result[0].line, line);
628            }
629        }
630    }
631
632    #[test]
633    fn test_over_indented_lists() {
634        let rule = MD007ULIndent::default();
635
636        let test_cases = vec![
637            ("* Item 1\n   * Item 1.1", 1, 2),                   // Expected 2 spaces, got 3
638            ("* Item 1\n    * Item 1.1", 1, 2),                  // Expected 2 spaces, got 4
639            ("* Item 1\n  * Item 1.1\n     * Item 1.1.1", 1, 3), // Expected 4 spaces, got 5
640        ];
641
642        for (content, expected_warnings, line) in test_cases {
643            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644            let result = rule.check(&ctx).unwrap();
645            assert_eq!(
646                result.len(),
647                expected_warnings,
648                "Expected {expected_warnings} warnings for over-indented list:\n{content}"
649            );
650            if expected_warnings > 0 {
651                assert_eq!(result[0].line, line);
652            }
653        }
654    }
655
656    #[test]
657    fn test_custom_indent_2_spaces() {
658        let rule = MD007ULIndent::new(2); // Default
659        let content = "* Item 1\n  * Item 2\n    * Item 3";
660        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661        let result = rule.check(&ctx).unwrap();
662        assert!(result.is_empty());
663    }
664
665    #[test]
666    fn test_custom_indent_3_spaces() {
667        // With smart auto-detection, pure unordered lists with indent=3 use fixed style
668        // This provides markdownlint compatibility for the common case
669        let rule = MD007ULIndent::new(3);
670
671        // Fixed style with indent=3: level 0 = 0, level 1 = 3, level 2 = 6
672        let correct_content = "* Item 1\n   * Item 2\n      * Item 3";
673        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
674        let result = rule.check(&ctx).unwrap();
675        assert!(
676            result.is_empty(),
677            "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
678        );
679
680        // Wrong indentation (text-aligned style spacing)
681        let wrong_content = "* Item 1\n  * Item 2\n    * Item 3";
682        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
683        let result = rule.check(&ctx).unwrap();
684        assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
685    }
686
687    #[test]
688    fn test_custom_indent_4_spaces() {
689        // With smart auto-detection, pure unordered lists with indent=4 use fixed style
690        // This provides markdownlint compatibility (fixes issue #210)
691        let rule = MD007ULIndent::new(4);
692
693        // Fixed style with indent=4: level 0 = 0, level 1 = 4, level 2 = 8
694        let correct_content = "* Item 1\n    * Item 2\n        * Item 3";
695        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
696        let result = rule.check(&ctx).unwrap();
697        assert!(
698            result.is_empty(),
699            "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
700        );
701
702        // Wrong indentation (text-aligned style spacing)
703        let wrong_content = "* Item 1\n  * Item 2\n    * Item 3";
704        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
705        let result = rule.check(&ctx).unwrap();
706        assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
707    }
708
709    #[test]
710    fn test_tab_indentation() {
711        let rule = MD007ULIndent::default();
712
713        // Single tab
714        let content = "* Item 1\n\t* Item 2";
715        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716        let result = rule.check(&ctx).unwrap();
717        assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
718
719        // Fix should convert tab to spaces
720        let fixed = rule.fix(&ctx).unwrap();
721        assert_eq!(fixed, "* Item 1\n  * Item 2");
722
723        // Multiple tabs
724        let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
725        let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
726        let fixed = rule.fix(&ctx).unwrap();
727        // With non-cascade: Item 2 at 2 spaces, content at 4
728        // Item 3 aligns with Item 2's expected content at 4 spaces
729        assert_eq!(fixed, "* Item 1\n  * Item 2\n    * Item 3");
730
731        // Mixed tabs and spaces
732        let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
733        let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
734        let fixed = rule.fix(&ctx).unwrap();
735        // With non-cascade: Item 2 at 2 spaces, content at 4
736        // Item 3 aligns with Item 2's expected content at 4 spaces
737        assert_eq!(fixed, "* Item 1\n  * Item 2\n    * Item 3");
738    }
739
740    #[test]
741    fn test_mixed_ordered_unordered_lists() {
742        let rule = MD007ULIndent::default();
743
744        // MD007 only checks unordered lists, so ordered lists should be ignored
745        // Note: 3 spaces is now correct for bullets under ordered items
746        let content = r#"1. Ordered item
747   * Unordered sub-item (correct - 3 spaces under ordered)
748   2. Ordered sub-item
749* Unordered item
750  1. Ordered sub-item
751  * Unordered sub-item"#;
752
753        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
754        let result = rule.check(&ctx).unwrap();
755        assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
756
757        // No fix needed as all indentation is correct
758        let fixed = rule.fix(&ctx).unwrap();
759        assert_eq!(fixed, content);
760    }
761
762    #[test]
763    fn test_list_markers_variety() {
764        let rule = MD007ULIndent::default();
765
766        // Test all three unordered list markers
767        let content = r#"* Asterisk
768  * Nested asterisk
769- Hyphen
770  - Nested hyphen
771+ Plus
772  + Nested plus"#;
773
774        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775        let result = rule.check(&ctx).unwrap();
776        assert!(
777            result.is_empty(),
778            "All unordered list markers should work with proper indentation"
779        );
780
781        // Test with wrong indentation for each marker type
782        let wrong_content = r#"* Asterisk
783   * Wrong asterisk
784- Hyphen
785 - Wrong hyphen
786+ Plus
787    + Wrong plus"#;
788
789        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
790        let result = rule.check(&ctx).unwrap();
791        assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
792    }
793
794    #[test]
795    fn test_empty_list_items() {
796        let rule = MD007ULIndent::default();
797        let content = "* Item 1\n* \n  * Item 2";
798        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799        let result = rule.check(&ctx).unwrap();
800        assert!(
801            result.is_empty(),
802            "Empty list items should not affect indentation checks"
803        );
804    }
805
806    #[test]
807    fn test_list_with_code_blocks() {
808        let rule = MD007ULIndent::default();
809        let content = r#"* Item 1
810  ```
811  code
812  ```
813  * Item 2
814    * Item 3"#;
815        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816        let result = rule.check(&ctx).unwrap();
817        assert!(result.is_empty());
818    }
819
820    #[test]
821    fn test_list_in_front_matter() {
822        let rule = MD007ULIndent::default();
823        let content = r#"---
824tags:
825  - tag1
826  - tag2
827---
828* Item 1
829  * Item 2"#;
830        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831        let result = rule.check(&ctx).unwrap();
832        assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
833    }
834
835    #[test]
836    fn test_fix_preserves_content() {
837        let rule = MD007ULIndent::default();
838        let content = "* Item 1 with **bold** and *italic*\n   * Item 2 with `code`\n     * Item 3 with [link](url)";
839        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840        let fixed = rule.fix(&ctx).unwrap();
841        // With non-cascade: Item 2 at 2 spaces, content at 4
842        // Item 3 aligns with Item 2's expected content at 4 spaces
843        let expected = "* Item 1 with **bold** and *italic*\n  * Item 2 with `code`\n    * Item 3 with [link](url)";
844        assert_eq!(fixed, expected, "Fix should only change indentation, not content");
845    }
846
847    #[test]
848    fn test_start_indented_config() {
849        let config = MD007Config {
850            start_indented: true,
851            start_indent: crate::types::IndentSize::from_const(4),
852            indent: crate::types::IndentSize::from_const(2),
853            style: md007_config::IndentStyle::TextAligned,
854            style_explicit: true, // Explicit style for this test
855        };
856        let rule = MD007ULIndent::from_config_struct(config);
857
858        // First level should be indented by start_indent (4 spaces)
859        // Level 0: 4 spaces (start_indent)
860        // Level 1: 6 spaces (start_indent + indent = 4 + 2)
861        // Level 2: 8 spaces (start_indent + 2*indent = 4 + 4)
862        let content = "    * Item 1\n      * Item 2\n        * Item 3";
863        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864        let result = rule.check(&ctx).unwrap();
865        assert!(result.is_empty(), "Expected no warnings with start_indented config");
866
867        // Wrong first level indentation
868        let wrong_content = "  * Item 1\n    * Item 2";
869        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
870        let result = rule.check(&ctx).unwrap();
871        assert_eq!(result.len(), 2);
872        assert_eq!(result[0].line, 1);
873        assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
874        assert_eq!(result[1].line, 2);
875        assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
876
877        // Fix should correct to start_indent for first level
878        let fixed = rule.fix(&ctx).unwrap();
879        assert_eq!(fixed, "    * Item 1\n      * Item 2");
880    }
881
882    #[test]
883    fn test_start_indented_false_allows_any_first_level() {
884        let rule = MD007ULIndent::default(); // start_indented is false by default
885
886        // When start_indented is false, first level items at any indentation are allowed
887        let content = "   * Item 1"; // First level at 3 spaces
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889        let result = rule.check(&ctx).unwrap();
890        assert!(
891            result.is_empty(),
892            "First level at any indentation should be allowed when start_indented is false"
893        );
894
895        // Multiple first level items at different indentations should all be allowed
896        let content = "* Item 1\n  * Item 2\n    * Item 3"; // All at level 0 (different indents)
897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898        let result = rule.check(&ctx).unwrap();
899        assert!(
900            result.is_empty(),
901            "All first-level items should be allowed at any indentation"
902        );
903    }
904
905    #[test]
906    fn test_deeply_nested_lists() {
907        let rule = MD007ULIndent::default();
908        let content = r#"* L1
909  * L2
910    * L3
911      * L4
912        * L5
913          * L6"#;
914        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915        let result = rule.check(&ctx).unwrap();
916        assert!(result.is_empty());
917
918        // Test with wrong deep nesting
919        let wrong_content = r#"* L1
920  * L2
921    * L3
922      * L4
923         * L5
924            * L6"#;
925        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
926        let result = rule.check(&ctx).unwrap();
927        assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
928    }
929
930    #[test]
931    fn test_excessive_indentation_detected() {
932        let rule = MD007ULIndent::default();
933
934        // Test excessive indentation (5 spaces instead of 2)
935        let content = "- Item 1\n     - Item 2 with 5 spaces";
936        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937        let result = rule.check(&ctx).unwrap();
938        assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
939        assert_eq!(result[0].line, 2);
940        assert!(result[0].message.contains("Expected 2 spaces"));
941        assert!(result[0].message.contains("found 5"));
942
943        // Test slightly excessive indentation (3 spaces instead of 2)
944        let content = "- Item 1\n   - Item 2 with 3 spaces";
945        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946        let result = rule.check(&ctx).unwrap();
947        assert_eq!(
948            result.len(),
949            1,
950            "Should detect slightly excessive indentation (3 instead of 2)"
951        );
952        assert_eq!(result[0].line, 2);
953        assert!(result[0].message.contains("Expected 2 spaces"));
954        assert!(result[0].message.contains("found 3"));
955
956        // Test insufficient indentation (1 space is treated as level 0, should be 0)
957        let content = "- Item 1\n - Item 2 with 1 space";
958        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959        let result = rule.check(&ctx).unwrap();
960        assert_eq!(
961            result.len(),
962            1,
963            "Should detect 1-space indent (insufficient for nesting, expected 0)"
964        );
965        assert_eq!(result[0].line, 2);
966        assert!(result[0].message.contains("Expected 0 spaces"));
967        assert!(result[0].message.contains("found 1"));
968    }
969
970    #[test]
971    fn test_excessive_indentation_with_4_space_config() {
972        // With smart auto-detection, pure unordered lists use fixed style
973        // Fixed style with indent=4: level 0 = 0, level 1 = 4, level 2 = 8
974        let rule = MD007ULIndent::new(4);
975
976        // Test excessive indentation (5 spaces instead of 4)
977        let content = "- Formatter:\n     - The stable style changed";
978        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979        let result = rule.check(&ctx).unwrap();
980        assert!(
981            !result.is_empty(),
982            "Should detect 5 spaces when expecting 4 (fixed style)"
983        );
984
985        // Test with correct fixed style alignment (4 spaces for level 1)
986        let correct_content = "- Formatter:\n    - The stable style changed";
987        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
988        let result = rule.check(&ctx).unwrap();
989        assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
990    }
991
992    #[test]
993    fn test_bullets_nested_under_numbered_items() {
994        let rule = MD007ULIndent::default();
995        let content = "\
9961. **Active Directory/LDAP**
997   - User authentication and directory services
998   - LDAP for user information and validation
999
10002. **Oracle Unified Directory (OUD)**
1001   - Extended user directory services";
1002        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003        let result = rule.check(&ctx).unwrap();
1004        // Should have no warnings - 3 spaces is correct for bullets under numbered items
1005        assert!(
1006            result.is_empty(),
1007            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1008        );
1009    }
1010
1011    #[test]
1012    fn test_bullets_nested_under_numbered_items_wrong_indent() {
1013        let rule = MD007ULIndent::default();
1014        let content = "\
10151. **Active Directory/LDAP**
1016  - Wrong: only 2 spaces";
1017        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1018        let result = rule.check(&ctx).unwrap();
1019        // Should flag incorrect indentation
1020        assert_eq!(
1021            result.len(),
1022            1,
1023            "Expected warning for incorrect indentation under numbered items"
1024        );
1025        assert!(
1026            result
1027                .iter()
1028                .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1029        );
1030    }
1031
1032    #[test]
1033    fn test_regular_bullet_nesting_still_works() {
1034        let rule = MD007ULIndent::default();
1035        let content = "\
1036* Top level
1037  * Nested bullet (2 spaces is correct)
1038    * Deeply nested (4 spaces)";
1039        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1040        let result = rule.check(&ctx).unwrap();
1041        // Should have no warnings - standard bullet nesting still uses 2-space increments
1042        assert!(
1043            result.is_empty(),
1044            "Expected no warnings for standard bullet nesting, got: {result:?}"
1045        );
1046    }
1047
1048    #[test]
1049    fn test_blockquote_with_tab_after_marker() {
1050        let rule = MD007ULIndent::default();
1051        let content = ">\t* List item\n>\t  * Nested\n";
1052        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053        let result = rule.check(&ctx).unwrap();
1054        assert!(
1055            result.is_empty(),
1056            "Tab after blockquote marker should be handled correctly, got: {result:?}"
1057        );
1058    }
1059
1060    #[test]
1061    fn test_blockquote_with_space_then_tab_after_marker() {
1062        let rule = MD007ULIndent::default();
1063        let content = "> \t* List item\n";
1064        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065        let result = rule.check(&ctx).unwrap();
1066        // First-level list item at any indentation is allowed when start_indented=false (default)
1067        assert!(
1068            result.is_empty(),
1069            "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1070        );
1071    }
1072
1073    #[test]
1074    fn test_blockquote_with_multiple_tabs() {
1075        let rule = MD007ULIndent::default();
1076        let content = ">\t\t* List item\n";
1077        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078        let result = rule.check(&ctx).unwrap();
1079        // First-level list item at any indentation is allowed when start_indented=false (default)
1080        assert!(
1081            result.is_empty(),
1082            "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1083        );
1084    }
1085
1086    #[test]
1087    fn test_nested_blockquote_with_tab() {
1088        let rule = MD007ULIndent::default();
1089        let content = ">\t>\t* List item\n>\t>\t  * Nested\n";
1090        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091        let result = rule.check(&ctx).unwrap();
1092        assert!(
1093            result.is_empty(),
1094            "Nested blockquotes with tabs should work correctly, got: {result:?}"
1095        );
1096    }
1097
1098    // Tests for smart style auto-detection (fixes issue #210 while preserving #209 fix)
1099
1100    #[test]
1101    fn test_smart_style_pure_unordered_uses_fixed() {
1102        // Issue #210: Pure unordered lists with custom indent should use fixed style
1103        let rule = MD007ULIndent::new(4);
1104
1105        // With fixed style (auto-detected), this should be valid
1106        let content = "* Level 0\n    * Level 1\n        * Level 2";
1107        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108        let result = rule.check(&ctx).unwrap();
1109        assert!(
1110            result.is_empty(),
1111            "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1112        );
1113    }
1114
1115    #[test]
1116    fn test_smart_style_mixed_lists_uses_text_aligned() {
1117        // Issue #209: Mixed lists should use text-aligned to avoid oscillation
1118        let rule = MD007ULIndent::new(4);
1119
1120        // With text-aligned style (auto-detected for mixed), bullets align with parent text
1121        let content = "1. Ordered\n   * Bullet aligns with 'Ordered' text (3 spaces)";
1122        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123        let result = rule.check(&ctx).unwrap();
1124        assert!(
1125            result.is_empty(),
1126            "Mixed lists should use text-aligned style, got: {result:?}"
1127        );
1128    }
1129
1130    #[test]
1131    fn test_smart_style_explicit_fixed_overrides() {
1132        // When style is explicitly set to fixed, it should be respected even for mixed lists
1133        let config = MD007Config {
1134            indent: crate::types::IndentSize::from_const(4),
1135            start_indented: false,
1136            start_indent: crate::types::IndentSize::from_const(2),
1137            style: md007_config::IndentStyle::Fixed,
1138            style_explicit: true, // Explicit setting
1139        };
1140        let rule = MD007ULIndent::from_config_struct(config);
1141
1142        // With explicit fixed style, expect fixed calculations even for mixed lists
1143        let content = "1. Ordered\n    * Should be at 4 spaces (fixed)";
1144        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145        let result = rule.check(&ctx).unwrap();
1146        // The bullet is at 4 spaces which matches fixed style level 1
1147        assert!(
1148            result.is_empty(),
1149            "Explicit fixed style should be respected, got: {result:?}"
1150        );
1151    }
1152
1153    #[test]
1154    fn test_smart_style_explicit_text_aligned_overrides() {
1155        // When style is explicitly set to text-aligned, it should be respected
1156        let config = MD007Config {
1157            indent: crate::types::IndentSize::from_const(4),
1158            start_indented: false,
1159            start_indent: crate::types::IndentSize::from_const(2),
1160            style: md007_config::IndentStyle::TextAligned,
1161            style_explicit: true, // Explicit setting
1162        };
1163        let rule = MD007ULIndent::from_config_struct(config);
1164
1165        // With explicit text-aligned, pure unordered should use text-aligned (not auto-switch to fixed)
1166        let content = "* Level 0\n  * Level 1 (aligned with 'Level 0' text)";
1167        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1168        let result = rule.check(&ctx).unwrap();
1169        assert!(
1170            result.is_empty(),
1171            "Explicit text-aligned should be respected, got: {result:?}"
1172        );
1173
1174        // This would be correct for fixed but wrong for text-aligned
1175        let fixed_style_content = "* Level 0\n    * Level 1 (4 spaces - fixed style)";
1176        let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1177        let result = rule.check(&ctx).unwrap();
1178        assert!(
1179            !result.is_empty(),
1180            "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1181        );
1182    }
1183
1184    #[test]
1185    fn test_smart_style_default_indent_no_autoswitch() {
1186        // When indent is default (2), no auto-switch happens (both styles produce same result)
1187        let rule = MD007ULIndent::new(2);
1188
1189        let content = "* Level 0\n  * Level 1\n    * Level 2";
1190        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1191        let result = rule.check(&ctx).unwrap();
1192        assert!(
1193            result.is_empty(),
1194            "Default indent should work regardless of style, got: {result:?}"
1195        );
1196    }
1197
1198    #[test]
1199    fn test_has_mixed_list_nesting_detection() {
1200        // Test the mixed list detection function directly
1201
1202        // Pure unordered - no mixed nesting
1203        let content = "* Item 1\n  * Item 2\n    * Item 3";
1204        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1205        assert!(
1206            !MD007ULIndent::has_mixed_list_nesting(&ctx),
1207            "Pure unordered should not be detected as mixed"
1208        );
1209
1210        // Pure ordered - no mixed nesting
1211        let content = "1. Item 1\n   2. Item 2\n      3. Item 3";
1212        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213        assert!(
1214            !MD007ULIndent::has_mixed_list_nesting(&ctx),
1215            "Pure ordered should not be detected as mixed"
1216        );
1217
1218        // Mixed: unordered under ordered
1219        let content = "1. Ordered\n   * Unordered child";
1220        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221        assert!(
1222            MD007ULIndent::has_mixed_list_nesting(&ctx),
1223            "Unordered under ordered should be detected as mixed"
1224        );
1225
1226        // Mixed: ordered under unordered
1227        let content = "* Unordered\n  1. Ordered child";
1228        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229        assert!(
1230            MD007ULIndent::has_mixed_list_nesting(&ctx),
1231            "Ordered under unordered should be detected as mixed"
1232        );
1233
1234        // Separate lists (not nested) - not mixed
1235        let content = "* Unordered\n\n1. Ordered (separate list)";
1236        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237        assert!(
1238            !MD007ULIndent::has_mixed_list_nesting(&ctx),
1239            "Separate lists should not be detected as mixed"
1240        );
1241
1242        // Mixed lists inside blockquotes should be detected
1243        let content = "> 1. Ordered in blockquote\n>    * Unordered child";
1244        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245        assert!(
1246            MD007ULIndent::has_mixed_list_nesting(&ctx),
1247            "Mixed lists in blockquotes should be detected"
1248        );
1249    }
1250
1251    #[test]
1252    fn test_issue_210_exact_reproduction() {
1253        // Exact reproduction from issue #210
1254        let config = MD007Config {
1255            indent: crate::types::IndentSize::from_const(4),
1256            start_indented: false,
1257            start_indent: crate::types::IndentSize::from_const(2),
1258            style: md007_config::IndentStyle::TextAligned, // Default
1259            style_explicit: false,                         // Not explicitly set - should auto-detect
1260        };
1261        let rule = MD007ULIndent::from_config_struct(config);
1262
1263        let content = "# Title\n\n* some\n    * list\n    * items\n";
1264        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1265        let result = rule.check(&ctx).unwrap();
1266
1267        assert!(
1268            result.is_empty(),
1269            "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1270        );
1271    }
1272
1273    #[test]
1274    fn test_issue_209_still_fixed() {
1275        // Verify issue #209 (oscillation) is still fixed
1276        let config = MD007Config {
1277            indent: crate::types::IndentSize::from_const(3),
1278            start_indented: false,
1279            start_indent: crate::types::IndentSize::from_const(2),
1280            style: md007_config::IndentStyle::TextAligned, // Default
1281            style_explicit: false,                         // Not explicitly set
1282        };
1283        let rule = MD007ULIndent::from_config_struct(config);
1284
1285        // Mixed list from issue #209 - should use text-aligned, no oscillation
1286        let content = r#"# Header 1
1287
1288- **Second item**:
1289  - **This is a nested list**:
1290    1. **First point**
1291       - First subpoint
1292"#;
1293        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294        let result = rule.check(&ctx).unwrap();
1295
1296        assert!(
1297            result.is_empty(),
1298            "Issue #209: Mixed lists should use text-aligned and have no issues, got: {result:?}"
1299        );
1300    }
1301
1302    // Edge case tests for review findings
1303
1304    #[test]
1305    fn test_multi_level_mixed_detection_grandparent() {
1306        // Test that multi-level mixed detection finds grandparent type differences
1307        // ordered → unordered → unordered should be detected as mixed
1308        // because the grandparent (ordered) is different from descendants (unordered)
1309        let content = "1. Ordered grandparent\n   * Unordered child\n     * Unordered grandchild";
1310        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311        assert!(
1312            MD007ULIndent::has_mixed_list_nesting(&ctx),
1313            "Should detect mixed nesting when grandparent differs in type"
1314        );
1315
1316        // unordered → ordered → ordered should also be detected as mixed
1317        let content = "* Unordered grandparent\n  1. Ordered child\n     2. Ordered grandchild";
1318        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319        assert!(
1320            MD007ULIndent::has_mixed_list_nesting(&ctx),
1321            "Should detect mixed nesting for ordered descendants under unordered"
1322        );
1323    }
1324
1325    #[test]
1326    fn test_html_comments_skipped_in_detection() {
1327        // Lists inside HTML comments should not affect mixed detection
1328        let content = r#"* Unordered list
1329<!-- This is a comment
1330  1. This ordered list is inside a comment
1331     * This nested bullet is also inside
1332-->
1333  * Another unordered item"#;
1334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335        assert!(
1336            !MD007ULIndent::has_mixed_list_nesting(&ctx),
1337            "Lists in HTML comments should be ignored in mixed detection"
1338        );
1339    }
1340
1341    #[test]
1342    fn test_blank_lines_separate_lists() {
1343        // Blank lines at root level should separate lists, treating them as independent
1344        let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1345        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1346        assert!(
1347            !MD007ULIndent::has_mixed_list_nesting(&ctx),
1348            "Blank line at root should separate lists"
1349        );
1350
1351        // But nested lists after blank should still be detected if mixed
1352        let content = "1. Ordered parent\n\n   * Still a child due to indentation";
1353        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1354        assert!(
1355            MD007ULIndent::has_mixed_list_nesting(&ctx),
1356            "Indented list after blank is still nested"
1357        );
1358    }
1359
1360    #[test]
1361    fn test_column_1_normalization() {
1362        // 1-space indent should be treated as column 0 (root level)
1363        // This creates a sibling relationship, not nesting
1364        let content = "* First item\n * Second item with 1 space (sibling)";
1365        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366        let rule = MD007ULIndent::default();
1367        let result = rule.check(&ctx).unwrap();
1368        // The second item should be flagged as wrong (1 space is not valid for nesting)
1369        assert!(
1370            result.iter().any(|w| w.line == 2),
1371            "1-space indent should be flagged as incorrect"
1372        );
1373    }
1374
1375    #[test]
1376    fn test_code_blocks_skipped_in_detection() {
1377        // Lists inside code blocks should not affect mixed detection
1378        let content = r#"* Unordered list
1379```
13801. This ordered list is inside a code block
1381   * This nested bullet is also inside
1382```
1383  * Another unordered item"#;
1384        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1385        assert!(
1386            !MD007ULIndent::has_mixed_list_nesting(&ctx),
1387            "Lists in code blocks should be ignored in mixed detection"
1388        );
1389    }
1390
1391    #[test]
1392    fn test_front_matter_skipped_in_detection() {
1393        // Lists inside YAML front matter should not affect mixed detection
1394        let content = r#"---
1395items:
1396  - yaml list item
1397  - another item
1398---
1399* Unordered list after front matter"#;
1400        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1401        assert!(
1402            !MD007ULIndent::has_mixed_list_nesting(&ctx),
1403            "Lists in front matter should be ignored in mixed detection"
1404        );
1405    }
1406
1407    #[test]
1408    fn test_alternating_types_at_same_level() {
1409        // Alternating between ordered and unordered at the same nesting level
1410        // is NOT mixed nesting (they are siblings, not parent-child)
1411        let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1412        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1413        assert!(
1414            !MD007ULIndent::has_mixed_list_nesting(&ctx),
1415            "Alternating types at same level should not be detected as mixed"
1416        );
1417    }
1418
1419    #[test]
1420    fn test_five_level_deep_mixed_nesting() {
1421        // Test detection at 5+ levels of nesting
1422        let content = "* L0\n  1. L1\n     * L2\n       1. L3\n          * L4\n            1. L5";
1423        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1424        assert!(
1425            MD007ULIndent::has_mixed_list_nesting(&ctx),
1426            "Should detect mixed nesting at 5+ levels"
1427        );
1428    }
1429
1430    #[test]
1431    fn test_very_deep_pure_unordered_nesting() {
1432        // Test pure unordered list with 10+ levels of nesting
1433        let mut content = String::from("* L1");
1434        for level in 2..=12 {
1435            let indent = "  ".repeat(level - 1);
1436            content.push_str(&format!("\n{indent}* L{level}"));
1437        }
1438
1439        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1440
1441        // Should NOT be detected as mixed (all unordered)
1442        assert!(
1443            !MD007ULIndent::has_mixed_list_nesting(&ctx),
1444            "Pure unordered deep nesting should not be detected as mixed"
1445        );
1446
1447        // Should use fixed style with custom indent
1448        let rule = MD007ULIndent::new(4);
1449        let result = rule.check(&ctx).unwrap();
1450        // With text-aligned default but auto-switch to fixed for pure unordered,
1451        // the first nested level should be flagged (2 spaces instead of 4)
1452        assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1453    }
1454
1455    #[test]
1456    fn test_interleaved_content_between_list_items() {
1457        // Paragraph continuation between list items should not break detection
1458        let content = "1. Ordered parent\n\n   Paragraph continuation\n\n   * Unordered child";
1459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460        assert!(
1461            MD007ULIndent::has_mixed_list_nesting(&ctx),
1462            "Should detect mixed nesting even with interleaved paragraphs"
1463        );
1464    }
1465
1466    #[test]
1467    fn test_esm_blocks_skipped_in_detection() {
1468        // ESM import/export blocks in MDX should be skipped
1469        // Note: ESM detection depends on LintContext properly setting in_esm_block
1470        let content = "* Unordered list\n  * Nested unordered";
1471        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1472        assert!(
1473            !MD007ULIndent::has_mixed_list_nesting(&ctx),
1474            "Pure unordered should not be detected as mixed"
1475        );
1476    }
1477
1478    #[test]
1479    fn test_multiple_list_blocks_pure_then_mixed() {
1480        // Document with pure unordered list followed by mixed list
1481        // Detection should find the mixed list and return true
1482        let content = r#"* Pure unordered
1483  * Nested unordered
1484
14851. Mixed section
1486   * Bullet under ordered"#;
1487        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1488        assert!(
1489            MD007ULIndent::has_mixed_list_nesting(&ctx),
1490            "Should detect mixed nesting in any part of document"
1491        );
1492    }
1493
1494    #[test]
1495    fn test_multiple_separate_pure_lists() {
1496        // Multiple pure unordered lists separated by blank lines
1497        // Should NOT be detected as mixed
1498        let content = r#"* First list
1499  * Nested
1500
1501* Second list
1502  * Also nested
1503
1504* Third list
1505  * Deeply
1506    * Nested"#;
1507        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1508        assert!(
1509            !MD007ULIndent::has_mixed_list_nesting(&ctx),
1510            "Multiple separate pure unordered lists should not be mixed"
1511        );
1512    }
1513
1514    #[test]
1515    fn test_code_block_between_list_items() {
1516        // Code block between list items should not affect detection
1517        let content = r#"1. Ordered
1518   ```
1519   code
1520   ```
1521   * Still a mixed child"#;
1522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1523        assert!(
1524            MD007ULIndent::has_mixed_list_nesting(&ctx),
1525            "Code block between items should not prevent mixed detection"
1526        );
1527    }
1528
1529    #[test]
1530    fn test_blockquoted_mixed_detection() {
1531        // Mixed lists inside blockquotes should be detected
1532        let content = "> 1. Ordered in blockquote\n>    * Mixed child";
1533        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534        // Note: Detection depends on correct marker_column calculation in blockquotes
1535        // This test verifies the detection logic works with blockquoted content
1536        assert!(
1537            MD007ULIndent::has_mixed_list_nesting(&ctx),
1538            "Should detect mixed nesting in blockquotes"
1539        );
1540    }
1541}