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