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        // Single tab
656        let content = "* Item 1\n\t* Item 2";
657        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658        let result = rule.check(&ctx).unwrap();
659        assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
660
661        // Fix should convert tab to spaces
662        let fixed = rule.fix(&ctx).unwrap();
663        assert_eq!(fixed, "* Item 1\n  * Item 2");
664
665        // Multiple tabs
666        let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
667        let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
668        let fixed = rule.fix(&ctx).unwrap();
669        // With non-cascade: Item 2 at 2 spaces, content at 4
670        // Item 3 aligns with Item 2's expected content at 4 spaces
671        assert_eq!(fixed, "* Item 1\n  * Item 2\n    * Item 3");
672
673        // Mixed tabs and spaces
674        let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
675        let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
676        let fixed = rule.fix(&ctx).unwrap();
677        // With non-cascade: Item 2 at 2 spaces, content at 4
678        // Item 3 aligns with Item 2's expected content at 4 spaces
679        assert_eq!(fixed, "* Item 1\n  * Item 2\n    * Item 3");
680    }
681
682    #[test]
683    fn test_mixed_ordered_unordered_lists() {
684        let rule = MD007ULIndent::default();
685
686        // MD007 only checks unordered lists, so ordered lists should be ignored
687        // Note: 3 spaces is now correct for bullets under ordered items
688        let content = r#"1. Ordered item
689   * Unordered sub-item (correct - 3 spaces under ordered)
690   2. Ordered sub-item
691* Unordered item
692  1. Ordered sub-item
693  * Unordered sub-item"#;
694
695        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696        let result = rule.check(&ctx).unwrap();
697        assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
698
699        // No fix needed as all indentation is correct
700        let fixed = rule.fix(&ctx).unwrap();
701        assert_eq!(fixed, content);
702    }
703
704    #[test]
705    fn test_list_markers_variety() {
706        let rule = MD007ULIndent::default();
707
708        // Test all three unordered list markers
709        let content = r#"* Asterisk
710  * Nested asterisk
711- Hyphen
712  - Nested hyphen
713+ Plus
714  + Nested plus"#;
715
716        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717        let result = rule.check(&ctx).unwrap();
718        assert!(
719            result.is_empty(),
720            "All unordered list markers should work with proper indentation"
721        );
722
723        // Test with wrong indentation for each marker type
724        let wrong_content = r#"* Asterisk
725   * Wrong asterisk
726- Hyphen
727 - Wrong hyphen
728+ Plus
729    + Wrong plus"#;
730
731        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
732        let result = rule.check(&ctx).unwrap();
733        assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
734    }
735
736    #[test]
737    fn test_empty_list_items() {
738        let rule = MD007ULIndent::default();
739        let content = "* Item 1\n* \n  * Item 2";
740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741        let result = rule.check(&ctx).unwrap();
742        assert!(
743            result.is_empty(),
744            "Empty list items should not affect indentation checks"
745        );
746    }
747
748    #[test]
749    fn test_list_with_code_blocks() {
750        let rule = MD007ULIndent::default();
751        let content = r#"* Item 1
752  ```
753  code
754  ```
755  * Item 2
756    * Item 3"#;
757        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758        let result = rule.check(&ctx).unwrap();
759        assert!(result.is_empty());
760    }
761
762    #[test]
763    fn test_list_in_front_matter() {
764        let rule = MD007ULIndent::default();
765        let content = r#"---
766tags:
767  - tag1
768  - tag2
769---
770* Item 1
771  * Item 2"#;
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let result = rule.check(&ctx).unwrap();
774        assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
775    }
776
777    #[test]
778    fn test_fix_preserves_content() {
779        let rule = MD007ULIndent::default();
780        let content = "* Item 1 with **bold** and *italic*\n   * Item 2 with `code`\n     * Item 3 with [link](url)";
781        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782        let fixed = rule.fix(&ctx).unwrap();
783        // With non-cascade: Item 2 at 2 spaces, content at 4
784        // Item 3 aligns with Item 2's expected content at 4 spaces
785        let expected = "* Item 1 with **bold** and *italic*\n  * Item 2 with `code`\n    * Item 3 with [link](url)";
786        assert_eq!(fixed, expected, "Fix should only change indentation, not content");
787    }
788
789    #[test]
790    fn test_start_indented_config() {
791        let config = MD007Config {
792            start_indented: true,
793            start_indent: crate::types::IndentSize::from_const(4),
794            indent: crate::types::IndentSize::from_const(2),
795            style: md007_config::IndentStyle::TextAligned,
796            style_explicit: true, // Explicit style for this test
797        };
798        let rule = MD007ULIndent::from_config_struct(config);
799
800        // First level should be indented by start_indent (4 spaces)
801        // Level 0: 4 spaces (start_indent)
802        // Level 1: 6 spaces (start_indent + indent = 4 + 2)
803        // Level 2: 8 spaces (start_indent + 2*indent = 4 + 4)
804        let content = "    * Item 1\n      * Item 2\n        * Item 3";
805        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806        let result = rule.check(&ctx).unwrap();
807        assert!(result.is_empty(), "Expected no warnings with start_indented config");
808
809        // Wrong first level indentation
810        let wrong_content = "  * Item 1\n    * Item 2";
811        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
812        let result = rule.check(&ctx).unwrap();
813        assert_eq!(result.len(), 2);
814        assert_eq!(result[0].line, 1);
815        assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
816        assert_eq!(result[1].line, 2);
817        assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
818
819        // Fix should correct to start_indent for first level
820        let fixed = rule.fix(&ctx).unwrap();
821        assert_eq!(fixed, "    * Item 1\n      * Item 2");
822    }
823
824    #[test]
825    fn test_start_indented_false_allows_any_first_level() {
826        let rule = MD007ULIndent::default(); // start_indented is false by default
827
828        // When start_indented is false, first level items at any indentation are allowed
829        let content = "   * Item 1"; // First level at 3 spaces
830        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831        let result = rule.check(&ctx).unwrap();
832        assert!(
833            result.is_empty(),
834            "First level at any indentation should be allowed when start_indented is false"
835        );
836
837        // Multiple first level items at different indentations should all be allowed
838        let content = "* Item 1\n  * Item 2\n    * Item 3"; // All at level 0 (different indents)
839        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840        let result = rule.check(&ctx).unwrap();
841        assert!(
842            result.is_empty(),
843            "All first-level items should be allowed at any indentation"
844        );
845    }
846
847    #[test]
848    fn test_deeply_nested_lists() {
849        let rule = MD007ULIndent::default();
850        let content = r#"* L1
851  * L2
852    * L3
853      * L4
854        * L5
855          * L6"#;
856        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857        let result = rule.check(&ctx).unwrap();
858        assert!(result.is_empty());
859
860        // Test with wrong deep nesting
861        let wrong_content = r#"* L1
862  * L2
863    * L3
864      * L4
865         * L5
866            * L6"#;
867        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
868        let result = rule.check(&ctx).unwrap();
869        assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
870    }
871
872    #[test]
873    fn test_excessive_indentation_detected() {
874        let rule = MD007ULIndent::default();
875
876        // Test excessive indentation (5 spaces instead of 2)
877        let content = "- Item 1\n     - Item 2 with 5 spaces";
878        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879        let result = rule.check(&ctx).unwrap();
880        assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
881        assert_eq!(result[0].line, 2);
882        assert!(result[0].message.contains("Expected 2 spaces"));
883        assert!(result[0].message.contains("found 5"));
884
885        // Test slightly excessive indentation (3 spaces instead of 2)
886        let content = "- Item 1\n   - Item 2 with 3 spaces";
887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888        let result = rule.check(&ctx).unwrap();
889        assert_eq!(
890            result.len(),
891            1,
892            "Should detect slightly excessive indentation (3 instead of 2)"
893        );
894        assert_eq!(result[0].line, 2);
895        assert!(result[0].message.contains("Expected 2 spaces"));
896        assert!(result[0].message.contains("found 3"));
897
898        // Test insufficient indentation (1 space is treated as level 0, should be 0)
899        let content = "- Item 1\n - Item 2 with 1 space";
900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901        let result = rule.check(&ctx).unwrap();
902        assert_eq!(
903            result.len(),
904            1,
905            "Should detect 1-space indent (insufficient for nesting, expected 0)"
906        );
907        assert_eq!(result[0].line, 2);
908        assert!(result[0].message.contains("Expected 0 spaces"));
909        assert!(result[0].message.contains("found 1"));
910    }
911
912    #[test]
913    fn test_excessive_indentation_with_4_space_config() {
914        // With smart auto-detection, pure unordered lists use fixed style
915        // Fixed style with indent=4: level 0 = 0, level 1 = 4, level 2 = 8
916        let rule = MD007ULIndent::new(4);
917
918        // Test excessive indentation (5 spaces instead of 4)
919        let content = "- Formatter:\n     - The stable style changed";
920        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921        let result = rule.check(&ctx).unwrap();
922        assert!(
923            !result.is_empty(),
924            "Should detect 5 spaces when expecting 4 (fixed style)"
925        );
926
927        // Test with correct fixed style alignment (4 spaces for level 1)
928        let correct_content = "- Formatter:\n    - The stable style changed";
929        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
930        let result = rule.check(&ctx).unwrap();
931        assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
932    }
933
934    #[test]
935    fn test_bullets_nested_under_numbered_items() {
936        let rule = MD007ULIndent::default();
937        let content = "\
9381. **Active Directory/LDAP**
939   - User authentication and directory services
940   - LDAP for user information and validation
941
9422. **Oracle Unified Directory (OUD)**
943   - Extended user directory services";
944        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945        let result = rule.check(&ctx).unwrap();
946        // Should have no warnings - 3 spaces is correct for bullets under numbered items
947        assert!(
948            result.is_empty(),
949            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
950        );
951    }
952
953    #[test]
954    fn test_bullets_nested_under_numbered_items_wrong_indent() {
955        let rule = MD007ULIndent::default();
956        let content = "\
9571. **Active Directory/LDAP**
958  - Wrong: only 2 spaces";
959        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960        let result = rule.check(&ctx).unwrap();
961        // Should flag incorrect indentation
962        assert_eq!(
963            result.len(),
964            1,
965            "Expected warning for incorrect indentation under numbered items"
966        );
967        assert!(
968            result
969                .iter()
970                .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
971        );
972    }
973
974    #[test]
975    fn test_regular_bullet_nesting_still_works() {
976        let rule = MD007ULIndent::default();
977        let content = "\
978* Top level
979  * Nested bullet (2 spaces is correct)
980    * Deeply nested (4 spaces)";
981        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982        let result = rule.check(&ctx).unwrap();
983        // Should have no warnings - standard bullet nesting still uses 2-space increments
984        assert!(
985            result.is_empty(),
986            "Expected no warnings for standard bullet nesting, got: {result:?}"
987        );
988    }
989
990    #[test]
991    fn test_blockquote_with_tab_after_marker() {
992        let rule = MD007ULIndent::default();
993        let content = ">\t* List item\n>\t  * Nested\n";
994        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995        let result = rule.check(&ctx).unwrap();
996        assert!(
997            result.is_empty(),
998            "Tab after blockquote marker should be handled correctly, got: {result:?}"
999        );
1000    }
1001
1002    #[test]
1003    fn test_blockquote_with_space_then_tab_after_marker() {
1004        let rule = MD007ULIndent::default();
1005        let content = "> \t* List item\n";
1006        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1007        let result = rule.check(&ctx).unwrap();
1008        // First-level list item at any indentation is allowed when start_indented=false (default)
1009        assert!(
1010            result.is_empty(),
1011            "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1012        );
1013    }
1014
1015    #[test]
1016    fn test_blockquote_with_multiple_tabs() {
1017        let rule = MD007ULIndent::default();
1018        let content = ">\t\t* List item\n";
1019        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020        let result = rule.check(&ctx).unwrap();
1021        // First-level list item at any indentation is allowed when start_indented=false (default)
1022        assert!(
1023            result.is_empty(),
1024            "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1025        );
1026    }
1027
1028    #[test]
1029    fn test_nested_blockquote_with_tab() {
1030        let rule = MD007ULIndent::default();
1031        let content = ">\t>\t* List item\n>\t>\t  * Nested\n";
1032        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033        let result = rule.check(&ctx).unwrap();
1034        assert!(
1035            result.is_empty(),
1036            "Nested blockquotes with tabs should work correctly, got: {result:?}"
1037        );
1038    }
1039
1040    // Tests for smart style auto-detection (fixes issue #210 while preserving #209 fix)
1041
1042    #[test]
1043    fn test_smart_style_pure_unordered_uses_fixed() {
1044        // Issue #210: Pure unordered lists with custom indent should use fixed style
1045        let rule = MD007ULIndent::new(4);
1046
1047        // With fixed style (auto-detected), this should be valid
1048        let content = "* Level 0\n    * Level 1\n        * Level 2";
1049        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050        let result = rule.check(&ctx).unwrap();
1051        assert!(
1052            result.is_empty(),
1053            "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1054        );
1055    }
1056
1057    #[test]
1058    fn test_smart_style_mixed_lists_uses_text_aligned() {
1059        // Issue #209: Mixed lists should use text-aligned to avoid oscillation
1060        let rule = MD007ULIndent::new(4);
1061
1062        // With text-aligned style (auto-detected for mixed), bullets align with parent text
1063        let content = "1. Ordered\n   * Bullet aligns with 'Ordered' text (3 spaces)";
1064        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065        let result = rule.check(&ctx).unwrap();
1066        assert!(
1067            result.is_empty(),
1068            "Mixed lists should use text-aligned style, got: {result:?}"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_smart_style_explicit_fixed_overrides() {
1074        // When style is explicitly set to fixed, it should be respected even for mixed lists
1075        let config = MD007Config {
1076            indent: crate::types::IndentSize::from_const(4),
1077            start_indented: false,
1078            start_indent: crate::types::IndentSize::from_const(2),
1079            style: md007_config::IndentStyle::Fixed,
1080            style_explicit: true, // Explicit setting
1081        };
1082        let rule = MD007ULIndent::from_config_struct(config);
1083
1084        // With explicit fixed style, expect fixed calculations even for mixed lists
1085        let content = "1. Ordered\n    * Should be at 4 spaces (fixed)";
1086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087        let result = rule.check(&ctx).unwrap();
1088        // The bullet is at 4 spaces which matches fixed style level 1
1089        assert!(
1090            result.is_empty(),
1091            "Explicit fixed style should be respected, got: {result:?}"
1092        );
1093    }
1094
1095    #[test]
1096    fn test_smart_style_explicit_text_aligned_overrides() {
1097        // When style is explicitly set to text-aligned, it should be respected
1098        let config = MD007Config {
1099            indent: crate::types::IndentSize::from_const(4),
1100            start_indented: false,
1101            start_indent: crate::types::IndentSize::from_const(2),
1102            style: md007_config::IndentStyle::TextAligned,
1103            style_explicit: true, // Explicit setting
1104        };
1105        let rule = MD007ULIndent::from_config_struct(config);
1106
1107        // With explicit text-aligned, pure unordered should use text-aligned (not auto-switch to fixed)
1108        let content = "* Level 0\n  * Level 1 (aligned with 'Level 0' text)";
1109        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110        let result = rule.check(&ctx).unwrap();
1111        assert!(
1112            result.is_empty(),
1113            "Explicit text-aligned should be respected, got: {result:?}"
1114        );
1115
1116        // This would be correct for fixed but wrong for text-aligned
1117        let fixed_style_content = "* Level 0\n    * Level 1 (4 spaces - fixed style)";
1118        let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1119        let result = rule.check(&ctx).unwrap();
1120        assert!(
1121            !result.is_empty(),
1122            "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1123        );
1124    }
1125
1126    #[test]
1127    fn test_smart_style_default_indent_no_autoswitch() {
1128        // When indent is default (2), no auto-switch happens (both styles produce same result)
1129        let rule = MD007ULIndent::new(2);
1130
1131        let content = "* Level 0\n  * Level 1\n    * Level 2";
1132        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133        let result = rule.check(&ctx).unwrap();
1134        assert!(
1135            result.is_empty(),
1136            "Default indent should work regardless of style, got: {result:?}"
1137        );
1138    }
1139
1140    #[test]
1141    fn test_has_mixed_list_nesting_detection() {
1142        // Test the mixed list detection function directly
1143
1144        // Pure unordered - no mixed nesting
1145        let content = "* Item 1\n  * Item 2\n    * Item 3";
1146        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147        assert!(
1148            !ctx.has_mixed_list_nesting(),
1149            "Pure unordered should not be detected as mixed"
1150        );
1151
1152        // Pure ordered - no mixed nesting
1153        let content = "1. Item 1\n   2. Item 2\n      3. Item 3";
1154        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155        assert!(
1156            !ctx.has_mixed_list_nesting(),
1157            "Pure ordered should not be detected as mixed"
1158        );
1159
1160        // Mixed: unordered under ordered
1161        let content = "1. Ordered\n   * Unordered child";
1162        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163        assert!(
1164            ctx.has_mixed_list_nesting(),
1165            "Unordered under ordered should be detected as mixed"
1166        );
1167
1168        // Mixed: ordered under unordered
1169        let content = "* Unordered\n  1. Ordered child";
1170        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1171        assert!(
1172            ctx.has_mixed_list_nesting(),
1173            "Ordered under unordered should be detected as mixed"
1174        );
1175
1176        // Separate lists (not nested) - not mixed
1177        let content = "* Unordered\n\n1. Ordered (separate list)";
1178        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179        assert!(
1180            !ctx.has_mixed_list_nesting(),
1181            "Separate lists should not be detected as mixed"
1182        );
1183
1184        // Mixed lists inside blockquotes should be detected
1185        let content = "> 1. Ordered in blockquote\n>    * Unordered child";
1186        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187        assert!(
1188            ctx.has_mixed_list_nesting(),
1189            "Mixed lists in blockquotes should be detected"
1190        );
1191    }
1192
1193    #[test]
1194    fn test_issue_210_exact_reproduction() {
1195        // Exact reproduction from issue #210
1196        let config = MD007Config {
1197            indent: crate::types::IndentSize::from_const(4),
1198            start_indented: false,
1199            start_indent: crate::types::IndentSize::from_const(2),
1200            style: md007_config::IndentStyle::TextAligned, // Default
1201            style_explicit: false,                         // Not explicitly set - should auto-detect
1202        };
1203        let rule = MD007ULIndent::from_config_struct(config);
1204
1205        let content = "# Title\n\n* some\n    * list\n    * items\n";
1206        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1207        let result = rule.check(&ctx).unwrap();
1208
1209        assert!(
1210            result.is_empty(),
1211            "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1212        );
1213    }
1214
1215    #[test]
1216    fn test_issue_209_still_fixed() {
1217        // Verify issue #209 (oscillation) is still fixed when style is explicitly set
1218        // With issue #236 fix, explicit style must be set to get pure text-aligned behavior
1219        let config = MD007Config {
1220            indent: crate::types::IndentSize::from_const(3),
1221            start_indented: false,
1222            start_indent: crate::types::IndentSize::from_const(2),
1223            style: md007_config::IndentStyle::TextAligned,
1224            style_explicit: true, // Explicit style to test text-aligned behavior
1225        };
1226        let rule = MD007ULIndent::from_config_struct(config);
1227
1228        // Mixed list from issue #209 - with explicit text-aligned, no oscillation
1229        let content = r#"# Header 1
1230
1231- **Second item**:
1232  - **This is a nested list**:
1233    1. **First point**
1234       - First subpoint
1235"#;
1236        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237        let result = rule.check(&ctx).unwrap();
1238
1239        assert!(
1240            result.is_empty(),
1241            "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1242        );
1243    }
1244
1245    // Edge case tests for review findings
1246
1247    #[test]
1248    fn test_multi_level_mixed_detection_grandparent() {
1249        // Test that multi-level mixed detection finds grandparent type differences
1250        // ordered → unordered → unordered should be detected as mixed
1251        // because the grandparent (ordered) is different from descendants (unordered)
1252        let content = "1. Ordered grandparent\n   * Unordered child\n     * Unordered grandchild";
1253        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254        assert!(
1255            ctx.has_mixed_list_nesting(),
1256            "Should detect mixed nesting when grandparent differs in type"
1257        );
1258
1259        // unordered → ordered → ordered should also be detected as mixed
1260        let content = "* Unordered grandparent\n  1. Ordered child\n     2. Ordered grandchild";
1261        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1262        assert!(
1263            ctx.has_mixed_list_nesting(),
1264            "Should detect mixed nesting for ordered descendants under unordered"
1265        );
1266    }
1267
1268    #[test]
1269    fn test_html_comments_skipped_in_detection() {
1270        // Lists inside HTML comments should not affect mixed detection
1271        let content = r#"* Unordered list
1272<!-- This is a comment
1273  1. This ordered list is inside a comment
1274     * This nested bullet is also inside
1275-->
1276  * Another unordered item"#;
1277        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278        assert!(
1279            !ctx.has_mixed_list_nesting(),
1280            "Lists in HTML comments should be ignored in mixed detection"
1281        );
1282    }
1283
1284    #[test]
1285    fn test_blank_lines_separate_lists() {
1286        // Blank lines at root level should separate lists, treating them as independent
1287        let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1288        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289        assert!(
1290            !ctx.has_mixed_list_nesting(),
1291            "Blank line at root should separate lists"
1292        );
1293
1294        // But nested lists after blank should still be detected if mixed
1295        let content = "1. Ordered parent\n\n   * Still a child due to indentation";
1296        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297        assert!(
1298            ctx.has_mixed_list_nesting(),
1299            "Indented list after blank is still nested"
1300        );
1301    }
1302
1303    #[test]
1304    fn test_column_1_normalization() {
1305        // 1-space indent should be treated as column 0 (root level)
1306        // This creates a sibling relationship, not nesting
1307        let content = "* First item\n * Second item with 1 space (sibling)";
1308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309        let rule = MD007ULIndent::default();
1310        let result = rule.check(&ctx).unwrap();
1311        // The second item should be flagged as wrong (1 space is not valid for nesting)
1312        assert!(
1313            result.iter().any(|w| w.line == 2),
1314            "1-space indent should be flagged as incorrect"
1315        );
1316    }
1317
1318    #[test]
1319    fn test_code_blocks_skipped_in_detection() {
1320        // Lists inside code blocks should not affect mixed detection
1321        let content = r#"* Unordered list
1322```
13231. This ordered list is inside a code block
1324   * This nested bullet is also inside
1325```
1326  * Another unordered item"#;
1327        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328        assert!(
1329            !ctx.has_mixed_list_nesting(),
1330            "Lists in code blocks should be ignored in mixed detection"
1331        );
1332    }
1333
1334    #[test]
1335    fn test_front_matter_skipped_in_detection() {
1336        // Lists inside YAML front matter should not affect mixed detection
1337        let content = r#"---
1338items:
1339  - yaml list item
1340  - another item
1341---
1342* Unordered list after front matter"#;
1343        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344        assert!(
1345            !ctx.has_mixed_list_nesting(),
1346            "Lists in front matter should be ignored in mixed detection"
1347        );
1348    }
1349
1350    #[test]
1351    fn test_alternating_types_at_same_level() {
1352        // Alternating between ordered and unordered at the same nesting level
1353        // is NOT mixed nesting (they are siblings, not parent-child)
1354        let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356        assert!(
1357            !ctx.has_mixed_list_nesting(),
1358            "Alternating types at same level should not be detected as mixed"
1359        );
1360    }
1361
1362    #[test]
1363    fn test_five_level_deep_mixed_nesting() {
1364        // Test detection at 5+ levels of nesting
1365        let content = "* L0\n  1. L1\n     * L2\n       1. L3\n          * L4\n            1. L5";
1366        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1367        assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1368    }
1369
1370    #[test]
1371    fn test_very_deep_pure_unordered_nesting() {
1372        // Test pure unordered list with 10+ levels of nesting
1373        let mut content = String::from("* L1");
1374        for level in 2..=12 {
1375            let indent = "  ".repeat(level - 1);
1376            content.push_str(&format!("\n{indent}* L{level}"));
1377        }
1378
1379        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1380
1381        // Should NOT be detected as mixed (all unordered)
1382        assert!(
1383            !ctx.has_mixed_list_nesting(),
1384            "Pure unordered deep nesting should not be detected as mixed"
1385        );
1386
1387        // Should use fixed style with custom indent
1388        let rule = MD007ULIndent::new(4);
1389        let result = rule.check(&ctx).unwrap();
1390        // With text-aligned default but auto-switch to fixed for pure unordered,
1391        // the first nested level should be flagged (2 spaces instead of 4)
1392        assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1393    }
1394
1395    #[test]
1396    fn test_interleaved_content_between_list_items() {
1397        // Paragraph continuation between list items should not break detection
1398        let content = "1. Ordered parent\n\n   Paragraph continuation\n\n   * Unordered child";
1399        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400        assert!(
1401            ctx.has_mixed_list_nesting(),
1402            "Should detect mixed nesting even with interleaved paragraphs"
1403        );
1404    }
1405
1406    #[test]
1407    fn test_esm_blocks_skipped_in_detection() {
1408        // ESM import/export blocks in MDX should be skipped
1409        // Note: ESM detection depends on LintContext properly setting in_esm_block
1410        let content = "* Unordered list\n  * Nested unordered";
1411        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1412        assert!(
1413            !ctx.has_mixed_list_nesting(),
1414            "Pure unordered should not be detected as mixed"
1415        );
1416    }
1417
1418    #[test]
1419    fn test_multiple_list_blocks_pure_then_mixed() {
1420        // Document with pure unordered list followed by mixed list
1421        // Detection should find the mixed list and return true
1422        let content = r#"* Pure unordered
1423  * Nested unordered
1424
14251. Mixed section
1426   * Bullet under ordered"#;
1427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1428        assert!(
1429            ctx.has_mixed_list_nesting(),
1430            "Should detect mixed nesting in any part of document"
1431        );
1432    }
1433
1434    #[test]
1435    fn test_multiple_separate_pure_lists() {
1436        // Multiple pure unordered lists separated by blank lines
1437        // Should NOT be detected as mixed
1438        let content = r#"* First list
1439  * Nested
1440
1441* Second list
1442  * Also nested
1443
1444* Third list
1445  * Deeply
1446    * Nested"#;
1447        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448        assert!(
1449            !ctx.has_mixed_list_nesting(),
1450            "Multiple separate pure unordered lists should not be mixed"
1451        );
1452    }
1453
1454    #[test]
1455    fn test_code_block_between_list_items() {
1456        // Code block between list items should not affect detection
1457        let content = r#"1. Ordered
1458   ```
1459   code
1460   ```
1461   * Still a mixed child"#;
1462        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463        assert!(
1464            ctx.has_mixed_list_nesting(),
1465            "Code block between items should not prevent mixed detection"
1466        );
1467    }
1468
1469    #[test]
1470    fn test_blockquoted_mixed_detection() {
1471        // Mixed lists inside blockquotes should be detected
1472        let content = "> 1. Ordered in blockquote\n>    * Mixed child";
1473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474        // Note: Detection depends on correct marker_column calculation in blockquotes
1475        // This test verifies the detection logic works with blockquoted content
1476        assert!(
1477            ctx.has_mixed_list_nesting(),
1478            "Should detect mixed nesting in blockquotes"
1479        );
1480    }
1481}