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