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