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