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