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