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