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