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