Skip to main content

rumdl_lib/rules/
md007_ul_indent.rs

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