Skip to main content

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
7pub mod 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    /// Pop list-stack entries that a content line at (`bq_depth`, `visual_indent`)
52    /// has closed. An open item still contains the line only when the line stays
53    /// in the item's blockquote context (or a deeper one) and begins at or past
54    /// the item's content column; otherwise the item has ended. Keeping the stack
55    /// accurate prevents a later list from being mistaken for a sublist of an item
56    /// that already closed (which would, e.g., wrongly extend the ordered-ancestor
57    /// exemption past a terminating paragraph, blockquote, or code block).
58    /// Visual indentation of a line measured in the same coordinate space the
59    /// stack uses for `content_col`: for a blockquoted line that is the width of
60    /// the leading whitespace *after* the `>` prefix(es); for any other line it is
61    /// the absolute `visual_indent`. Comparing a blockquoted line's absolute indent
62    /// (which counts the `>` markers) against a blockquote-relative content column
63    /// would otherwise treat in-quote content as if it had dedented out of the item.
64    /// Measure the line's indentation in the coordinate space of a blockquote at the
65    /// given nesting `depth`: strip exactly `depth` `>` markers (each with one optional
66    /// following space or tab) and return the leading whitespace of the remainder as
67    /// visual columns. At depth 0 this is the line's own visual indent.
68    ///
69    /// The remainder may itself begin with deeper `>` markers; the whitespace measured
70    /// is whatever precedes them, so an interrupting deeper quote reports the column at
71    /// which its `>` begins inside the shallower container. That lets a closed-item check
72    /// compare the line against an item using the item's own quote coordinate space,
73    /// avoiding any relative-vs-absolute mismatch.
74    fn indent_relative_to_depth(
75        ctx: &crate::lint_context::LintContext,
76        line_info: &crate::lint_context::LineInfo,
77        depth: usize,
78    ) -> usize {
79        if depth == 0 {
80            return line_info.visual_indent;
81        }
82        // The blockquote's pre-parsed `content` has its leading whitespace stripped,
83        // so it cannot report the in-quote indentation. Walk the `>` prefix(es) on the
84        // raw line (mirroring the list-item indent calculation) and measure the
85        // whitespace that follows, which is the indent inside the quote container.
86        let line_content = line_info.content(ctx.content);
87        let mut remaining = line_content;
88        let mut content_start = 0;
89        let mut stripped_levels = 0;
90        while stripped_levels < depth {
91            let trimmed = remaining.trim_start();
92            if !trimmed.starts_with('>') {
93                break;
94            }
95            content_start += remaining.len() - trimmed.len();
96            content_start += 1;
97            let after_gt = &trimmed[1..];
98            if let Some(stripped) = after_gt.strip_prefix(' ') {
99                content_start += 1;
100                remaining = stripped;
101            } else if let Some(stripped) = after_gt.strip_prefix('\t') {
102                content_start += 1;
103                remaining = stripped;
104            } else {
105                remaining = after_gt;
106            }
107            stripped_levels += 1;
108        }
109        let content_after_prefix = &line_content[content_start..];
110        let ws_chars = content_after_prefix
111            .chars()
112            .take_while(|c| *c == ' ' || *c == '\t')
113            .count();
114        Self::char_pos_to_visual_column(content_after_prefix, ws_chars)
115    }
116
117    fn terminate_closed_items(
118        ctx: &crate::lint_context::LintContext,
119        line_info: &crate::lint_context::LineInfo,
120        list_stack: &mut Vec<(usize, usize, bool, usize, usize, bool)>,
121        line_bq_depth: usize,
122    ) {
123        while let Some(&(_, _, _, content_col, item_bq_depth, _)) = list_stack.last() {
124            let closed = match item_bq_depth.cmp(&line_bq_depth) {
125                // The line has exited a deeper blockquote the item lived in.
126                std::cmp::Ordering::Greater => true,
127                // The line is in the same or a deeper blockquote than the item.
128                // Measure the line's indent in the item's own quote coordinate space
129                // and close the item when the line begins left of the item's content.
130                // For a same-depth line this is the in-container indent; for a deeper
131                // interrupting quote it is the column where that quote's `>` begins
132                // inside the item's container, so a `> > quote` left of the item's
133                // content (e.g. interrupting `> 1. ordered`) closes it, while a quote
134                // indented into the item's content keeps it open.
135                std::cmp::Ordering::Equal | std::cmp::Ordering::Less => {
136                    content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
137                }
138            };
139            if closed {
140                list_stack.pop();
141            } else {
142                break;
143            }
144        }
145    }
146
147    /// Calculate expected indentation for a nested list item.
148    ///
149    /// This uses per-parent logic rather than document-wide style selection:
150    /// - When parent is **ordered**: align with parent's text (handles variable-width markers)
151    /// - When parent is **unordered**: use configured indent (fixed-width markers)
152    ///
153    /// If user explicitly sets `style`, that choice is respected uniformly.
154    /// "Do What I Mean" behavior: if user sets `indent` but not `style`, use fixed style.
155    fn calculate_expected_indent(
156        &self,
157        nesting_level: usize,
158        parent_info: Option<(bool, usize)>, // (is_ordered, content_visual_col)
159    ) -> usize {
160        if nesting_level == 0 {
161            return 0;
162        }
163
164        // If user explicitly set style, respect their choice uniformly
165        if self.config.style_explicit {
166            return match self.config.style {
167                md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
168                md007_config::IndentStyle::TextAligned => {
169                    parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
170                }
171            };
172        }
173
174        // "Do What I Mean": if indent is explicitly set (but style is not), use fixed style
175        // This is the expected behavior when users configure `indent = 4` - they want 4-space increments
176        if self.config.indent_explicit {
177            match parent_info {
178                Some((true, parent_content_col)) => {
179                    // Parent is ordered: return text-aligned as primary expected value.
180                    // The caller also accepts the fixed indent as an alternative.
181                    return parent_content_col;
182                }
183                _ => {
184                    // Parent is unordered or no parent: use fixed indent
185                    return nesting_level * self.config.indent.get() as usize;
186                }
187            }
188        }
189
190        // Smart default: per-parent type decision
191        match parent_info {
192            Some((true, parent_content_col)) => {
193                // Parent is ordered: align with parent's text position
194                // This handles variable-width markers ("1." vs "10." vs "100.")
195                parent_content_col
196            }
197            Some((false, parent_content_col)) => {
198                // Parent is unordered: check if it's at the expected fixed position
199                // If yes, continue with fixed style (for pure unordered lists)
200                // If no, parent is offset (e.g., inside ordered list), use text-aligned
201                let parent_level = nesting_level.saturating_sub(1);
202                let expected_parent_marker = parent_level * self.config.indent.get() as usize;
203                // Parent's marker column is content column minus marker width (2 for "- ")
204                let parent_marker_col = parent_content_col.saturating_sub(2);
205
206                if parent_marker_col == expected_parent_marker {
207                    // Parent is at expected fixed position, continue with fixed style
208                    nesting_level * self.config.indent.get() as usize
209                } else {
210                    // Parent is offset, use text-aligned
211                    parent_content_col
212                }
213            }
214            None => {
215                // No parent found (shouldn't happen at nesting_level > 0)
216                nesting_level * self.config.indent.get() as usize
217            }
218        }
219    }
220}
221
222impl Rule for MD007ULIndent {
223    fn name(&self) -> &'static str {
224        "MD007"
225    }
226
227    fn description(&self) -> &'static str {
228        "Unordered list indentation"
229    }
230
231    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
232        let mut warnings = Vec::new();
233        let mut list_stack: Vec<(usize, usize, bool, usize, usize, bool)> = Vec::new(); // Stack of (marker_visual_col, line_num, is_ordered, content_visual_col, blockquote_depth, exempt) for tracking nesting. `exempt` marks an unordered item that inherited the ordered-ancestor MD007 exemption.
234
235        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
236            // Skip if this line is in a code block, front matter, or mkdocstrings
237            let is_skipped_region = |info: &crate::lint_context::LineInfo| {
238                info.in_code_block || info.in_front_matter || info.in_mkdocstrings || info.in_footnote_definition
239            };
240            if is_skipped_region(line_info) {
241                // The opening line of such a region (e.g. an unindented code fence)
242                // breaks out of any open list just like a paragraph would, so the
243                // stale list stack must be cleared even though the region's lines
244                // are otherwise skipped. Interior lines (code contents, etc.) are
245                // immaterial: only act on the region's first non-blank line, using
246                // its indentation to decide which items it closed.
247                let region_start = line_idx == 0 || !is_skipped_region(&ctx.lines[line_idx - 1]);
248                if region_start && !line_info.is_blank {
249                    let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
250                    Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
251                }
252                continue;
253            }
254
255            // Check if this line has a list item
256            if let Some(list_item) = &line_info.list_item {
257                // For blockquoted lists, we need to calculate indentation relative to the blockquote content
258                // not the full line. This is because blockquoted lists follow the same indentation rules
259                // as regular lists, just within their blockquote context.
260                let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
261                    // Find the position after ALL blockquote prefixes (handles nested > > > etc)
262                    let line_content = line_info.content(ctx.content);
263                    let mut remaining = line_content;
264                    let mut content_start = 0;
265
266                    loop {
267                        let trimmed = remaining.trim_start();
268                        if !trimmed.starts_with('>') {
269                            break;
270                        }
271                        // Account for leading whitespace
272                        content_start += remaining.len() - trimmed.len();
273                        // Account for '>'
274                        content_start += 1;
275                        let after_gt = &trimmed[1..];
276                        // Handle optional whitespace after '>' (space or tab)
277                        if let Some(stripped) = after_gt.strip_prefix(' ') {
278                            content_start += 1;
279                            remaining = stripped;
280                        } else if let Some(stripped) = after_gt.strip_prefix('\t') {
281                            content_start += 1;
282                            remaining = stripped;
283                        } else {
284                            remaining = after_gt;
285                        }
286                    }
287
288                    // Extract the content after the blockquote prefix
289                    let content_after_prefix = &line_content[content_start..];
290                    // Adjust the marker column to be relative to the content after the prefix
291                    let adjusted_col = if list_item.marker_column >= content_start {
292                        list_item.marker_column - content_start
293                    } else {
294                        // This shouldn't happen, but handle it gracefully
295                        list_item.marker_column
296                    };
297                    (content_after_prefix.to_string(), adjusted_col)
298                } else {
299                    (line_info.content(ctx.content).to_string(), list_item.marker_column)
300                };
301
302                // Convert marker position to visual column
303                let visual_marker_column =
304                    Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
305
306                // Calculate content visual column for text-aligned style
307                let visual_content_column = if line_info.blockquote.is_some() {
308                    // For blockquoted content, we already have the adjusted content
309                    let adjusted_content_col =
310                        if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
311                            list_item.content_column - (line_info.byte_len - content_for_calculation.len())
312                        } else {
313                            list_item.content_column
314                        };
315                    Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
316                } else {
317                    Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
318                };
319
320                // For nesting detection, treat 1-space indent as if it's at column 0
321                // because 1 space is insufficient to establish a nesting relationship
322                // UNLESS the user has explicitly configured indent=1, in which case 1 space IS valid nesting
323                let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
324                    0
325                } else {
326                    visual_marker_column
327                };
328
329                // Determine blockquote depth for this line
330                let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
331
332                // Clean up stack - remove items at same or deeper indentation,
333                // but only consider items at the same blockquote depth
334                while let Some(&(indent, _, _, _, item_bq_depth, _)) = list_stack.last() {
335                    if item_bq_depth == bq_depth && indent >= visual_marker_for_nesting {
336                        list_stack.pop();
337                    } else if item_bq_depth > bq_depth {
338                        // Pop items from deeper blockquote contexts that we've left
339                        list_stack.pop();
340                    } else {
341                        break;
342                    }
343                }
344
345                // The loop above only reconciles items at the same (or deeper)
346                // blockquote depth. A list item that enters a deeper blockquote than an
347                // ancestor (e.g. `> > - item` after `> 1. ordered`, or `> - item` after
348                // a top-level `1. ordered`) starts a separate container when that quote
349                // begins left of the ancestor's content. Measured in the ancestor's own
350                // quote coordinate space, the deeper quote's marker is then to the left
351                // of the ancestor's content column, so the ancestor is closed. Pop it
352                // here, otherwise a closed ordered ancestor would linger and wrongly
353                // extend its exemption to a later, separately indented unordered list.
354                // A deeper quote indented into the ancestor's content is part of that
355                // item and keeps it open. Same-depth nesting and items already inside a
356                // blockquote are left to the loop above and the exemption check below.
357                while let Some(&(_, _, _, content_col, item_bq_depth, _)) = list_stack.last() {
358                    if item_bq_depth < bq_depth
359                        && content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
360                    {
361                        list_stack.pop();
362                    } else {
363                        break;
364                    }
365                }
366
367                // For ordered list items, just track them in the stack
368                if list_item.is_ordered {
369                    // For ordered lists, we don't check indentation but we need to track for text-aligned children
370                    // Use the actual positions since we don't enforce indentation for ordered lists
371                    list_stack.push((
372                        visual_marker_column,
373                        line_idx,
374                        true,
375                        visual_content_column,
376                        bq_depth,
377                        false,
378                    ));
379                    continue;
380                }
381
382                // At this point, we know this is an unordered list item.
383                //
384                // markdownlint applies MD007 to a sublist only if its parent lists
385                // are all also unordered. An unordered item that is genuinely nested
386                // under an ordered ancestor is therefore exempt from the indentation
387                // check, at any depth. Two conditions must both hold:
388                //
389                //   1. threshold: an ordered ancestor at this blockquote depth has its
390                //      content column at or left of this bullet's marker, so the bullet
391                //      is indented far enough to be that ordered item's sublist. A
392                //      bullet indented less than the ordered content column is a new
393                //      top-level list, which markdownlint still checks.
394                //   2. chain: the nearest same-depth ancestor is itself ordered, or is
395                //      an unordered item that already inherited the exemption. This
396                //      stops the exemption from leaking past a non-nested unordered
397                //      parent to its children. For `100. ordered` / `   - parent` /
398                //      `     - child`, the parent is left of the ordered content column
399                //      (not nested, not exempt), so the child resolves against the real
400                //      unordered layout and is still checked.
401                //
402                // The MkDocs flavor is excluded: it deliberately enforces
403                // Python-Markdown's stricter continuation indent under ordered parents
404                // (insufficient indent there is a real rendering bug, not a style nit).
405                let threshold_ok = list_stack
406                    .iter()
407                    .any(|item| item.4 == bq_depth && item.2 && item.3 <= visual_marker_column);
408                let chain_ok = list_stack
409                    .iter()
410                    .rev()
411                    .find(|item| item.4 == bq_depth)
412                    .is_some_and(|item| item.2 || item.5);
413                if ctx.flavor != crate::config::MarkdownFlavor::MkDocs && threshold_ok && chain_ok {
414                    list_stack.push((
415                        visual_marker_column,
416                        line_idx,
417                        false,
418                        visual_content_column,
419                        bq_depth,
420                        true,
421                    ));
422                    continue;
423                }
424
425                // Count only items at the same blockquote depth for nesting level
426                let nesting_level = list_stack.iter().filter(|item| item.4 == bq_depth).count();
427
428                // Get parent info for per-parent calculation (only from same blockquote depth)
429                let parent_info = list_stack
430                    .iter()
431                    .rev()
432                    .find(|item| item.4 == bq_depth)
433                    .map(|&(_, _, is_ordered, content_col, _, _)| (is_ordered, content_col));
434
435                // Calculate expected indent using per-parent logic
436                // When start_indented is true, only depth-0 items use the start_indent value.
437                // For nested items (depth >= 1), the parent's actual position in the stack
438                // already reflects the start_indent shift, so calculate_expected_indent
439                // naturally produces the correct result.
440                let mut expected_indent = if self.config.start_indented && nesting_level == 0 {
441                    self.config.start_indent.get() as usize
442                } else {
443                    self.calculate_expected_indent(nesting_level, parent_info)
444                };
445
446                // When indent is explicitly set and parent is ordered, also accept
447                // the fixed indent value (nesting_level * indent). This lets users
448                // choose either text-aligned or their configured indent under ordered lists.
449                let also_acceptable =
450                    if self.config.indent_explicit && parent_info.is_some_and(|(is_ordered, _)| is_ordered) {
451                        Some(nesting_level * self.config.indent.get() as usize)
452                    } else {
453                        None
454                    };
455
456                // MkDocs (Python-Markdown) uses 4-space-tab continuation for list items.
457                // Under an ordered list item, Python-Markdown requires at least
458                // marker_column + 4 spaces for continuation content to be recognized.
459                if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
460                    && let Some(&(parent_marker_col, _, true, _, _, _)) =
461                        list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2)
462                {
463                    expected_indent = expected_indent.max(parent_marker_col + 4);
464                }
465
466                // Add current item to stack
467                // Use actual marker position for cleanup logic
468                // For text-aligned children, store the EXPECTED content position after fix
469                // (not the actual position) to prevent error cascade
470                // When accepted via also_acceptable, use that indent for content col
471                let accepted_indent = if also_acceptable.is_some_and(|alt| visual_marker_column == alt) {
472                    visual_marker_column
473                } else {
474                    expected_indent
475                };
476                let expected_content_visual_col = accepted_indent + 2;
477                list_stack.push((
478                    visual_marker_column,
479                    line_idx,
480                    false,
481                    expected_content_visual_col,
482                    bq_depth,
483                    false,
484                ));
485
486                // A top-level item (depth 0) is expected at column 0 when start_indented
487                // is false. Column 0 is already correct, so skip it; any other column
488                // (1, 2, or 3) is a misindented top-level list and must be flagged with
489                // "Expected 0". Four or more leading spaces form an indented code block,
490                // not a list, so such lines never reach here as list items.
491                if !self.config.start_indented && nesting_level == 0 && visual_marker_column == 0 {
492                    continue;
493                }
494
495                if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
496                    // Use the fixed indent as the suggested value when the alternative was available
497                    if let Some(alt) = also_acceptable {
498                        expected_indent = alt;
499                    }
500                    // Generate fix for this list item
501                    let fix = {
502                        let correct_indent = " ".repeat(expected_indent);
503
504                        // Build the replacement string - need to preserve everything before the list marker
505                        // For blockquoted lines, this includes the blockquote prefix
506                        let replacement = if line_info.blockquote.is_some() {
507                            // Count the blockquote markers
508                            let mut blockquote_count = 0;
509                            for ch in line_info.content(ctx.content).chars() {
510                                if ch == '>' {
511                                    blockquote_count += 1;
512                                } else if ch != ' ' && ch != '\t' {
513                                    break;
514                                }
515                            }
516                            // Build the blockquote prefix (one '>' per level, with spaces between for nested)
517                            let blockquote_prefix = if blockquote_count > 1 {
518                                (0..blockquote_count)
519                                    .map(|_| "> ")
520                                    .collect::<String>()
521                                    .trim_end()
522                                    .to_string()
523                            } else {
524                                ">".to_string()
525                            };
526                            // Add correct indentation after the blockquote prefix
527                            // Include one space after the blockquote marker(s) as part of the indent
528                            format!("{blockquote_prefix} {correct_indent}")
529                        } else {
530                            correct_indent
531                        };
532
533                        // Calculate the byte positions
534                        // The range should cover from start of line to the marker position
535                        let start_byte = line_info.byte_offset;
536                        let mut end_byte = line_info.byte_offset;
537
538                        // Calculate where the marker starts
539                        for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
540                            if i >= list_item.marker_column {
541                                break;
542                            }
543                            end_byte += ch.len_utf8();
544                        }
545
546                        Some(crate::rule::Fix::new(start_byte..end_byte, replacement))
547                    };
548
549                    warnings.push(LintWarning {
550                        rule_name: Some(self.name().to_string()),
551                        message: format!(
552                            "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
553                        ),
554                        line: line_idx + 1, // Convert to 1-indexed
555                        column: 1,          // Start of line
556                        end_line: line_idx + 1,
557                        end_column: visual_marker_column + 1, // End of visual indentation
558                        severity: Severity::Warning,
559                        fix,
560                    });
561                }
562            } else if !line_info.is_blank {
563                // A non-blank, non-list content line that breaks out of the open
564                // list terminates every list item whose content begins to its
565                // right: an item's children must be indented past its content
566                // column, so a line indented less cannot belong to it. Popping
567                // these closed items keeps list_stack accurate, so a later list
568                // is not mistaken for a sublist of one that has already ended
569                // (e.g. a top-level paragraph closing an ordered list, after
570                // which a separately indented bullet is a new top-level list).
571                //
572                // A CommonMark lazy continuation line is the exception: plain
573                // paragraph text that immediately follows the item (no blank line
574                // between) continues the item's open paragraph and so keeps the
575                // list open. Constructs that interrupt a paragraph (ATX heading,
576                // thematic break, fenced code, HTML block, HTML comment, div block)
577                // end the list even without a blank line, matching markdownlint. A
578                // line beginning with
579                // a list marker is likewise not lazy paragraph text - it would start
580                // a new list item - so it must still terminate stale ancestors (e.g.
581                // a deeper bullet that pulldown-cmark treats as item content rather
582                // than a sublist).
583                //
584                // Blockquotes need container awareness: a continuation in the *same*
585                // quote (`> text` after `> 1. item`) is lazy, but newly entering a
586                // quote (`> text` after a non-quoted item) interrupts the paragraph
587                // and ends the list. So compare the previous line's quote depth, and
588                // examine the marker on the quote-stripped content.
589                let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
590                let prev_line = line_idx.checked_sub(1).map(|i| &ctx.lines[i]);
591                let prev_blank = prev_line.is_none_or(|p| p.is_blank);
592                let prev_bq_depth = prev_line
593                    .and_then(|p| p.blockquote.as_ref())
594                    .map_or(0, |bq| bq.nesting_level);
595                let same_container = prev_bq_depth == bq_depth;
596                let text = line_info
597                    .blockquote
598                    .as_ref()
599                    .map_or_else(|| line_info.content(ctx.content), |bq| bq.content.as_str());
600                let trimmed = text.trim_start();
601                let starts_like_list_marker = match trimmed.as_bytes().first() {
602                    Some(b'-' | b'*' | b'+') => {
603                        matches!(trimmed.as_bytes().get(1), Some(b' ' | b'\t'))
604                    }
605                    Some(c) if c.is_ascii_digit() => {
606                        // CommonMark allows at most 9 digits in an ordered list marker.
607                        // A longer digit run is not a marker, so the line can be lazy
608                        // paragraph text rather than a list-interrupting item.
609                        let after_digits = trimmed.trim_start_matches(|ch: char| ch.is_ascii_digit());
610                        let num_digits = trimmed.len() - after_digits.len();
611                        let mut rest = after_digits.chars();
612                        (1..=9).contains(&num_digits)
613                            && matches!(rest.next(), Some('.' | ')'))
614                            && matches!(rest.next(), Some(' ' | '\t') | None)
615                    }
616                    _ => false,
617                };
618                // Lazy continuation only extends an OPEN paragraph. The previous line
619                // must itself be paragraph text (or a list-item line whose paragraph the
620                // current line continues), not a closed block such as a fenced code
621                // block, heading, thematic break, HTML block/comment, or div marker.
622                // After such a block, an unindented line starts a new paragraph and
623                // closes the list instead of lazily continuing it.
624                let prev_is_open_paragraph = prev_line.is_some_and(|p| {
625                    !p.is_blank
626                        && !p.in_code_block
627                        && p.heading.is_none()
628                        && !p.is_horizontal_rule
629                        && !p.in_html_block
630                        && !p.in_html_comment
631                        && !p.is_div_marker
632                });
633                let is_lazy_paragraph_continuation = !prev_blank
634                    && prev_is_open_paragraph
635                    && same_container
636                    && !starts_like_list_marker
637                    && line_info.heading.is_none()
638                    && !line_info.is_horizontal_rule
639                    && !line_info.in_code_block
640                    && !line_info.in_html_block
641                    && !line_info.in_html_comment
642                    && !line_info.is_div_marker;
643                if is_lazy_paragraph_continuation {
644                    // Lazy continuation: the list stays open, leave the stack intact.
645                    continue;
646                }
647                Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
648            }
649        }
650        Ok(warnings)
651    }
652
653    /// Optimized check using document structure
654    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
655        // Get all warnings with their fixes
656        let warnings = self.check(ctx)?;
657        let warnings =
658            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
659
660        // If no warnings, return original content
661        if warnings.is_empty() {
662            return Ok(ctx.content.to_string());
663        }
664
665        // Collect all fixes and sort by range start (descending) to apply from end to beginning
666        let mut fixes: Vec<_> = warnings
667            .iter()
668            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
669            .collect();
670        fixes.sort_by(|a, b| b.0.cmp(&a.0));
671
672        // Apply fixes from end to beginning to preserve byte offsets
673        let mut result = ctx.content.to_string();
674        for (start, end, replacement) in fixes {
675            if start < result.len() && end <= result.len() && start <= end {
676                result.replace_range(start..end, replacement);
677            }
678        }
679
680        Ok(result)
681    }
682
683    /// Get the category of this rule for selective processing
684    fn category(&self) -> RuleCategory {
685        RuleCategory::List
686    }
687
688    /// Check if this rule should be skipped
689    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
690        // Fast path: check if document likely has lists
691        if ctx.content.is_empty() || !ctx.likely_has_lists() {
692            return true;
693        }
694        // Verify unordered list items actually exist
695        !ctx.lines
696            .iter()
697            .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
698    }
699
700    fn as_any(&self) -> &dyn std::any::Any {
701        self
702    }
703
704    fn default_config_section(&self) -> Option<(String, toml::Value)> {
705        let default_config = MD007Config::default();
706        let json_value = serde_json::to_value(&default_config).ok()?;
707        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
708
709        if let toml::Value::Table(table) = toml_value {
710            if !table.is_empty() {
711                Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
712            } else {
713                None
714            }
715        } else {
716            None
717        }
718    }
719
720    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
721    where
722        Self: Sized,
723    {
724        let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
725
726        // Check if style and/or indent were explicitly set in the config
727        if let Some(rule_cfg) = config.rules.get("MD007") {
728            rule_config.style_explicit = rule_cfg.values.contains_key("style");
729            rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
730
731            // Warn if both indent and text-aligned style are explicitly set
732            // This combination is contradictory: indent implies fixed increments,
733            // but text-aligned ignores the indent value and aligns with parent text
734            if rule_config.indent_explicit
735                && rule_config.style_explicit
736                && rule_config.style == md007_config::IndentStyle::TextAligned
737            {
738                eprintln!(
739                    "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
740                     Text-aligned style ignores indent and aligns nested items with parent text. \
741                     To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
742                    rule_config.indent.get()
743                );
744            }
745        }
746
747        // MkDocs/Python-Markdown requires 4-space indentation for nested list content.
748        // Enforce indent=4 and style=fixed regardless of user config.
749        if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
750            if rule_config.indent_explicit && rule_config.indent.get() < 4 {
751                eprintln!(
752                    "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires indent >= 4 \
753                     (Python-Markdown enforces 4-space indentation). \
754                     Overriding indent={} to indent=4.",
755                    rule_config.indent.get()
756                );
757            }
758            if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
759                eprintln!(
760                    "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires style=\"fixed\" \
761                     (Python-Markdown uses fixed 4-space indentation). \
762                     Overriding style=\"text-aligned\" to style=\"fixed\"."
763                );
764            }
765            if rule_config.indent.get() < 4 {
766                rule_config.indent = crate::types::IndentSize::from_const(4);
767            }
768            rule_config.style = md007_config::IndentStyle::Fixed;
769        }
770
771        Box::new(Self::from_config_struct(rule_config))
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use crate::lint_context::LintContext;
779    use crate::rule::Rule;
780
781    #[test]
782    fn test_valid_list_indent() {
783        let rule = MD007ULIndent::default();
784        let content = "* Item 1\n  * Item 2\n    * Item 3";
785        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786        let result = rule.check(&ctx).unwrap();
787        assert!(
788            result.is_empty(),
789            "Expected no warnings for valid indentation, but got {} warnings",
790            result.len()
791        );
792    }
793
794    #[test]
795    fn test_invalid_list_indent() {
796        let rule = MD007ULIndent::default();
797        let content = "* Item 1\n   * Item 2\n      * Item 3";
798        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799        let result = rule.check(&ctx).unwrap();
800        assert_eq!(result.len(), 2);
801        assert_eq!(result[0].line, 2);
802        assert_eq!(result[0].column, 1);
803        assert_eq!(result[1].line, 3);
804        assert_eq!(result[1].column, 1);
805    }
806
807    #[test]
808    fn test_mixed_indentation() {
809        let rule = MD007ULIndent::default();
810        let content = "* Item 1\n  * Item 2\n   * Item 3\n  * Item 4";
811        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812        let result = rule.check(&ctx).unwrap();
813        assert_eq!(result.len(), 1);
814        assert_eq!(result[0].line, 3);
815        assert_eq!(result[0].column, 1);
816    }
817
818    #[test]
819    fn test_fix_indentation() {
820        let rule = MD007ULIndent::default();
821        let content = "* Item 1\n   * Item 2\n      * Item 3";
822        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823        let result = rule.fix(&ctx).unwrap();
824        // With text-aligned style and non-cascade:
825        // Item 2 aligns with Item 1's text (2 spaces)
826        // Item 3 aligns with Item 2's expected text position (4 spaces)
827        let expected = "* Item 1\n  * Item 2\n    * Item 3";
828        assert_eq!(result, expected);
829    }
830
831    #[test]
832    fn test_md007_in_yaml_code_block() {
833        let rule = MD007ULIndent::default();
834        let content = r#"```yaml
835repos:
836-   repo: https://github.com/rvben/rumdl
837    rev: v0.5.0
838    hooks:
839    -   id: rumdl-check
840```"#;
841        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842        let result = rule.check(&ctx).unwrap();
843        assert!(
844            result.is_empty(),
845            "MD007 should not trigger inside a code block, but got warnings: {result:?}"
846        );
847    }
848
849    #[test]
850    fn test_blockquoted_list_indent() {
851        let rule = MD007ULIndent::default();
852        let content = "> * Item 1\n>   * Item 2\n>     * Item 3";
853        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854        let result = rule.check(&ctx).unwrap();
855        assert!(
856            result.is_empty(),
857            "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
858        );
859    }
860
861    #[test]
862    fn test_blockquoted_list_invalid_indent() {
863        let rule = MD007ULIndent::default();
864        let content = "> * Item 1\n>    * Item 2\n>       * Item 3";
865        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866        let result = rule.check(&ctx).unwrap();
867        assert_eq!(
868            result.len(),
869            2,
870            "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
871        );
872        assert_eq!(result[0].line, 2);
873        assert_eq!(result[1].line, 3);
874    }
875
876    #[test]
877    fn test_nested_blockquote_list_indent() {
878        let rule = MD007ULIndent::default();
879        let content = "> > * Item 1\n> >   * Item 2\n> >     * Item 3";
880        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881        let result = rule.check(&ctx).unwrap();
882        assert!(
883            result.is_empty(),
884            "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
885        );
886    }
887
888    #[test]
889    fn test_blockquote_list_with_code_block() {
890        let rule = MD007ULIndent::default();
891        let content = "> * Item 1\n>   * Item 2\n>   ```\n>   code\n>   ```\n>   * Item 3";
892        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893        let result = rule.check(&ctx).unwrap();
894        assert!(
895            result.is_empty(),
896            "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
897        );
898    }
899
900    #[test]
901    fn test_properly_indented_lists() {
902        let rule = MD007ULIndent::default();
903
904        // Test various properly indented lists
905        let test_cases = vec![
906            "* Item 1\n* Item 2",
907            "* Item 1\n  * Item 1.1\n    * Item 1.1.1",
908            "- Item 1\n  - Item 1.1",
909            "+ Item 1\n  + Item 1.1",
910            "* Item 1\n  * Item 1.1\n* Item 2\n  * Item 2.1",
911        ];
912
913        for content in test_cases {
914            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915            let result = rule.check(&ctx).unwrap();
916            assert!(
917                result.is_empty(),
918                "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
919                content,
920                result.len()
921            );
922        }
923    }
924
925    #[test]
926    fn test_under_indented_lists() {
927        let rule = MD007ULIndent::default();
928
929        let test_cases = vec![
930            ("* Item 1\n * Item 1.1", 1, 2),                   // Expected 2 spaces, got 1
931            ("* Item 1\n  * Item 1.1\n   * Item 1.1.1", 1, 3), // Expected 4 spaces, got 3
932        ];
933
934        for (content, expected_warnings, line) in test_cases {
935            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936            let result = rule.check(&ctx).unwrap();
937            assert_eq!(
938                result.len(),
939                expected_warnings,
940                "Expected {expected_warnings} warnings for under-indented list:\n{content}"
941            );
942            if expected_warnings > 0 {
943                assert_eq!(result[0].line, line);
944            }
945        }
946    }
947
948    #[test]
949    fn test_over_indented_lists() {
950        let rule = MD007ULIndent::default();
951
952        let test_cases = vec![
953            ("* Item 1\n   * Item 1.1", 1, 2),                   // Expected 2 spaces, got 3
954            ("* Item 1\n    * Item 1.1", 1, 2),                  // Expected 2 spaces, got 4
955            ("* Item 1\n  * Item 1.1\n     * Item 1.1.1", 1, 3), // Expected 4 spaces, got 5
956        ];
957
958        for (content, expected_warnings, line) in test_cases {
959            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960            let result = rule.check(&ctx).unwrap();
961            assert_eq!(
962                result.len(),
963                expected_warnings,
964                "Expected {expected_warnings} warnings for over-indented list:\n{content}"
965            );
966            if expected_warnings > 0 {
967                assert_eq!(result[0].line, line);
968            }
969        }
970    }
971
972    #[test]
973    fn test_custom_indent_2_spaces() {
974        let rule = MD007ULIndent::new(2); // Default
975        let content = "* Item 1\n  * Item 2\n    * Item 3";
976        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977        let result = rule.check(&ctx).unwrap();
978        assert!(result.is_empty());
979    }
980
981    #[test]
982    fn test_custom_indent_3_spaces() {
983        // With smart auto-detection, pure unordered lists with indent=3 use fixed style
984        // This provides markdownlint compatibility for the common case
985        let rule = MD007ULIndent::new(3);
986
987        // Fixed style with indent=3: level 0 = 0, level 1 = 3, level 2 = 6
988        let correct_content = "* Item 1\n   * Item 2\n      * Item 3";
989        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
990        let result = rule.check(&ctx).unwrap();
991        assert!(
992            result.is_empty(),
993            "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
994        );
995
996        // Wrong indentation (text-aligned style spacing)
997        let wrong_content = "* Item 1\n  * Item 2\n    * Item 3";
998        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
999        let result = rule.check(&ctx).unwrap();
1000        assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
1001    }
1002
1003    #[test]
1004    fn test_custom_indent_4_spaces() {
1005        // With smart auto-detection, pure unordered lists with indent=4 use fixed style
1006        // This provides markdownlint compatibility (fixes issue #210)
1007        let rule = MD007ULIndent::new(4);
1008
1009        // Fixed style with indent=4: level 0 = 0, level 1 = 4, level 2 = 8
1010        let correct_content = "* Item 1\n    * Item 2\n        * Item 3";
1011        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1012        let result = rule.check(&ctx).unwrap();
1013        assert!(
1014            result.is_empty(),
1015            "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
1016        );
1017
1018        // Wrong indentation (text-aligned style spacing)
1019        let wrong_content = "* Item 1\n  * Item 2\n    * Item 3";
1020        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1021        let result = rule.check(&ctx).unwrap();
1022        assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
1023    }
1024
1025    #[test]
1026    fn test_tab_indentation() {
1027        let rule = MD007ULIndent::default();
1028
1029        // Note: Tab at line start = 4 spaces = indented code per CommonMark, not a list item
1030        // MD007 checks list indentation, so this test now checks actual nested lists
1031        // Hard tabs within lists should be caught by MD010, not MD007
1032
1033        // Single wrong indentation (3 spaces instead of 2)
1034        let content = "* Item 1\n   * Item 2";
1035        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036        let result = rule.check(&ctx).unwrap();
1037        assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
1038
1039        // Fix should correct to 2 spaces
1040        let fixed = rule.fix(&ctx).unwrap();
1041        assert_eq!(fixed, "* Item 1\n  * Item 2");
1042
1043        // Multiple indentation errors
1044        let content_multi = "* Item 1\n   * Item 2\n      * Item 3";
1045        let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
1046        let fixed = rule.fix(&ctx).unwrap();
1047        // With non-cascade: Item 2 at 2 spaces, content at 4
1048        // Item 3 aligns with Item 2's expected content at 4 spaces
1049        assert_eq!(fixed, "* Item 1\n  * Item 2\n    * Item 3");
1050
1051        // Mixed wrong indentations
1052        let content_mixed = "* Item 1\n   * Item 2\n     * Item 3";
1053        let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
1054        let fixed = rule.fix(&ctx).unwrap();
1055        // With non-cascade: Item 2 at 2 spaces, content at 4
1056        // Item 3 aligns with Item 2's expected content at 4 spaces
1057        assert_eq!(fixed, "* Item 1\n  * Item 2\n    * Item 3");
1058    }
1059
1060    #[test]
1061    fn test_mixed_ordered_unordered_lists() {
1062        let rule = MD007ULIndent::default();
1063
1064        // MD007 only checks unordered lists, so ordered lists should be ignored
1065        // Note: 3 spaces is now correct for bullets under ordered items
1066        let content = r#"1. Ordered item
1067   * Unordered sub-item (correct - 3 spaces under ordered)
1068   2. Ordered sub-item
1069* Unordered item
1070  1. Ordered sub-item
1071  * Unordered sub-item"#;
1072
1073        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1074        let result = rule.check(&ctx).unwrap();
1075        assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
1076
1077        // No fix needed as all indentation is correct
1078        let fixed = rule.fix(&ctx).unwrap();
1079        assert_eq!(fixed, content);
1080    }
1081
1082    #[test]
1083    fn test_list_markers_variety() {
1084        let rule = MD007ULIndent::default();
1085
1086        // Test all three unordered list markers
1087        let content = r#"* Asterisk
1088  * Nested asterisk
1089- Hyphen
1090  - Nested hyphen
1091+ Plus
1092  + Nested plus"#;
1093
1094        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1095        let result = rule.check(&ctx).unwrap();
1096        assert!(
1097            result.is_empty(),
1098            "All unordered list markers should work with proper indentation"
1099        );
1100
1101        // Test with wrong indentation for each marker type
1102        let wrong_content = r#"* Asterisk
1103   * Wrong asterisk
1104- Hyphen
1105 - Wrong hyphen
1106+ Plus
1107    + Wrong plus"#;
1108
1109        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1110        let result = rule.check(&ctx).unwrap();
1111        assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
1112    }
1113
1114    #[test]
1115    fn test_empty_list_items() {
1116        let rule = MD007ULIndent::default();
1117        let content = "* Item 1\n* \n  * Item 2";
1118        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119        let result = rule.check(&ctx).unwrap();
1120        assert!(
1121            result.is_empty(),
1122            "Empty list items should not affect indentation checks"
1123        );
1124    }
1125
1126    #[test]
1127    fn test_list_with_code_blocks() {
1128        let rule = MD007ULIndent::default();
1129        let content = r#"* Item 1
1130  ```
1131  code
1132  ```
1133  * Item 2
1134    * Item 3"#;
1135        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1136        let result = rule.check(&ctx).unwrap();
1137        assert!(result.is_empty());
1138    }
1139
1140    #[test]
1141    fn test_list_in_front_matter() {
1142        let rule = MD007ULIndent::default();
1143        let content = r#"---
1144tags:
1145  - tag1
1146  - tag2
1147---
1148* Item 1
1149  * Item 2"#;
1150        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151        let result = rule.check(&ctx).unwrap();
1152        assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
1153    }
1154
1155    #[test]
1156    fn test_fix_preserves_content() {
1157        let rule = MD007ULIndent::default();
1158        let content = "* Item 1 with **bold** and *italic*\n   * Item 2 with `code`\n     * Item 3 with [link](url)";
1159        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160        let fixed = rule.fix(&ctx).unwrap();
1161        // With non-cascade: Item 2 at 2 spaces, content at 4
1162        // Item 3 aligns with Item 2's expected content at 4 spaces
1163        let expected = "* Item 1 with **bold** and *italic*\n  * Item 2 with `code`\n    * Item 3 with [link](url)";
1164        assert_eq!(fixed, expected, "Fix should only change indentation, not content");
1165    }
1166
1167    #[test]
1168    fn test_start_indented_config() {
1169        let config = MD007Config {
1170            start_indented: true,
1171            start_indent: crate::types::IndentSize::from_const(4),
1172            indent: crate::types::IndentSize::from_const(2),
1173            style: md007_config::IndentStyle::TextAligned,
1174            style_explicit: true, // Explicit style for this test
1175            indent_explicit: false,
1176        };
1177        let rule = MD007ULIndent::from_config_struct(config);
1178
1179        // First level should be indented by start_indent (4 spaces)
1180        // Level 0: 4 spaces (start_indent)
1181        // Level 1: 6 spaces (start_indent + indent = 4 + 2)
1182        // Level 2: 8 spaces (start_indent + 2*indent = 4 + 4)
1183        let content = "    * Item 1\n      * Item 2\n        * Item 3";
1184        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1185        let result = rule.check(&ctx).unwrap();
1186        assert!(result.is_empty(), "Expected no warnings with start_indented config");
1187
1188        // Wrong first level indentation
1189        let wrong_content = "  * Item 1\n    * Item 2";
1190        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1191        let result = rule.check(&ctx).unwrap();
1192        assert_eq!(result.len(), 2);
1193        assert_eq!(result[0].line, 1);
1194        assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
1195        assert_eq!(result[1].line, 2);
1196        assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
1197
1198        // Fix should correct to start_indent for first level
1199        let fixed = rule.fix(&ctx).unwrap();
1200        assert_eq!(fixed, "    * Item 1\n      * Item 2");
1201    }
1202
1203    #[test]
1204    fn test_start_indented_false_flags_indented_first_level() {
1205        let rule = MD007ULIndent::default(); // start_indented is false by default
1206
1207        // When start_indented is false, a top-level item is expected at column 0. A
1208        // top-level item indented 1-3 spaces is a misindented list and must be flagged
1209        // with "Expected 0", matching markdownlint-cli2 (which reports Expected: 0;
1210        // Actual: 3 here).
1211        let content = "   * Item 1"; // First level at 3 spaces
1212        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213        let result = rule.check(&ctx).unwrap();
1214        assert!(
1215            result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1216            "a top-level item indented 3 spaces must be flagged with Expected 0, got: {result:?}"
1217        );
1218
1219        // A correctly nested list (0/2/4 spaces) produces no warnings: these are a
1220        // top-level item and its properly indented descendants, not three first-level
1221        // items.
1222        let content = "* Item 1\n  * Item 2\n    * Item 3";
1223        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224        let result = rule.check(&ctx).unwrap();
1225        assert!(
1226            result.is_empty(),
1227            "a correctly nested 0/2/4-space list should produce no warnings, got: {result:?}"
1228        );
1229    }
1230
1231    #[test]
1232    fn test_deeply_nested_lists() {
1233        let rule = MD007ULIndent::default();
1234        let content = r#"* L1
1235  * L2
1236    * L3
1237      * L4
1238        * L5
1239          * L6"#;
1240        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1241        let result = rule.check(&ctx).unwrap();
1242        assert!(result.is_empty());
1243
1244        // Test with wrong deep nesting
1245        let wrong_content = r#"* L1
1246  * L2
1247    * L3
1248      * L4
1249         * L5
1250            * L6"#;
1251        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1252        let result = rule.check(&ctx).unwrap();
1253        assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
1254    }
1255
1256    #[test]
1257    fn test_excessive_indentation_detected() {
1258        let rule = MD007ULIndent::default();
1259
1260        // Test excessive indentation (5 spaces instead of 2)
1261        let content = "- Item 1\n     - Item 2 with 5 spaces";
1262        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1263        let result = rule.check(&ctx).unwrap();
1264        assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
1265        assert_eq!(result[0].line, 2);
1266        assert!(result[0].message.contains("Expected 2 spaces"));
1267        assert!(result[0].message.contains("found 5"));
1268
1269        // Test slightly excessive indentation (3 spaces instead of 2)
1270        let content = "- Item 1\n   - Item 2 with 3 spaces";
1271        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272        let result = rule.check(&ctx).unwrap();
1273        assert_eq!(
1274            result.len(),
1275            1,
1276            "Should detect slightly excessive indentation (3 instead of 2)"
1277        );
1278        assert_eq!(result[0].line, 2);
1279        assert!(result[0].message.contains("Expected 2 spaces"));
1280        assert!(result[0].message.contains("found 3"));
1281
1282        // Test insufficient indentation (1 space is treated as level 0, should be 0)
1283        let content = "- Item 1\n - Item 2 with 1 space";
1284        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1285        let result = rule.check(&ctx).unwrap();
1286        assert_eq!(
1287            result.len(),
1288            1,
1289            "Should detect 1-space indent (insufficient for nesting, expected 0)"
1290        );
1291        assert_eq!(result[0].line, 2);
1292        assert!(result[0].message.contains("Expected 0 spaces"));
1293        assert!(result[0].message.contains("found 1"));
1294    }
1295
1296    #[test]
1297    fn test_excessive_indentation_with_4_space_config() {
1298        // With smart auto-detection, pure unordered lists use fixed style
1299        // Fixed style with indent=4: level 0 = 0, level 1 = 4, level 2 = 8
1300        let rule = MD007ULIndent::new(4);
1301
1302        // Test excessive indentation (5 spaces instead of 4)
1303        let content = "- Formatter:\n     - The stable style changed";
1304        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1305        let result = rule.check(&ctx).unwrap();
1306        assert!(
1307            !result.is_empty(),
1308            "Should detect 5 spaces when expecting 4 (fixed style)"
1309        );
1310
1311        // Test with correct fixed style alignment (4 spaces for level 1)
1312        let correct_content = "- Formatter:\n    - The stable style changed";
1313        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1314        let result = rule.check(&ctx).unwrap();
1315        assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
1316    }
1317
1318    #[test]
1319    fn test_bullets_nested_under_numbered_items() {
1320        let rule = MD007ULIndent::default();
1321        let content = "\
13221. **Active Directory/LDAP**
1323   - User authentication and directory services
1324   - LDAP for user information and validation
1325
13262. **Oracle Unified Directory (OUD)**
1327   - Extended user directory services";
1328        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329        let result = rule.check(&ctx).unwrap();
1330        // Should have no warnings - 3 spaces is correct for bullets under numbered items
1331        assert!(
1332            result.is_empty(),
1333            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1334        );
1335    }
1336
1337    #[test]
1338    fn test_bullets_nested_under_numbered_items_wrong_indent() {
1339        let rule = MD007ULIndent::default();
1340        let content = "\
13411. **Active Directory/LDAP**
1342  - Wrong: only 2 spaces";
1343        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344        let result = rule.check(&ctx).unwrap();
1345        // Should flag incorrect indentation
1346        assert_eq!(
1347            result.len(),
1348            1,
1349            "Expected warning for incorrect indentation under numbered items"
1350        );
1351        assert!(
1352            result
1353                .iter()
1354                .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1355        );
1356    }
1357
1358    #[test]
1359    fn test_regular_bullet_nesting_still_works() {
1360        let rule = MD007ULIndent::default();
1361        let content = "\
1362* Top level
1363  * Nested bullet (2 spaces is correct)
1364    * Deeply nested (4 spaces)";
1365        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366        let result = rule.check(&ctx).unwrap();
1367        // Should have no warnings - standard bullet nesting still uses 2-space increments
1368        assert!(
1369            result.is_empty(),
1370            "Expected no warnings for standard bullet nesting, got: {result:?}"
1371        );
1372    }
1373
1374    #[test]
1375    fn test_blockquote_with_tab_after_marker() {
1376        let rule = MD007ULIndent::default();
1377        let content = ">\t* List item\n>\t  * Nested\n";
1378        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1379        let result = rule.check(&ctx).unwrap();
1380        assert!(
1381            result.is_empty(),
1382            "Tab after blockquote marker should be handled correctly, got: {result:?}"
1383        );
1384    }
1385
1386    #[test]
1387    fn test_blockquote_with_space_then_tab_after_marker() {
1388        let rule = MD007ULIndent::default();
1389        let content = "> \t* List item\n";
1390        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391        let result = rule.check(&ctx).unwrap();
1392        // Inside the blockquote the bullet is indented away from column 0, so it is a
1393        // misindented top-level list and is flagged with "Expected 0", matching
1394        // markdownlint-cli2 (which flags Expected: 0). The reported actual column
1395        // reflects rumdl's CommonMark tab-stop expansion rather than a raw char count.
1396        assert!(
1397            result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1398            "an indented blockquoted top-level item must be flagged with Expected 0, got: {result:?}"
1399        );
1400    }
1401
1402    #[test]
1403    fn test_blockquote_with_multiple_tabs() {
1404        let rule = MD007ULIndent::default();
1405        let content = ">\t\t* List item\n";
1406        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407        let result = rule.check(&ctx).unwrap();
1408        // First-level list item at any indentation is allowed when start_indented=false (default)
1409        assert!(
1410            result.is_empty(),
1411            "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1412        );
1413    }
1414
1415    #[test]
1416    fn test_nested_blockquote_with_tab() {
1417        let rule = MD007ULIndent::default();
1418        let content = ">\t>\t* List item\n>\t>\t  * Nested\n";
1419        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420        let result = rule.check(&ctx).unwrap();
1421        assert!(
1422            result.is_empty(),
1423            "Nested blockquotes with tabs should work correctly, got: {result:?}"
1424        );
1425    }
1426
1427    // Tests for smart style auto-detection (fixes issue #210 while preserving #209 fix)
1428
1429    #[test]
1430    fn test_smart_style_pure_unordered_uses_fixed() {
1431        // Issue #210: Pure unordered lists with custom indent should use fixed style
1432        let rule = MD007ULIndent::new(4);
1433
1434        // With fixed style (auto-detected), this should be valid
1435        let content = "* Level 0\n    * Level 1\n        * Level 2";
1436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1437        let result = rule.check(&ctx).unwrap();
1438        assert!(
1439            result.is_empty(),
1440            "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1441        );
1442    }
1443
1444    #[test]
1445    fn test_smart_style_mixed_lists_uses_text_aligned() {
1446        // Issue #209: Mixed lists should use text-aligned to avoid oscillation
1447        let rule = MD007ULIndent::new(4);
1448
1449        // With text-aligned style (auto-detected for mixed), bullets align with parent text
1450        let content = "1. Ordered\n   * Bullet aligns with 'Ordered' text (3 spaces)";
1451        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1452        let result = rule.check(&ctx).unwrap();
1453        assert!(
1454            result.is_empty(),
1455            "Mixed lists should use text-aligned style, got: {result:?}"
1456        );
1457    }
1458
1459    #[test]
1460    fn test_smart_style_explicit_fixed_overrides() {
1461        // When style is explicitly set to fixed, it should be respected even for mixed lists
1462        let config = MD007Config {
1463            indent: crate::types::IndentSize::from_const(4),
1464            start_indented: false,
1465            start_indent: crate::types::IndentSize::from_const(2),
1466            style: md007_config::IndentStyle::Fixed,
1467            style_explicit: true, // Explicit setting
1468            indent_explicit: false,
1469        };
1470        let rule = MD007ULIndent::from_config_struct(config);
1471
1472        // With explicit fixed style, expect fixed calculations even for mixed lists
1473        let content = "1. Ordered\n    * Should be at 4 spaces (fixed)";
1474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475        let result = rule.check(&ctx).unwrap();
1476        // The bullet is at 4 spaces which matches fixed style level 1
1477        assert!(
1478            result.is_empty(),
1479            "Explicit fixed style should be respected, got: {result:?}"
1480        );
1481    }
1482
1483    #[test]
1484    fn test_smart_style_explicit_text_aligned_overrides() {
1485        // When style is explicitly set to text-aligned, it should be respected
1486        let config = MD007Config {
1487            indent: crate::types::IndentSize::from_const(4),
1488            start_indented: false,
1489            start_indent: crate::types::IndentSize::from_const(2),
1490            style: md007_config::IndentStyle::TextAligned,
1491            style_explicit: true, // Explicit setting
1492            indent_explicit: false,
1493        };
1494        let rule = MD007ULIndent::from_config_struct(config);
1495
1496        // With explicit text-aligned, pure unordered should use text-aligned (not auto-switch to fixed)
1497        let content = "* Level 0\n  * Level 1 (aligned with 'Level 0' text)";
1498        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1499        let result = rule.check(&ctx).unwrap();
1500        assert!(
1501            result.is_empty(),
1502            "Explicit text-aligned should be respected, got: {result:?}"
1503        );
1504
1505        // This would be correct for fixed but wrong for text-aligned
1506        let fixed_style_content = "* Level 0\n    * Level 1 (4 spaces - fixed style)";
1507        let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1508        let result = rule.check(&ctx).unwrap();
1509        assert!(
1510            !result.is_empty(),
1511            "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1512        );
1513    }
1514
1515    #[test]
1516    fn test_smart_style_default_indent_no_autoswitch() {
1517        // When indent is default (2), no auto-switch happens (both styles produce same result)
1518        let rule = MD007ULIndent::new(2);
1519
1520        let content = "* Level 0\n  * Level 1\n    * Level 2";
1521        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1522        let result = rule.check(&ctx).unwrap();
1523        assert!(
1524            result.is_empty(),
1525            "Default indent should work regardless of style, got: {result:?}"
1526        );
1527    }
1528
1529    #[test]
1530    fn test_has_mixed_list_nesting_detection() {
1531        // Test the mixed list detection function directly
1532
1533        // Pure unordered - no mixed nesting
1534        let content = "* Item 1\n  * Item 2\n    * Item 3";
1535        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1536        assert!(
1537            !ctx.has_mixed_list_nesting(),
1538            "Pure unordered should not be detected as mixed"
1539        );
1540
1541        // Pure ordered - no mixed nesting
1542        let content = "1. Item 1\n   2. Item 2\n      3. Item 3";
1543        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544        assert!(
1545            !ctx.has_mixed_list_nesting(),
1546            "Pure ordered should not be detected as mixed"
1547        );
1548
1549        // Mixed: unordered under ordered
1550        let content = "1. Ordered\n   * Unordered child";
1551        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552        assert!(
1553            ctx.has_mixed_list_nesting(),
1554            "Unordered under ordered should be detected as mixed"
1555        );
1556
1557        // Mixed: ordered under unordered
1558        let content = "* Unordered\n  1. Ordered child";
1559        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1560        assert!(
1561            ctx.has_mixed_list_nesting(),
1562            "Ordered under unordered should be detected as mixed"
1563        );
1564
1565        // Separate lists (not nested) - not mixed
1566        let content = "* Unordered\n\n1. Ordered (separate list)";
1567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568        assert!(
1569            !ctx.has_mixed_list_nesting(),
1570            "Separate lists should not be detected as mixed"
1571        );
1572
1573        // Mixed lists inside blockquotes should be detected
1574        let content = "> 1. Ordered in blockquote\n>    * Unordered child";
1575        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1576        assert!(
1577            ctx.has_mixed_list_nesting(),
1578            "Mixed lists in blockquotes should be detected"
1579        );
1580    }
1581
1582    #[test]
1583    fn test_issue_210_exact_reproduction() {
1584        // Exact reproduction from issue #210
1585        let config = MD007Config {
1586            indent: crate::types::IndentSize::from_const(4),
1587            start_indented: false,
1588            start_indent: crate::types::IndentSize::from_const(2),
1589            style: md007_config::IndentStyle::TextAligned, // Default
1590            style_explicit: false,                         // Not explicitly set - should auto-detect
1591            indent_explicit: false,                        // Not explicitly set
1592        };
1593        let rule = MD007ULIndent::from_config_struct(config);
1594
1595        let content = "# Title\n\n* some\n    * list\n    * items\n";
1596        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597        let result = rule.check(&ctx).unwrap();
1598
1599        assert!(
1600            result.is_empty(),
1601            "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1602        );
1603    }
1604
1605    #[test]
1606    fn test_issue_209_still_fixed() {
1607        // Verify issue #209 (oscillation) is still fixed when style is explicitly set
1608        // With issue #236 fix, explicit style must be set to get pure text-aligned behavior
1609        let config = MD007Config {
1610            indent: crate::types::IndentSize::from_const(3),
1611            start_indented: false,
1612            start_indent: crate::types::IndentSize::from_const(2),
1613            style: md007_config::IndentStyle::TextAligned,
1614            style_explicit: true, // Explicit style to test text-aligned behavior
1615            indent_explicit: false,
1616        };
1617        let rule = MD007ULIndent::from_config_struct(config);
1618
1619        // Mixed list from issue #209 - with explicit text-aligned, no oscillation
1620        let content = r#"# Header 1
1621
1622- **Second item**:
1623  - **This is a nested list**:
1624    1. **First point**
1625       - First subpoint
1626"#;
1627        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628        let result = rule.check(&ctx).unwrap();
1629
1630        assert!(
1631            result.is_empty(),
1632            "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1633        );
1634    }
1635
1636    // Edge case tests for review findings
1637
1638    #[test]
1639    fn test_multi_level_mixed_detection_grandparent() {
1640        // Test that multi-level mixed detection finds grandparent type differences
1641        // ordered → unordered → unordered should be detected as mixed
1642        // because the grandparent (ordered) is different from descendants (unordered)
1643        let content = "1. Ordered grandparent\n   * Unordered child\n     * Unordered grandchild";
1644        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645        assert!(
1646            ctx.has_mixed_list_nesting(),
1647            "Should detect mixed nesting when grandparent differs in type"
1648        );
1649
1650        // unordered → ordered → ordered should also be detected as mixed
1651        let content = "* Unordered grandparent\n  1. Ordered child\n     2. Ordered grandchild";
1652        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1653        assert!(
1654            ctx.has_mixed_list_nesting(),
1655            "Should detect mixed nesting for ordered descendants under unordered"
1656        );
1657    }
1658
1659    #[test]
1660    fn test_html_comments_skipped_in_detection() {
1661        // Lists inside HTML comments should not affect mixed detection
1662        let content = r#"* Unordered list
1663<!-- This is a comment
1664  1. This ordered list is inside a comment
1665     * This nested bullet is also inside
1666-->
1667  * Another unordered item"#;
1668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1669        assert!(
1670            !ctx.has_mixed_list_nesting(),
1671            "Lists in HTML comments should be ignored in mixed detection"
1672        );
1673    }
1674
1675    #[test]
1676    fn test_blank_lines_separate_lists() {
1677        // Blank lines at root level should separate lists, treating them as independent
1678        let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1680        assert!(
1681            !ctx.has_mixed_list_nesting(),
1682            "Blank line at root should separate lists"
1683        );
1684
1685        // But nested lists after blank should still be detected if mixed
1686        let content = "1. Ordered parent\n\n   * Still a child due to indentation";
1687        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1688        assert!(
1689            ctx.has_mixed_list_nesting(),
1690            "Indented list after blank is still nested"
1691        );
1692    }
1693
1694    #[test]
1695    fn test_column_1_normalization() {
1696        // 1-space indent should be treated as column 0 (root level)
1697        // This creates a sibling relationship, not nesting
1698        let content = "* First item\n * Second item with 1 space (sibling)";
1699        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700        let rule = MD007ULIndent::default();
1701        let result = rule.check(&ctx).unwrap();
1702        // The second item should be flagged as wrong (1 space is not valid for nesting)
1703        assert!(
1704            result.iter().any(|w| w.line == 2),
1705            "1-space indent should be flagged as incorrect"
1706        );
1707    }
1708
1709    #[test]
1710    fn test_code_blocks_skipped_in_detection() {
1711        // Lists inside code blocks should not affect mixed detection
1712        let content = r#"* Unordered list
1713```
17141. This ordered list is inside a code block
1715   * This nested bullet is also inside
1716```
1717  * Another unordered item"#;
1718        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1719        assert!(
1720            !ctx.has_mixed_list_nesting(),
1721            "Lists in code blocks should be ignored in mixed detection"
1722        );
1723    }
1724
1725    #[test]
1726    fn test_front_matter_skipped_in_detection() {
1727        // Lists inside YAML front matter should not affect mixed detection
1728        let content = r#"---
1729items:
1730  - yaml list item
1731  - another item
1732---
1733* Unordered list after front matter"#;
1734        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1735        assert!(
1736            !ctx.has_mixed_list_nesting(),
1737            "Lists in front matter should be ignored in mixed detection"
1738        );
1739    }
1740
1741    #[test]
1742    fn test_alternating_types_at_same_level() {
1743        // Alternating between ordered and unordered at the same nesting level
1744        // is NOT mixed nesting (they are siblings, not parent-child)
1745        let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1746        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1747        assert!(
1748            !ctx.has_mixed_list_nesting(),
1749            "Alternating types at same level should not be detected as mixed"
1750        );
1751    }
1752
1753    #[test]
1754    fn test_five_level_deep_mixed_nesting() {
1755        // Test detection at 5+ levels of nesting
1756        let content = "* L0\n  1. L1\n     * L2\n       1. L3\n          * L4\n            1. L5";
1757        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1758        assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1759    }
1760
1761    #[test]
1762    fn test_very_deep_pure_unordered_nesting() {
1763        // Test pure unordered list with 10+ levels of nesting
1764        let mut content = String::from("* L1");
1765        for level in 2..=12 {
1766            let indent = "  ".repeat(level - 1);
1767            content.push_str(&format!("\n{indent}* L{level}"));
1768        }
1769
1770        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1771
1772        // Should NOT be detected as mixed (all unordered)
1773        assert!(
1774            !ctx.has_mixed_list_nesting(),
1775            "Pure unordered deep nesting should not be detected as mixed"
1776        );
1777
1778        // Should use fixed style with custom indent
1779        let rule = MD007ULIndent::new(4);
1780        let result = rule.check(&ctx).unwrap();
1781        // With text-aligned default but auto-switch to fixed for pure unordered,
1782        // the first nested level should be flagged (2 spaces instead of 4)
1783        assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1784    }
1785
1786    #[test]
1787    fn test_interleaved_content_between_list_items() {
1788        // Paragraph continuation between list items should not break detection
1789        let content = "1. Ordered parent\n\n   Paragraph continuation\n\n   * Unordered child";
1790        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1791        assert!(
1792            ctx.has_mixed_list_nesting(),
1793            "Should detect mixed nesting even with interleaved paragraphs"
1794        );
1795    }
1796
1797    #[test]
1798    fn test_esm_blocks_skipped_in_detection() {
1799        // ESM import/export blocks in MDX should be skipped
1800        // Note: ESM detection depends on LintContext properly setting in_esm_block
1801        let content = "* Unordered list\n  * Nested unordered";
1802        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803        assert!(
1804            !ctx.has_mixed_list_nesting(),
1805            "Pure unordered should not be detected as mixed"
1806        );
1807    }
1808
1809    #[test]
1810    fn test_multiple_list_blocks_pure_then_mixed() {
1811        // Document with pure unordered list followed by mixed list
1812        // Detection should find the mixed list and return true
1813        let content = r#"* Pure unordered
1814  * Nested unordered
1815
18161. Mixed section
1817   * Bullet under ordered"#;
1818        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819        assert!(
1820            ctx.has_mixed_list_nesting(),
1821            "Should detect mixed nesting in any part of document"
1822        );
1823    }
1824
1825    #[test]
1826    fn test_multiple_separate_pure_lists() {
1827        // Multiple pure unordered lists separated by blank lines
1828        // Should NOT be detected as mixed
1829        let content = r#"* First list
1830  * Nested
1831
1832* Second list
1833  * Also nested
1834
1835* Third list
1836  * Deeply
1837    * Nested"#;
1838        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839        assert!(
1840            !ctx.has_mixed_list_nesting(),
1841            "Multiple separate pure unordered lists should not be mixed"
1842        );
1843    }
1844
1845    #[test]
1846    fn test_code_block_between_list_items() {
1847        // Code block between list items should not affect detection
1848        let content = r#"1. Ordered
1849   ```
1850   code
1851   ```
1852   * Still a mixed child"#;
1853        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1854        assert!(
1855            ctx.has_mixed_list_nesting(),
1856            "Code block between items should not prevent mixed detection"
1857        );
1858    }
1859
1860    #[test]
1861    fn test_blockquoted_mixed_detection() {
1862        // Mixed lists inside blockquotes should be detected
1863        let content = "> 1. Ordered in blockquote\n>    * Mixed child";
1864        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1865        // Note: Detection depends on correct marker_column calculation in blockquotes
1866        // This test verifies the detection logic works with blockquoted content
1867        assert!(
1868            ctx.has_mixed_list_nesting(),
1869            "Should detect mixed nesting in blockquotes"
1870        );
1871    }
1872
1873    // Tests for "Do What I Mean" behavior (issue #273)
1874
1875    #[test]
1876    fn test_indent_explicit_uses_fixed_style() {
1877        // When indent is explicitly set but style is not, use fixed style automatically
1878        // This is the "Do What I Mean" behavior for issue #273
1879        let config = MD007Config {
1880            indent: crate::types::IndentSize::from_const(4),
1881            start_indented: false,
1882            start_indent: crate::types::IndentSize::from_const(2),
1883            style: md007_config::IndentStyle::TextAligned, // Default
1884            style_explicit: false,                         // Style NOT explicitly set
1885            indent_explicit: true,                         // Indent explicitly set
1886        };
1887        let rule = MD007ULIndent::from_config_struct(config);
1888
1889        // With indent_explicit=true and style_explicit=false, should use fixed style
1890        // Fixed style with indent=4: level 0 = 0, level 1 = 4, level 2 = 8
1891        let content = "* Level 0\n    * Level 1\n        * Level 2";
1892        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893        let result = rule.check(&ctx).unwrap();
1894        assert!(
1895            result.is_empty(),
1896            "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1897        );
1898
1899        // Text-aligned spacing (2 spaces per level) should now be wrong
1900        let wrong_content = "* Level 0\n  * Level 1\n    * Level 2";
1901        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1902        let result = rule.check(&ctx).unwrap();
1903        assert!(
1904            !result.is_empty(),
1905            "Should flag text-aligned spacing when indent_explicit=true"
1906        );
1907    }
1908
1909    #[test]
1910    fn test_explicit_style_overrides_indent_explicit() {
1911        // When both indent and style are explicitly set, style wins
1912        // This ensures backwards compatibility and respects explicit user choice
1913        let config = MD007Config {
1914            indent: crate::types::IndentSize::from_const(4),
1915            start_indented: false,
1916            start_indent: crate::types::IndentSize::from_const(2),
1917            style: md007_config::IndentStyle::TextAligned,
1918            style_explicit: true,  // Style explicitly set
1919            indent_explicit: true, // Indent also explicitly set (user will see warning)
1920        };
1921        let rule = MD007ULIndent::from_config_struct(config);
1922
1923        // With explicit text-aligned style, should use text-aligned even with indent_explicit
1924        let content = "* Level 0\n  * Level 1\n    * Level 2";
1925        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926        let result = rule.check(&ctx).unwrap();
1927        assert!(
1928            result.is_empty(),
1929            "Explicit text-aligned style should be respected, got: {result:?}"
1930        );
1931    }
1932
1933    #[test]
1934    fn test_no_indent_explicit_uses_smart_detection() {
1935        // When neither is explicitly set, use smart per-parent detection (original behavior)
1936        let config = MD007Config {
1937            indent: crate::types::IndentSize::from_const(4),
1938            start_indented: false,
1939            start_indent: crate::types::IndentSize::from_const(2),
1940            style: md007_config::IndentStyle::TextAligned,
1941            style_explicit: false,
1942            indent_explicit: false, // Neither explicitly set - use smart detection
1943        };
1944        let rule = MD007ULIndent::from_config_struct(config);
1945
1946        // Pure unordered with neither explicit: per-parent logic applies
1947        // For pure unordered at expected positions, fixed style is used
1948        let content = "* Level 0\n    * Level 1";
1949        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1950        let result = rule.check(&ctx).unwrap();
1951        // This should work with smart detection for pure unordered lists
1952        assert!(
1953            result.is_empty(),
1954            "Smart detection should accept 4-space indent, got: {result:?}"
1955        );
1956    }
1957
1958    #[test]
1959    fn test_issue_273_exact_reproduction() {
1960        // Exact reproduction from issue #273:
1961        // User sets `indent = 4` without setting style, expects 4-space increments
1962        let config = MD007Config {
1963            indent: crate::types::IndentSize::from_const(4),
1964            start_indented: false,
1965            start_indent: crate::types::IndentSize::from_const(2),
1966            style: md007_config::IndentStyle::TextAligned, // Default (would use text-aligned)
1967            style_explicit: false,
1968            indent_explicit: true, // User explicitly set indent
1969        };
1970        let rule = MD007ULIndent::from_config_struct(config);
1971
1972        let content = r#"* Item 1
1973    * Item 2
1974        * Item 3"#;
1975        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1976        let result = rule.check(&ctx).unwrap();
1977        assert!(
1978            result.is_empty(),
1979            "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
1980        );
1981    }
1982
1983    #[test]
1984    fn test_indent_explicit_with_ordered_parent() {
1985        // When indent is explicitly set, both text-aligned and fixed indent are accepted
1986        // under ordered parents, since the user wants their configured indent but
1987        // text-aligned is also valid for ordered list children.
1988        let config = MD007Config {
1989            indent: crate::types::IndentSize::from_const(4),
1990            start_indented: false,
1991            start_indent: crate::types::IndentSize::from_const(2),
1992            style: md007_config::IndentStyle::TextAligned,
1993            style_explicit: false,
1994            indent_explicit: true, // User set indent=4
1995        };
1996        let rule = MD007ULIndent::from_config_struct(config);
1997
1998        // 4-space indent under "1. " should pass (matches configured indent)
1999        let content = "1. Ordered\n    * Bullet with 4-space indent";
2000        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2001        let result = rule.check(&ctx).unwrap();
2002        assert!(
2003            result.is_empty(),
2004            "4-space indent under ordered should pass with indent=4: {result:?}"
2005        );
2006
2007        // 3-space indent under "1. " should also pass (text-aligned with "1. ")
2008        let content_3 = "1. Ordered\n   * Bullet with 3-space indent";
2009        let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2010        let result = rule.check(&ctx).unwrap();
2011        assert!(
2012            result.is_empty(),
2013            "3-space indent under ordered should pass (text-aligned): {result:?}"
2014        );
2015
2016        // 2-space indent under "1. " should be wrong (neither text-aligned nor fixed)
2017        let wrong_content = "1. Ordered\n  * Bullet with 2-space indent";
2018        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2019        let result = rule.check(&ctx).unwrap();
2020        assert!(
2021            !result.is_empty(),
2022            "2-space indent under ordered list should be flagged when indent=4: {result:?}"
2023        );
2024    }
2025
2026    #[test]
2027    fn test_indent_explicit_mixed_list_deep_nesting() {
2028        // Deep nesting with alternating list types tests the edge case thoroughly:
2029        // - Bullets under bullets: use configured indent (4)
2030        // - Bullets under ordered: use text-aligned
2031        // - Ordered under bullets: N/A (MD007 only checks bullets)
2032        let config = MD007Config {
2033            indent: crate::types::IndentSize::from_const(4),
2034            start_indented: false,
2035            start_indent: crate::types::IndentSize::from_const(2),
2036            style: md007_config::IndentStyle::TextAligned,
2037            style_explicit: false,
2038            indent_explicit: true,
2039        };
2040        let rule = MD007ULIndent::from_config_struct(config);
2041
2042        // Level 0: bullet (col 0)
2043        // Level 1: bullet (col 4 - fixed, parent is bullet)
2044        // Level 2: ordered (col 8 - not checked by MD007)
2045        // Level 3: bullet - text-aligned=11 (3 chars for "1. " from col 8), fixed=12
2046        // Both 11 (text-aligned) and 12 (fixed) should be accepted
2047        let content_text_aligned = r#"* Level 0
2048    * Level 1 (4-space indent from bullet parent)
2049        1. Level 2 ordered
2050           * Level 3 bullet (text-aligned under ordered)"#;
2051        let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
2052        let result = rule.check(&ctx).unwrap();
2053        assert!(
2054            result.is_empty(),
2055            "Text-aligned nesting under ordered should pass: {result:?}"
2056        );
2057
2058        let content_fixed = r#"* Level 0
2059    * Level 1 (4-space indent from bullet parent)
2060        1. Level 2 ordered
2061            * Level 3 bullet (fixed indent under ordered)"#;
2062        let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
2063        let result = rule.check(&ctx).unwrap();
2064        assert!(
2065            result.is_empty(),
2066            "Fixed indent nesting under ordered should also pass: {result:?}"
2067        );
2068    }
2069
2070    #[test]
2071    fn test_ordered_list_double_digit_markers() {
2072        // Ordered lists with 10+ items have wider markers ("10." vs "9.")
2073        // Bullets nested under these must text-align correctly
2074        let config = MD007Config {
2075            indent: crate::types::IndentSize::from_const(4),
2076            start_indented: false,
2077            start_indent: crate::types::IndentSize::from_const(2),
2078            style: md007_config::IndentStyle::TextAligned,
2079            style_explicit: false,
2080            indent_explicit: true,
2081        };
2082        let rule = MD007ULIndent::from_config_struct(config);
2083
2084        // "10. " = 4 chars, text-aligned = 4, fixed = 4
2085        let content = "10. Double digit\n    * Bullet at col 4";
2086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2087        let result = rule.check(&ctx).unwrap();
2088        assert!(
2089            result.is_empty(),
2090            "Bullet under '10.' should align at column 4: {result:?}"
2091        );
2092
2093        // Single digit "1. " = 3 chars, text-aligned = 3, fixed = 4
2094        // Both should be accepted under ordered parent with explicit indent
2095        let content_3 = "1. Single digit\n   * Bullet at col 3";
2096        let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2097        let result = rule.check(&ctx).unwrap();
2098        assert!(
2099            result.is_empty(),
2100            "Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
2101        );
2102
2103        let content_4 = "1. Single digit\n    * Bullet at col 4";
2104        let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2105        let result = rule.check(&ctx).unwrap();
2106        assert!(
2107            result.is_empty(),
2108            "Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
2109        );
2110    }
2111
2112    #[test]
2113    fn test_indent_explicit_pure_unordered_uses_fixed() {
2114        // Regression test: pure unordered lists should use fixed indent
2115        // when indent is explicitly configured
2116        let config = MD007Config {
2117            indent: crate::types::IndentSize::from_const(4),
2118            start_indented: false,
2119            start_indent: crate::types::IndentSize::from_const(2),
2120            style: md007_config::IndentStyle::TextAligned,
2121            style_explicit: false,
2122            indent_explicit: true,
2123        };
2124        let rule = MD007ULIndent::from_config_struct(config);
2125
2126        // Pure unordered with 4-space indent should pass
2127        let content = "* Level 0\n    * Level 1\n        * Level 2";
2128        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2129        let result = rule.check(&ctx).unwrap();
2130        assert!(
2131            result.is_empty(),
2132            "Pure unordered with indent=4 should use 4-space increments: {result:?}"
2133        );
2134
2135        // Text-aligned (2-space) should fail with indent=4
2136        let wrong_content = "* Level 0\n  * Level 1\n    * Level 2";
2137        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2138        let result = rule.check(&ctx).unwrap();
2139        assert!(
2140            !result.is_empty(),
2141            "2-space indent should be flagged when indent=4 is configured"
2142        );
2143    }
2144
2145    #[test]
2146    fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
2147        // MkDocs (Python-Markdown) requires 4-space continuation for ordered
2148        // list items. `1. text` has content at column 3, but Python-Markdown
2149        // needs marker_col + 4 = 4 spaces minimum.
2150        let rule = MD007ULIndent::default();
2151        let content = "1. text\n\n    - nested item";
2152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2153        let result = rule.check(&ctx).unwrap();
2154        assert!(
2155            result.is_empty(),
2156            "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
2157        );
2158    }
2159
2160    #[test]
2161    fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
2162        // Without MkDocs, `1. text` has content at column 3,
2163        // so 3-space indent is correct (text-aligned).
2164        let rule = MD007ULIndent::default();
2165        let content = "1. text\n\n   - nested item";
2166        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2167        let result = rule.check(&ctx).unwrap();
2168        assert!(
2169            result.is_empty(),
2170            "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
2171        );
2172    }
2173
2174    #[test]
2175    fn test_standard_flavor_ordered_list_under_ordered_is_exempt() {
2176        // markdownlint exempts unordered sublists of an ordered list from MD007
2177        // ("applies only if parent lists are all also unordered"). A 4-space bullet
2178        // under `1. text` (content column 3) is a genuine sublist, so it must not be
2179        // flagged. Verified: markdownlint-cli2 reports 0 MD007 errors here.
2180        let rule = MD007ULIndent::default();
2181        let content = "1. text\n\n    - nested item";
2182        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2183        let result = rule.check(&ctx).unwrap();
2184        assert!(
2185            result.is_empty(),
2186            "unordered sublist of an ordered list must be exempt in Standard flavor, got: {result:?}"
2187        );
2188    }
2189
2190    #[test]
2191    fn test_mkdocs_multi_digit_ordered_list() {
2192        // `10. text` has content at column 4, which already meets
2193        // the 4-space minimum (marker_col 0 + 4 = 4). No adjustment needed.
2194        let rule = MD007ULIndent::default();
2195        let content = "10. text\n\n    - nested item";
2196        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2197        let result = rule.check(&ctx).unwrap();
2198        assert!(
2199            result.is_empty(),
2200            "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
2201        );
2202    }
2203
2204    #[test]
2205    fn test_mkdocs_triple_digit_ordered_list() {
2206        // `100. text` has content at column 5, which exceeds
2207        // the 4-space minimum (marker_col 0 + 4 = 4). No adjustment needed.
2208        let rule = MD007ULIndent::default();
2209        let content = "100. text\n\n     - nested item";
2210        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2211        let result = rule.check(&ctx).unwrap();
2212        assert!(
2213            result.is_empty(),
2214            "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
2215        );
2216    }
2217
2218    #[test]
2219    fn test_mkdocs_insufficient_indent_under_ordered() {
2220        // In MkDocs, 2-space indent under `1. text` is insufficient.
2221        // Expected: marker_col(0) + 4 = 4, got: 2.
2222        let rule = MD007ULIndent::default();
2223        let content = "1. text\n\n  - nested item";
2224        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2225        let result = rule.check(&ctx).unwrap();
2226        assert_eq!(
2227            result.len(),
2228            1,
2229            "2-space indent under ordered list should warn in MkDocs flavor"
2230        );
2231        assert!(
2232            result[0].message.contains("Expected 4"),
2233            "Warning should expect 4 spaces (MkDocs minimum), got: {}",
2234            result[0].message
2235        );
2236    }
2237
2238    #[test]
2239    fn test_mkdocs_deeper_nesting_under_ordered() {
2240        // `1. text` -> `    - sub` (4 spaces) -> `      - subsub` (6 spaces)
2241        // The sub-item at 4 spaces is correct for MkDocs.
2242        // The sub-sub-item at 6 spaces: parent is unordered at col 4 with content at col 6,
2243        // so 6-space indent is text-aligned (correct).
2244        let rule = MD007ULIndent::default();
2245        let content = "1. text\n\n    - sub\n      - subsub";
2246        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2247        let result = rule.check(&ctx).unwrap();
2248        assert!(
2249            result.is_empty(),
2250            "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
2251        );
2252    }
2253
2254    #[test]
2255    fn test_mkdocs_fix_adjusts_to_4_spaces() {
2256        // Verify that auto-fix corrects 3-space indent to 4-space in MkDocs
2257        let rule = MD007ULIndent::default();
2258        let content = "1. text\n\n   - nested item";
2259        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2260        let result = rule.check(&ctx).unwrap();
2261        assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
2262        let fixed = rule.fix(&ctx).unwrap();
2263        assert_eq!(
2264            fixed, "1. text\n\n    - nested item",
2265            "Fix should adjust indent to 4 spaces in MkDocs"
2266        );
2267    }
2268
2269    #[test]
2270    fn test_mkdocs_start_indented_with_ordered_parent() {
2271        // start_indented mode with MkDocs: the MkDocs adjustment should still apply
2272        // as a floor on top of the start_indented calculation.
2273        let config = MD007Config {
2274            start_indented: true,
2275            ..Default::default()
2276        };
2277        let rule = MD007ULIndent::from_config_struct(config);
2278        let content = "1. text\n\n    - nested item";
2279        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2280        let result = rule.check(&ctx).unwrap();
2281        assert!(
2282            result.is_empty(),
2283            "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
2284        );
2285    }
2286
2287    #[test]
2288    fn test_mkdocs_ordered_at_nonzero_indent() {
2289        // Ordered list nested inside an unordered list, with a further unordered child.
2290        // `- outer` at col 0, `  1. inner` at col 2, `      - deep` at col 6.
2291        // For `deep`: parent is ordered at marker_col=2, so MkDocs minimum = 2+4 = 6.
2292        // Text-aligned: content_col of `1. inner` = 5. max(5, 6) = 6.
2293        let rule = MD007ULIndent::default();
2294        let content = "- outer\n  1. inner\n      - deep";
2295        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2296        let result = rule.check(&ctx).unwrap();
2297        assert!(
2298            result.is_empty(),
2299            "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
2300        );
2301    }
2302
2303    #[test]
2304    fn test_mkdocs_blockquoted_ordered_list() {
2305        // Blockquoted ordered list in MkDocs: the indent is relative to
2306        // the blockquote content, so `> 1. text` with `>     - nested`
2307        // has 4 spaces of indent within the blockquote context.
2308        let rule = MD007ULIndent::default();
2309        let content = "> 1. text\n>\n>     - nested item";
2310        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2311        let result = rule.check(&ctx).unwrap();
2312        assert!(
2313            result.is_empty(),
2314            "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
2315        );
2316    }
2317
2318    #[test]
2319    fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
2320        // Same structure but with only 5 spaces for `deep`.
2321        // MkDocs minimum = marker_col(2) + 4 = 6, but got 5. Should warn.
2322        let rule = MD007ULIndent::default();
2323        let content = "- outer\n  1. inner\n     - deep";
2324        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2325        let result = rule.check(&ctx).unwrap();
2326        assert_eq!(
2327            result.len(),
2328            1,
2329            "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
2330        );
2331    }
2332
2333    #[test]
2334    fn test_issue_504_indent4_ordered_parent() {
2335        // Reproduction case from issue #504:
2336        // With indent=4, nested unordered items under ordered parent
2337        // should accept 4-space indentation
2338        let config = MD007Config {
2339            indent: crate::types::IndentSize::from_const(4),
2340            start_indented: false,
2341            start_indent: crate::types::IndentSize::from_const(2),
2342            style: md007_config::IndentStyle::TextAligned,
2343            style_explicit: false,
2344            indent_explicit: true,
2345        };
2346        let rule = MD007ULIndent::from_config_struct(config);
2347
2348        let content = r#"# Things
2349
2350+ An unordered list
2351    + An item with 4 spaces, ok.
2352
23531. A numbered list
2354    + A sublist with 4 spaces, not ok
2355        + A sub item with 4 spaces, ok
2356    + Why is rumdl expecting 3 spaces for a 4 space indent?
23572. Item 2
23583. Item 3"#;
2359        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2360        let result = rule.check(&ctx).unwrap();
2361        assert!(
2362            result.is_empty(),
2363            "Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
2364        );
2365    }
2366
2367    #[test]
2368    fn test_indent2_explicit_with_ordered_parent() {
2369        // When indent=2 is explicit and parent is "1. " (text-aligned=3),
2370        // both 2 (fixed) and 3 (text-aligned) should be accepted
2371        let config = MD007Config {
2372            indent: crate::types::IndentSize::from_const(2),
2373            start_indented: false,
2374            start_indent: crate::types::IndentSize::from_const(2),
2375            style: md007_config::IndentStyle::TextAligned,
2376            style_explicit: false,
2377            indent_explicit: true,
2378        };
2379        let rule = MD007ULIndent::from_config_struct(config);
2380
2381        // 3-space indent should pass (text-aligned with "1. ")
2382        let content = "1. Ordered\n   * Bullet at 3 spaces";
2383        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2384        let result = rule.check(&ctx).unwrap();
2385        assert!(
2386            result.is_empty(),
2387            "indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
2388        );
2389
2390        // 2-space indent should also pass (matches configured fixed indent)
2391        let content_2 = "1. Ordered\n  * Bullet at 2 spaces";
2392        let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
2393        let result = rule.check(&ctx).unwrap();
2394        assert!(
2395            result.is_empty(),
2396            "indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
2397        );
2398    }
2399
2400    // Issue #638: MD007 must not fire on unordered items nested under an ordered
2401    // list. markdownlint: "applies to a sublist only if its parent lists are all
2402    // also unordered." Verified against markdownlint-cli2 v0.18.1 (0 MD007 errors).
2403    const ISSUE_638_INPUT: &str = "# Title\n\n1. Some text\n   - Indented text\n     - more indented\n";
2404
2405    #[test]
2406    fn test_issue_638_unordered_under_ordered_smart_default() {
2407        let rule = MD007ULIndent::new(2);
2408        let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2409        let result = rule.check(&ctx).unwrap();
2410        assert!(
2411            result.is_empty(),
2412            "smart default: unordered items under an ordered list must not be flagged, got: {result:?}"
2413        );
2414    }
2415
2416    #[test]
2417    fn test_issue_638_unordered_under_ordered_indent_explicit() {
2418        let config = MD007Config {
2419            indent: crate::types::IndentSize::from_const(2),
2420            start_indented: false,
2421            start_indent: crate::types::IndentSize::from_const(2),
2422            style: md007_config::IndentStyle::TextAligned,
2423            style_explicit: false,
2424            indent_explicit: true,
2425        };
2426        let rule = MD007ULIndent::from_config_struct(config);
2427        let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2428        let result = rule.check(&ctx).unwrap();
2429        assert!(
2430            result.is_empty(),
2431            "indent=2 explicit: unordered items under an ordered list must not be flagged, got: {result:?}"
2432        );
2433    }
2434
2435    #[test]
2436    fn test_issue_638_unordered_under_ordered_style_fixed() {
2437        // The reporter's exact config: indent = 2, style = "fixed".
2438        let config = MD007Config {
2439            indent: crate::types::IndentSize::from_const(2),
2440            start_indented: false,
2441            start_indent: crate::types::IndentSize::from_const(2),
2442            style: md007_config::IndentStyle::Fixed,
2443            style_explicit: true,
2444            indent_explicit: true,
2445        };
2446        let rule = MD007ULIndent::from_config_struct(config);
2447        let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2448        let result = rule.check(&ctx).unwrap();
2449        assert!(
2450            result.is_empty(),
2451            "style=fixed: unordered items under an ordered list must not be flagged, got: {result:?}"
2452        );
2453    }
2454
2455    #[test]
2456    fn test_issue_638_deeper_unordered_chain_under_ordered() {
2457        // Every unordered item below the ordered ancestor is exempt, at any depth.
2458        let rule = MD007ULIndent::new(2);
2459        let content = "1. Ordered\n   - child\n      - grandchild\n         - great-grandchild\n";
2460        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2461        let result = rule.check(&ctx).unwrap();
2462        assert!(
2463            result.is_empty(),
2464            "all unordered descendants of an ordered list are exempt, got: {result:?}"
2465        );
2466    }
2467
2468    #[test]
2469    fn test_issue_638_pure_unordered_still_checked() {
2470        // Guard: the exemption must not leak into pure unordered lists.
2471        let rule = MD007ULIndent::new(2);
2472        let content = "- Top\n   - three spaces (wrong, expected 2)\n";
2473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2474        let result = rule.check(&ctx).unwrap();
2475        assert_eq!(
2476            result.len(),
2477            1,
2478            "pure unordered nesting must still be checked, got: {result:?}"
2479        );
2480    }
2481
2482    #[test]
2483    fn test_issue_638_exemption_not_applied_after_list_terminated_by_paragraph() {
2484        // A top-level paragraph terminates the ordered list. The later, separately
2485        // indented unordered list is NOT a sublist of the (now-closed) ordered item, so
2486        // the ordered-ancestor exemption must not apply: MD007 flags both the misindented
2487        // top-level item and its child. Verified against markdownlint-cli2, which reports
2488        // MD007 on the parent (Expected: 0; Actual: 3) and the child (Expected: 2;
2489        // Actual: 6).
2490        let rule = MD007ULIndent::new(2);
2491        let content = "1. ordered\n\nparagraph\n\n   - parent\n      - child six\n";
2492        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2493        let result = rule.check(&ctx).unwrap();
2494        assert_eq!(
2495            result.len(),
2496            2,
2497            "the new top-level list following a terminated ordered list is checked at both levels, got: {result:?}"
2498        );
2499        assert!(
2500            result.iter().any(|w| w.line == 5 && w.message.contains("Expected 0")),
2501            "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2502        );
2503        assert!(
2504            result
2505                .iter()
2506                .any(|w| w.line == 6 && w.message.contains("Expected 2") && w.message.contains("found 6")),
2507            "the misindented child must be flagged with Expected 2, found 6, got: {result:?}"
2508        );
2509    }
2510
2511    #[test]
2512    fn test_issue_638_lazy_continuation_does_not_terminate_ordered_list() {
2513        // A non-indented paragraph line that immediately follows the ordered item
2514        // (no blank line between) is a CommonMark lazy continuation of that item,
2515        // so the ordered list stays open and its unordered sublist is exempt.
2516        // markdownlint-cli2 reports 0 MD007 errors here; the stale-ancestor
2517        // termination must not fire on a lazy continuation line.
2518        let rule = MD007ULIndent::new(2);
2519        let content = "1. ordered\nlazy continuation\n   - child\n     - grandchild\n";
2520        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2521        let result = rule.check(&ctx).unwrap();
2522        assert!(
2523            result.is_empty(),
2524            "lazy continuation must not terminate the ordered list; sublist stays exempt, got: {result:?}"
2525        );
2526    }
2527
2528    #[test]
2529    fn test_issue_638_heading_interrupts_ordered_list_without_blank() {
2530        // Unlike a lazy paragraph continuation, an ATX heading interrupts the open
2531        // paragraph and therefore terminates the ordered list even without an
2532        // intervening blank line. The following bullets are then a new top-level list,
2533        // so both the misindented top item and its child are flagged. markdownlint-cli2
2534        // reports MD007 on the top item (Expected: 0; Actual: 3) and the child
2535        // (Expected: 2; Actual: 5).
2536        let rule = MD007ULIndent::new(2);
2537        let content = "1. ordered\n# heading\n   - child\n     - grandchild\n";
2538        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2539        let result = rule.check(&ctx).unwrap();
2540        assert_eq!(
2541            result.len(),
2542            2,
2543            "a heading terminates the ordered list, so the new top-level list and its child are both checked, got: {result:?}"
2544        );
2545        assert!(
2546            result.iter().any(|w| w.line == 3 && w.message.contains("Expected 0")),
2547            "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2548        );
2549        assert!(
2550            result.iter().any(|w| w.line == 4 && w.message.contains("Expected 2")),
2551            "the misindented child must be flagged with Expected 2, got: {result:?}"
2552        );
2553    }
2554
2555    #[test]
2556    fn test_issue_638_lazy_continuation_inside_blockquote_keeps_exemption() {
2557        // Inside a blockquote, a plain continuation line in the same quote is a
2558        // lazy paragraph continuation of the ordered item, so the list stays open
2559        // and its sublist remains exempt. markdownlint-cli2 reports 0 MD007 errors;
2560        // termination must operate in blockquote-content coordinates, not absolute.
2561        let rule = MD007ULIndent::new(2);
2562        let content = "> 1. ordered\n> continuation\n>\n>    - child\n>      - grandchild\n";
2563        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2564        let result = rule.check(&ctx).unwrap();
2565        assert!(
2566            result.is_empty(),
2567            "a lazy continuation within the same blockquote must keep the sublist exempt, got: {result:?}"
2568        );
2569    }
2570
2571    #[test]
2572    fn test_issue_638_indented_fence_inside_blockquoted_ordered_item_keeps_exemption() {
2573        // A fenced code block indented to the ordered item's content column, all
2574        // within a blockquote, is part of that item. The list stays open and the
2575        // sublist remains exempt. markdownlint-cli2 reports 0 MD007 errors; the
2576        // skip-region termination must use blockquote-content-relative indent.
2577        let rule = MD007ULIndent::new(2);
2578        let content = "> 1. ordered\n>    ```\n>    code\n>    ```\n>    - child\n>      - grandchild\n";
2579        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2580        let result = rule.check(&ctx).unwrap();
2581        assert!(
2582            result.is_empty(),
2583            "an indented fence inside a blockquoted ordered item must keep the sublist exempt, got: {result:?}"
2584        );
2585    }
2586
2587    #[test]
2588    fn test_issue_638_fenced_code_block_terminates_ordered_list() {
2589        // A top-level fenced code block (its opening fence not indented into the
2590        // item) terminates the ordered list. Because the rule skips code-block
2591        // lines, the stale ordered ancestor must still be cleared so the exemption
2592        // does not leak to a later list. markdownlint-cli2 flags the misindented
2593        // child (Expected: 2; Actual: 6).
2594        let rule = MD007ULIndent::new(2);
2595        let content = "1. ordered\n```\ncode\n```\n\n   - parent\n      - child\n";
2596        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2597        let result = rule.check(&ctx).unwrap();
2598        assert!(
2599            result.iter().any(|w| w.line == 7),
2600            "a top-level fenced code block terminates the ordered list; the child must be flagged, got: {result:?}"
2601        );
2602    }
2603
2604    #[test]
2605    fn test_issue_638_fenced_code_block_inside_item_keeps_exemption() {
2606        // A fenced code block indented into the ordered item's content column is
2607        // part of that item, so the list stays open and the sublist remains exempt.
2608        // markdownlint-cli2 reports 0 MD007 errors; termination must not over-fire
2609        // on the code block's interior lines.
2610        let rule = MD007ULIndent::new(2);
2611        let content = "1. ordered\n   ```\n   code\n   ```\n   - child\n     - grandchild\n";
2612        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2613        let result = rule.check(&ctx).unwrap();
2614        assert!(
2615            result.is_empty(),
2616            "a fenced code block nested inside the item must keep the sublist exempt, got: {result:?}"
2617        );
2618    }
2619
2620    #[test]
2621    fn test_issue_638_blockquote_terminates_ordered_list() {
2622        // A top-level blockquote interrupts the open paragraph and terminates the
2623        // ordered list (it is not indented into the item's content). The later,
2624        // separately indented unordered list is therefore NOT a sublist of the
2625        // closed ordered item, so the ordered-ancestor exemption must not leak:
2626        // the misindented child must still be flagged. markdownlint-cli2 reports
2627        // MD007 on the child (Expected: 2; Actual: 6).
2628        let rule = MD007ULIndent::new(2);
2629        let content = "1. ordered\n> quote\n\n   - parent\n      - child\n";
2630        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2631        let result = rule.check(&ctx).unwrap();
2632        assert!(
2633            result.iter().any(|w| w.line == 5),
2634            "blockquote terminates the ordered list, so the child must still be flagged, got: {result:?}"
2635        );
2636    }
2637
2638    #[test]
2639    fn test_issue_638_blockquote_inside_item_keeps_exemption() {
2640        // When the blockquote is indented into the ordered item's content column it
2641        // is part of that item, so the list stays open and its unordered sublist
2642        // remains exempt. markdownlint-cli2 reports 0 MD007 errors here; the
2643        // termination must not over-fire on a blockquote nested inside the item.
2644        let rule = MD007ULIndent::new(2);
2645        let content = "1. ordered\n   > quote inside item\n   - child\n     - grandchild\n";
2646        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2647        let result = rule.check(&ctx).unwrap();
2648        assert!(
2649            result.is_empty(),
2650            "a blockquote nested inside the item must keep the sublist exempt, got: {result:?}"
2651        );
2652    }
2653
2654    #[test]
2655    fn test_issue_638_exemption_requires_genuine_nesting_under_ordered() {
2656        // A wide ordered marker ("100. ") has its content at column 5. An unordered
2657        // bullet indented only 3 spaces is left of that content column, so it is NOT
2658        // nested under the ordered item but a new top-level list. The ordered-ancestor
2659        // exemption must not leak through this non-nested bullet to its child: with
2660        // the ordered item no longer a genuine ancestor, the misindented child must
2661        // still be checked. markdownlint-cli2 flags both the parent (Expected: 0) and
2662        // the child (Expected: 2). The exemption must not suppress the child, and the
2663        // fix must not flatten the child into a sibling of the parent.
2664        let rule = MD007ULIndent::new(2);
2665        let content = "100. ordered\n   - parent\n     - child\n";
2666        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2667        let result = rule.check(&ctx).unwrap();
2668        assert!(
2669            result.iter().any(|w| w.line == 3),
2670            "the child of a non-nested bullet must still be checked, not exempted; got: {result:?}"
2671        );
2672    }
2673
2674    #[test]
2675    fn test_issue_638_paragraph_after_fenced_code_closes_ordered_list() {
2676        // A fenced code block inside an ordered item is not paragraph text, so an
2677        // unindented line after the closing fence is NOT a lazy paragraph continuation:
2678        // it closes the list. The later, separately indented bullet list is therefore a
2679        // new top-level list, not a sublist of the ordered item, so the ordered-ancestor
2680        // exemption must not leak: the misindented child must still be flagged.
2681        // (markdownlint-cli2 also flags the parent with Expected: 0; rumdl does not flag
2682        // indented top-level list items, a separate pre-existing limitation, so we assert
2683        // only the child here - the part this fix governs.)
2684        let rule = MD007ULIndent::new(2);
2685        let content = "1. ordered\n   ```\n   code\n   ```\nnot lazy text\n   - parent\n     - child\n";
2686        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2687        let result = rule.check(&ctx).unwrap();
2688        assert!(
2689            result.iter().any(|w| w.line == 7),
2690            "fenced code is not paragraph text, so the list closes and the nested child must still be checked, not exempted; got: {result:?}"
2691        );
2692    }
2693
2694    #[test]
2695    fn test_issue_638_overlong_ordered_marker_is_lazy_continuation() {
2696        // CommonMark ordered list markers allow at most 9 digits. A run of 10+ digits
2697        // (`1234567890.`) is not a valid marker, so the line is a lazy paragraph
2698        // continuation of the open ordered item, which keeps the list open. The nested
2699        // bullets remain a sublist under the ordered item and are exempt from MD007.
2700        // markdownlint-cli2 reports no MD007 warnings here.
2701        let rule = MD007ULIndent::new(2);
2702        let content = "1. ordered\n1234567890. this is continuation text\n   - child\n     - grandchild\n";
2703        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2704        let result = rule.check(&ctx).unwrap();
2705        assert!(
2706            result.is_empty(),
2707            "an overlong digit run is not a valid ordered marker, so the list stays open and the nested bullets are exempt; got: {result:?}"
2708        );
2709    }
2710
2711    #[test]
2712    fn test_indented_top_level_list_item_is_flagged() {
2713        // A top-level unordered list item indented 2 or 3 spaces is a misindented list
2714        // (4+ spaces would be an indented code block, not a list). markdownlint-cli2
2715        // flags the top item with "Expected: 0". rumdl must flag it too, not only its
2716        // children. The default config has start_indented = false, so the expected
2717        // indent for a depth-0 item is column 0.
2718        let rule = MD007ULIndent::new(2);
2719        for indent in 2..=3 {
2720            let pad = " ".repeat(indent);
2721            let content = format!("{pad}- parent\n{pad}  - child\n");
2722            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2723            let result = rule.check(&ctx).unwrap();
2724            assert!(
2725                result.iter().any(|w| w.line == 1),
2726                "a top-level item indented {indent} spaces must be flagged (Expected 0); got: {result:?}"
2727            );
2728        }
2729    }
2730
2731    #[test]
2732    fn test_indented_code_block_bullet_is_not_a_list_item() {
2733        // Four or more leading spaces at the top level form an indented code block, not a
2734        // list, so MD007 must not fire. Both rumdl and markdownlint-cli2 stay silent.
2735        let rule = MD007ULIndent::new(2);
2736        let content = "    - not a list, this is code\n";
2737        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2738        let result = rule.check(&ctx).unwrap();
2739        assert!(
2740            result.is_empty(),
2741            "a 4-space-indented bullet is an indented code block, not a misindented list; got: {result:?}"
2742        );
2743    }
2744
2745    #[test]
2746    fn test_tab_indent_expands_to_four_column_tabstop() {
2747        // CommonMark expands a leading tab to the next 4-column tab stop when it helps
2748        // define block structure. A single-tab-indented sublist therefore sits at visual
2749        // column 4, which is an over-indent for depth 1 (expected 2). rumdl must report
2750        // the expanded column (found 4), NOT a raw character count of 1. (markdownlint
2751        // counts the tab as a single character and reports "Actual 1"; that is incorrect
2752        // per the CommonMark tab-stop rule, so rumdl deliberately diverges here.)
2753        let rule = MD007ULIndent::new(2);
2754        let content = "- a\n\t- b\n";
2755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2756        let result = rule.check(&ctx).unwrap();
2757        let warning = result
2758            .iter()
2759            .find(|w| w.line == 2)
2760            .expect("a tab-indented sublist at column 4 is over-indented for depth 1 and must be flagged");
2761        assert!(
2762            warning.message.contains("found 4"),
2763            "the tab must expand to the 4-column tab stop (found 4), not be counted as one character; got: {}",
2764            warning.message
2765        );
2766    }
2767
2768    #[test]
2769    fn test_tab_completing_two_space_indent_to_tabstop_is_accepted() {
2770        // Two spaces advance to column 2; a following tab then advances to the next
2771        // 4-column tab stop, landing the sublist marker at column 4 - exactly the
2772        // expected indent for depth 2. With correct tab-stop math the line is well
2773        // indented and must produce no warning. (markdownlint miscounts `  \t` as three
2774        // characters and false-positives with "Actual 3"; rumdl correctly stays silent.)
2775        let rule = MD007ULIndent::new(2);
2776        let content = "- a\n  - b\n  \t- c\n";
2777        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2778        let result = rule.check(&ctx).unwrap();
2779        assert!(
2780            result.is_empty(),
2781            "`  \\t` expands to column 4, the correct depth-2 indent, so no MD007 warning is expected; got: {result:?}"
2782        );
2783    }
2784
2785    #[test]
2786    fn test_issue_638_html_comment_terminates_ordered_list() {
2787        // An HTML comment is a block construct that interrupts the open paragraph and
2788        // terminates the ordered list, just like a heading or fenced code block. The
2789        // later, separately indented unordered list is therefore not a sublist of the
2790        // closed ordered item, so the ordered-ancestor exemption must not leak: the
2791        // misindented child must still be flagged. markdownlint-cli2 reports MD007 on
2792        // the child (Expected: 2; Actual: 6).
2793        let rule = MD007ULIndent::new(2);
2794        let content = "1. ordered\n<!-- comment -->\n\n   - parent\n      - child\n";
2795        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2796        let result = rule.check(&ctx).unwrap();
2797        assert!(
2798            result.iter().any(|w| w.line == 5),
2799            "an HTML comment terminates the ordered list, so the child must still be flagged, got: {result:?}"
2800        );
2801    }
2802
2803    #[test]
2804    fn test_issue_638_blockquoted_list_item_terminates_ordered_list() {
2805        // A blockquoted list item that begins left of the ordered item's content
2806        // column starts a new container and terminates the ordered list (the `>` is
2807        // not indented into the item). The later, separately indented unordered list
2808        // is therefore not a sublist of the closed ordered item, so the
2809        // ordered-ancestor exemption must not leak: the misindented child must still
2810        // be flagged. markdownlint-cli2 reports MD007 on the child
2811        // (Expected: 2; Actual: 5).
2812        let rule = MD007ULIndent::new(2);
2813        let content = "1. ordered\n> - quote list\n\n   - parent\n     - child\n";
2814        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2815        let result = rule.check(&ctx).unwrap();
2816        assert!(
2817            result.iter().any(|w| w.line == 5),
2818            "a blockquoted list item terminates the ordered list, so the child must still be flagged, got: {result:?}"
2819        );
2820    }
2821
2822    #[test]
2823    fn test_issue_638_deeper_nested_quote_terminates_blockquoted_ordered_list() {
2824        // A blockquoted ordered item (`> 1. ordered`) is interrupted by a deeper
2825        // nested quote (`> > quote`). The inner `>` begins left of the ordered
2826        // item's content column (in the item's own quote coordinate space), so it
2827        // is a sibling block that closes the ordered list, not a continuation of
2828        // it. The unordered list that follows inside the same depth-1 quote is
2829        // therefore a fresh top-level list, not a sublist of the (closed) ordered
2830        // item, so the ordered-ancestor exemption must NOT leak to it.
2831        // markdownlint-cli2 (MD007 only) reports the parent (Expected: 0; Actual: 3)
2832        // and the child (Expected: 2; Actual: 6).
2833        let rule = MD007ULIndent::new(2);
2834        let content = "> 1. ordered\n> > quote\n>\n>    - parent\n>       - child\n";
2835        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2836        let result = rule.check(&ctx).unwrap();
2837        assert!(
2838            result.iter().any(|w| w.line == 4),
2839            "deeper nested quote closes the ordered list, so the misindented parent must be flagged, got: {result:?}"
2840        );
2841        assert!(
2842            result.iter().any(|w| w.line == 5),
2843            "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
2844        );
2845    }
2846
2847    #[test]
2848    fn test_issue_638_deeper_quote_list_item_terminates_blockquoted_ordered_list() {
2849        // Same leak as the deeper-nested-quote case, but the interrupting deeper
2850        // quote is itself a list item (`> > - quote list`). Its marker begins left
2851        // of the ordered item's content column (in the item's coordinate space), so
2852        // it closes the ordered list. The unordered list that follows in the depth-1
2853        // quote is therefore a fresh top-level list and must not inherit the
2854        // ordered-ancestor exemption. markdownlint-cli2 reports the parent
2855        // (Expected: 0; Actual: 3) and the child (Expected: 2; Actual: 6).
2856        let rule = MD007ULIndent::new(2);
2857        let content = "> 1. ordered\n> > - quote list\n>\n>    - parent\n>       - child\n";
2858        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2859        let result = rule.check(&ctx).unwrap();
2860        assert!(
2861            result.iter().any(|w| w.line == 4),
2862            "a deeper-quote list item closes the ordered list, so the parent must be flagged, got: {result:?}"
2863        );
2864        assert!(
2865            result.iter().any(|w| w.line == 5),
2866            "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
2867        );
2868    }
2869
2870    #[test]
2871    fn test_issue_638_deeper_quote_indented_into_item_keeps_exemption() {
2872        // When the deeper quote is indented to (or past) the ordered item's content
2873        // column, the `> quote` is a child block of the item, so the ordered list
2874        // stays open and its unordered sublist remains exempt. The termination must
2875        // not over-fire. markdownlint-cli2 reports 0 MD007 errors here.
2876        let rule = MD007ULIndent::new(2);
2877        let content = "> 1. ordered\n>    > quote inside item\n>    - child\n>      - grandchild\n";
2878        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2879        let result = rule.check(&ctx).unwrap();
2880        assert!(
2881            result.is_empty(),
2882            "a deeper quote indented into the item must keep the sublist exempt, got: {result:?}"
2883        );
2884    }
2885
2886    #[test]
2887    fn test_indent4_explicit_with_wide_ordered_parent() {
2888        // When indent=4 and parent is "100. " (text-aligned=5),
2889        // both 4-space and 5-space indent should be accepted.
2890        // The list parser may recognize 4-space as valid nesting under "100."
2891        let config = MD007Config {
2892            indent: crate::types::IndentSize::from_const(4),
2893            start_indented: false,
2894            start_indent: crate::types::IndentSize::from_const(2),
2895            style: md007_config::IndentStyle::TextAligned,
2896            style_explicit: false,
2897            indent_explicit: true,
2898        };
2899        let rule = MD007ULIndent::from_config_struct(config);
2900
2901        // 5-space indent should pass
2902        let content = "100. Wide ordered\n     * Bullet at 5 spaces";
2903        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2904        let result = rule.check(&ctx).unwrap();
2905        assert!(
2906            result.is_empty(),
2907            "indent=4 under '100.' should accept 5-space indent: {result:?}"
2908        );
2909
2910        // 4-space indent should also pass (matches configured indent)
2911        let content_4 = "100. Wide ordered\n    * Bullet at 4 spaces";
2912        let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2913        let result = rule.check(&ctx).unwrap();
2914        assert!(
2915            result.is_empty(),
2916            "indent=4 under '100.' should accept 4-space indent: {result:?}"
2917        );
2918    }
2919}