Skip to main content

rumdl_lib/rules/
md030_list_marker_space.rs

1//!
2//! Rule MD030: Spaces after list markers
3//!
4//! See [docs/md030.md](../../docs/md030.md) for full documentation, configuration, and examples.
5
6use crate::rule::{LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::blockquote::{effective_indent_in_blockquote, parse_blockquote_prefix};
8use crate::utils::calculate_indentation_width_default;
9use crate::utils::range_utils::calculate_match_range;
10use toml;
11
12mod md030_config;
13use md030_config::MD030Config;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum ListType {
17    Unordered,
18    Ordered,
19}
20
21/// How a following line relates to the list item being scanned.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23enum Continuation {
24    /// Part of the item (a continuation line or nested content).
25    Belongs,
26    /// A blank line, which neither continues nor ends the item.
27    Skip,
28    /// The item is over (a sibling/ancestor marker or under-indented content).
29    Ends,
30}
31
32/// An open list item (or inline bullet) whose content the current line may
33/// continue, tracked on a stack. `shift` is its cumulative indent shift (its own
34/// marker re-spacing plus every ancestor's), applied to the continuation lines it
35/// owns.
36struct AlignFrame {
37    marker_column: usize,
38    bq_level: usize,
39    min_indent: usize,
40    shift: isize,
41}
42
43#[derive(Clone, Default)]
44pub struct MD030ListMarkerSpace {
45    config: MD030Config,
46}
47
48impl MD030ListMarkerSpace {
49    pub fn new(ul_single: usize, ul_multi: usize, ol_single: usize, ol_multi: usize) -> Self {
50        Self {
51            config: MD030Config {
52                ul_single: crate::types::PositiveUsize::new(ul_single)
53                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
54                ul_multi: crate::types::PositiveUsize::new(ul_multi)
55                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
56                ol_single: crate::types::PositiveUsize::new(ol_single)
57                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
58                ol_multi: crate::types::PositiveUsize::new(ol_multi)
59                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
60                ol_align_column: crate::types::OlAlignColumn::default(),
61            },
62        }
63    }
64
65    fn from_config_struct(config: MD030Config) -> Self {
66        Self { config }
67    }
68
69    /// Set the ordered-list alignment column. Intended for tests; production code
70    /// configures this via `MD030.ol-align-column`. Panics on an out-of-range value
71    /// (the config path rejects those with a diagnostic instead).
72    #[cfg(test)]
73    fn with_ol_align_column(mut self, column: usize) -> Self {
74        self.config.ol_align_column =
75            crate::types::OlAlignColumn::new(column).expect("test ol-align-column out of range");
76        self
77    }
78
79    /// The target column for ordered list text, or `None` when alignment is off.
80    fn ol_align_column(&self) -> Option<usize> {
81        self.config.ol_align_column.enabled()
82    }
83
84    fn get_expected_spaces(&self, list_type: ListType, is_multi: bool) -> usize {
85        match (list_type, is_multi) {
86            (ListType::Unordered, false) => self.config.ul_single.get(),
87            (ListType::Unordered, true) => self.config.ul_multi.get(),
88            (ListType::Ordered, false) => self.config.ol_single.get(),
89            (ListType::Ordered, true) => self.config.ol_multi.get(),
90        }
91    }
92}
93
94impl Rule for MD030ListMarkerSpace {
95    fn name(&self) -> &'static str {
96        "MD030"
97    }
98
99    fn description(&self) -> &'static str {
100        "Spaces after list markers should be consistent"
101    }
102
103    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
104        let mut warnings = Vec::new();
105
106        // Early return if no list content
107        if self.should_skip(ctx) {
108            return Ok(warnings);
109        }
110
111        let lines = ctx.raw_lines();
112
113        // Track which lines we've already processed (to avoid duplicates)
114        let mut processed_lines = std::collections::HashSet::new();
115
116        // Content only needs re-indenting when a marker can *widen*, pushing content
117        // right, which otherwise detaches nested lists (a multi-line `1.` marker that
118        // grows leaves its `   1. inner` child under-indented and flattened). Narrowing
119        // leaves content over-indented but attached, which MD077 tightens, so we skip
120        // the whole mechanism unless some configured spacing exceeds 1 (or we align to
121        // a column). Widening needs an expected width above the 1 that recognized
122        // markers already have.
123        let may_widen = self.ol_align_column().is_some()
124            || self.config.ul_single.get() > 1
125            || self.config.ul_multi.get() > 1
126            || self.config.ol_single.get() > 1
127            || self.config.ol_multi.get() > 1;
128
129        // Active list items (and inline bullets) whose content the current line may
130        // continue. Each frame carries the item's cumulative indent shift (its own
131        // marker re-spacing plus every ancestor's), so a continuation line is
132        // re-indented by the shift of the innermost frame that still owns it. Because
133        // the loop walks top to bottom, every owning item is already on the stack by
134        // the time we reach its content, so the shift is known on the spot.
135        let mut stack: Vec<AlignFrame> = Vec::new();
136
137        // Main pass: re-indent each continuation/nested line as the loop reaches it,
138        // and check parser-recognized list items.
139        for (line_num, line_info) in ctx.lines.iter().enumerate() {
140            let line_num_1based = line_num + 1;
141            let line = lines[line_num];
142
143            // Drop frames whose item has ended, then read the shift and blockquote
144            // level of the innermost item that still owns this line.
145            let (owner_shift, owner_bq_level) = if may_widen {
146                while let Some(&AlignFrame {
147                    marker_column,
148                    bq_level,
149                    min_indent,
150                    ..
151                }) = stack.last()
152                {
153                    if Self::classify_continuation(ctx, line_num_1based, lines, marker_column, bq_level, min_indent)
154                        == Continuation::Ends
155                    {
156                        stack.pop();
157                    } else {
158                        break;
159                    }
160                }
161                stack.last().map_or((0, 0), |f| (f.shift, f.bq_level))
162            } else {
163                (0, 0)
164            };
165
166            // Skip code blocks, math blocks, PyMdown blocks, and MkDocs markdown HTML divs (grid cards use custom spacing)
167            let is_list_item = line_info.list_item.is_some()
168                && !line_info.in_code_block
169                && !line_info.in_math_block
170                && !line_info.in_pymdown_block
171                && !line_info.in_mkdocs_html_markdown
172                && !line_info.in_footnote_definition;
173
174            if !is_list_item {
175                // A continuation/nested line follows its owning item's shift.
176                if owner_shift > 0
177                    && !line.trim().is_empty()
178                    && let Some(warning) = self.indent_shift_warning(ctx, line, line_num, owner_bq_level, owner_shift)
179                {
180                    processed_lines.insert(line_num_1based);
181                    warnings.push(warning);
182                }
183                continue;
184            }
185
186            processed_lines.insert(line_num_1based);
187            let Some(list_info) = &line_info.list_item else {
188                continue;
189            };
190
191            // The item is content of its parent, so its leading indent follows too.
192            if may_widen
193                && let Some(warning) = self.indent_shift_warning(ctx, line, line_num, owner_bq_level, owner_shift)
194            {
195                warnings.push(warning);
196            }
197
198            let list_type = if list_info.is_ordered {
199                ListType::Ordered
200            } else {
201                ListType::Unordered
202            };
203            let marker_end = list_info.marker_column + list_info.marker.len();
204
205            // MD030 only applies when there is content after the marker.
206            if !Self::has_content_after_marker(line, marker_end) {
207                continue;
208            }
209
210            let actual_spaces = list_info.content_column.saturating_sub(marker_end);
211
212            let expected_spaces = if list_type == ListType::Ordered
213                && let Some(target_column) = self.ol_align_column()
214            {
215                // Align ordered text to the target column, overriding ol-single/
216                // ol-multi: pad a narrow marker up to it, and let one too wide overflow
217                // with a single space. Capped at 4 spaces, since 5+ start an indented
218                // code block in CommonMark.
219                let marker_len = list_info.marker.len();
220                target_column.saturating_sub(marker_len).clamp(1, 4)
221            } else {
222                // Default: a fixed number of spaces by list type and whether the item
223                // is single- or multi-line.
224                let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, lines);
225                self.get_expected_spaces(list_type, is_multi_line)
226            };
227
228            if actual_spaces != expected_spaces {
229                warnings.push(self.spacing_fix_warning(
230                    ctx,
231                    line,
232                    line_num,
233                    marker_end..marker_end + actual_spaces,
234                    expected_spaces,
235                    format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})"),
236                ));
237            }
238
239            // Push this item's frame so its continuation lines follow it, and space any
240            // inline nested bullet (`1. - x`), which gets its own frame.
241            if may_widen
242                && let Some((marker_column, bq_level, min_indent)) = Self::continuation_params(ctx, line_num_1based)
243            {
244                let item_shift = owner_shift + (expected_spaces as isize - actual_spaces as isize);
245                stack.push(AlignFrame {
246                    marker_column,
247                    bq_level,
248                    min_indent,
249                    shift: item_shift,
250                });
251                if list_info.is_ordered
252                    && let Some(warning) = self.align_inline_bullet(
253                        ctx,
254                        line_num_1based,
255                        lines,
256                        list_info.content_column,
257                        item_shift,
258                        &mut stack,
259                    )
260                {
261                    warnings.push(warning);
262                }
263            }
264        }
265
266        // Second pass: Detect list-like patterns the parser didn't recognize
267        // This handles cases like "1.Text" where there's no space after the marker
268        for (line_idx, line) in lines.iter().enumerate() {
269            let line_num = line_idx + 1;
270
271            // Skip if already processed or in code block/front matter/math block
272            if processed_lines.contains(&line_num) {
273                continue;
274            }
275            if let Some(line_info) = ctx.lines.get(line_idx)
276                && (line_info.in_code_block
277                    || line_info.in_front_matter
278                    || line_info.in_html_comment
279                    || line_info.in_mdx_comment
280                    || line_info.in_math_block
281                    || line_info.in_pymdown_block
282                    || line_info.in_mkdocs_html_markdown
283                    || line_info.in_footnote_definition)
284            {
285                continue;
286            }
287
288            // Skip indented code blocks
289            if self.is_indented_code_block(line, line_idx, lines) {
290                continue;
291            }
292
293            // Try to detect list-like patterns using regex-based detection
294            if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, lines) {
295                warnings.push(warning);
296            }
297        }
298
299        Ok(warnings)
300    }
301
302    fn category(&self) -> RuleCategory {
303        RuleCategory::List
304    }
305
306    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
307        if ctx.content.is_empty() {
308            return true;
309        }
310
311        // Fast byte-level check for list markers (including ordered lists)
312        let bytes = ctx.content.as_bytes();
313        !bytes.contains(&b'*')
314            && !bytes.contains(&b'-')
315            && !bytes.contains(&b'+')
316            && !bytes.iter().any(|&b| b.is_ascii_digit())
317    }
318
319    fn as_any(&self) -> &dyn std::any::Any {
320        self
321    }
322
323    crate::impl_rule_config_methods!(MD030Config);
324
325    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
326        if self.should_skip(ctx) {
327            return Ok(ctx.content.to_string());
328        }
329
330        // Derive fixes directly from check() so detection and fixing share one code path.
331        // This guarantees that every violation check() reports is also fixed, with no
332        // possibility of the two paths diverging due to mismatched skip conditions.
333        let warnings = self.check(ctx)?;
334        if warnings.is_empty() {
335            return Ok(ctx.content.to_string());
336        }
337
338        let warnings =
339            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
340
341        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
342            .map_err(crate::rule::LintError::InvalidInput)
343    }
344}
345
346impl MD030ListMarkerSpace {
347    /// Check if a list item line has content after the marker
348    /// Returns false if the line ends after the marker (with optional whitespace)
349    /// MD030 only applies when there IS content on the same line as the marker
350    #[inline]
351    fn has_content_after_marker(line: &str, marker_end: usize) -> bool {
352        if marker_end >= line.len() {
353            return false;
354        }
355        !line[marker_end..].trim().is_empty()
356    }
357
358    /// Build a warning that replaces the whitespace run at byte range `span` within
359    /// the line at `line_idx` (0-based) with `want` spaces. Shared by the marker-
360    /// spacing checks and the nested-content re-indentation.
361    fn spacing_fix_warning(
362        &self,
363        ctx: &crate::lint_context::LintContext,
364        line: &str,
365        line_idx: usize,
366        span: std::ops::Range<usize>,
367        want: usize,
368        message: String,
369    ) -> LintWarning {
370        let (start_line, start_col, end_line, end_col) =
371            calculate_match_range(line_idx + 1, line, span.start, span.len());
372        let base = ctx.line_offsets.get(line_idx).copied().unwrap_or(0);
373        LintWarning {
374            rule_name: Some(self.name().to_string()),
375            severity: Severity::Warning,
376            line: start_line,
377            column: start_col,
378            end_line,
379            end_column: end_col,
380            message,
381            fix: Some(crate::rule::Fix::new(
382                base + span.start..base + span.end,
383                " ".repeat(want),
384            )),
385        }
386    }
387
388    /// If the content at byte `content_col` of `line` is an inline unordered marker
389    /// (`-`/`*`/`+` followed by spaces and then content, as in `1. - x`), return the
390    /// byte offset of the spaces after that bullet and their current count. `None`
391    /// for anything else (e.g. `1. -x` or `1. *emphasis*`, which aren't markers).
392    fn inline_unordered_spaces(line: &str, content_col: usize) -> Option<(usize, usize)> {
393        if !matches!(line.as_bytes().get(content_col), Some(b'-' | b'*' | b'+')) {
394            return None;
395        }
396        let offset = content_col + 1;
397        let rest = line.get(offset..)?;
398        let spaces = rest.len() - rest.trim_start_matches(' ').len();
399        if spaces == 0 || rest[spaces..].is_empty() {
400            return None;
401        }
402        Some((offset, spaces))
403    }
404
405    /// The marker column, blockquote nesting level, and minimum (blockquote-aware)
406    /// indent a following line needs to continue the list item on `line_num`. These
407    /// are the inputs shared by every continuation scan. `None` if the line isn't a
408    /// list item. Inside a blockquote the indent excludes the prefix so it stays in
409    /// the coordinate system of [`effective_indent_in_blockquote`].
410    fn continuation_params(ctx: &crate::lint_context::LintContext, line_num: usize) -> Option<(usize, usize, usize)> {
411        let info = ctx.line_info(line_num)?;
412        let list = info.list_item.as_ref()?;
413        let (bq_level, min_indent) = match &info.blockquote {
414            Some(bq) if bq.nesting_level > 0 => (bq.nesting_level, list.content_column.saturating_sub(bq.prefix.len())),
415            _ => (0, list.content_column),
416        };
417        Some((list.marker_column, bq_level, min_indent))
418    }
419
420    /// Classify the line at `next_line_num` (1-based) relative to a list item whose
421    /// marker is at `marker_column` with continuation threshold (`bq_level`,
422    /// `min_indent`). The single source of truth for what belongs to a list item.
423    fn classify_continuation(
424        ctx: &crate::lint_context::LintContext,
425        next_line_num: usize,
426        lines: &[&str],
427        marker_column: usize,
428        bq_level: usize,
429        min_indent: usize,
430    ) -> Continuation {
431        let Some(info) = ctx.line_info(next_line_num) else {
432            return Continuation::Skip;
433        };
434        // A deeper marker is nested content; one at the same or a shallower column
435        // ends the item.
436        if let Some(next_list) = &info.list_item {
437            return if next_list.marker_column <= marker_column {
438                Continuation::Ends
439            } else {
440                Continuation::Belongs
441            };
442        }
443        let content = lines.get(next_line_num - 1).copied().unwrap_or("");
444        if content.trim().is_empty() {
445            return Continuation::Skip; // Blank lines don't decide on their own.
446        }
447        let raw_indent = content.len() - content.trim_start().len();
448        if effective_indent_in_blockquote(content, bq_level, raw_indent) < min_indent {
449            Continuation::Ends
450        } else {
451            Continuation::Belongs
452        }
453    }
454
455    /// Whether the list item on `line_num` spans multiple lines (has continuation or
456    /// nested content).
457    fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
458        let Some((marker_column, bq_level, min_indent)) = Self::continuation_params(ctx, line_num) else {
459            return false;
460        };
461        Self::has_continuation(ctx, line_num, lines, marker_column, bq_level, min_indent)
462    }
463
464    /// Whether any line after `line_num` (1-based) belongs to an item with the given
465    /// continuation threshold, scanning until the item ends. Shared by the multi-line
466    /// check and the inline-bullet check.
467    fn has_continuation(
468        ctx: &crate::lint_context::LintContext,
469        line_num: usize,
470        lines: &[&str],
471        marker_column: usize,
472        bq_level: usize,
473        min_indent: usize,
474    ) -> bool {
475        for next in (line_num + 1)..=lines.len() {
476            match Self::classify_continuation(ctx, next, lines, marker_column, bq_level, min_indent) {
477                Continuation::Belongs => return true,
478                Continuation::Ends => break,
479                Continuation::Skip => {}
480            }
481        }
482        false
483    }
484
485    /// Byte offset on `line` where its shiftable indent begins: column 0 when the
486    /// owning item is at top level, or just past the blockquote prefix when it sits
487    /// inside a blockquote (its indent lives after the `>` markers).
488    fn write_offset(owner_bq_level: usize, line: &str) -> usize {
489        match owner_bq_level {
490            0 => 0,
491            _ => parse_blockquote_prefix(line).map_or(0, |p| p.prefix.len()),
492        }
493    }
494
495    /// Build the warning that re-indents a continuation/nested `line` (0-based
496    /// `line_idx`) by `shift` columns, within its owning item's coordinate system.
497    /// Only a positive shift (content moving right, to stay attached to a widened
498    /// marker) is emitted; a non-positive shift leaves content over-indented but
499    /// attached, which MD077 cleans up. `None` when nothing moves.
500    fn indent_shift_warning(
501        &self,
502        ctx: &crate::lint_context::LintContext,
503        line: &str,
504        line_idx: usize,
505        owner_bq_level: usize,
506        shift: isize,
507    ) -> Option<LintWarning> {
508        if shift <= 0 {
509            return None;
510        }
511        let offset = Self::write_offset(owner_bq_level, line);
512        let after = &line[offset..];
513        let indent = after.len() - after.trim_start().len();
514        let new_indent = (indent as isize + shift).max(0) as usize;
515        if new_indent == indent {
516            return None;
517        }
518        Some(self.spacing_fix_warning(
519            ctx,
520            line,
521            line_idx,
522            offset..offset + indent,
523            new_indent,
524            format!(
525                "Nested content should align with the list marker (Expected indent: {new_indent}; Actual: {indent})"
526            ),
527        ))
528    }
529
530    /// The first item of a nested unordered list shares the ordered marker's line
531    /// (`1. - x`), where the parser exposes only the outer marker. Space that inline
532    /// bullet like a sibling bullet on its own line; when its spacing changes, push a
533    /// frame so its own continuation lines pick up the extra shift. `item_shift` is
534    /// the enclosing ordered item's cumulative shift. Returns the bullet's spacing
535    /// warning, if any.
536    fn align_inline_bullet(
537        &self,
538        ctx: &crate::lint_context::LintContext,
539        line_num: usize,
540        lines: &[&str],
541        content_column: usize,
542        item_shift: isize,
543        stack: &mut Vec<AlignFrame>,
544    ) -> Option<LintWarning> {
545        let line = lines[line_num - 1];
546        let (offset, spaces) = Self::inline_unordered_spaces(line, content_column)?;
547        let bullet_content_col = offset + spaces;
548        // ul-multi if the bullet itself spans lines, else ul-single, measured with a
549        // raw indent (bq_level 0) like a bullet that begins its own line.
550        let multi = Self::has_continuation(ctx, line_num, lines, content_column, 0, bullet_content_col);
551        let want = if multi {
552            self.config.ul_multi.get()
553        } else {
554            self.config.ul_single.get()
555        };
556        if spaces == want {
557            return None;
558        }
559        let bullet_delta = want as isize - spaces as isize;
560        stack.push(AlignFrame {
561            marker_column: content_column,
562            bq_level: 0,
563            min_indent: bullet_content_col,
564            shift: item_shift + bullet_delta,
565        });
566        Some(self.spacing_fix_warning(
567            ctx,
568            line,
569            line_num - 1,
570            offset..offset + spaces,
571            want,
572            format!("Spaces after list markers (Expected: {want}; Actual: {spaces})"),
573        ))
574    }
575
576    /// Detect list-like patterns that the parser didn't recognize (e.g., "1.Text" with no space)
577    /// This implements user-intention-based detection: if it looks like a list item, flag it
578    fn check_unrecognized_list_marker(
579        &self,
580        ctx: &crate::lint_context::LintContext,
581        line: &str,
582        line_num: usize,
583        lines: &[&str],
584    ) -> Option<LintWarning> {
585        // Strip blockquote prefix to analyze the content.
586        // Track the prefix length so fix positions are relative to the original line.
587        let (bq_prefix_len, content) = match parse_blockquote_prefix(line) {
588            Some(parsed) => (parsed.prefix.len(), parsed.content),
589            None => (0, line),
590        };
591
592        let trimmed = content.trim_start();
593        let indent_len = content.len() - trimmed.len();
594
595        // Note: We intentionally do NOT apply heuristic detection to unordered list markers
596        // (*, -, +) because they have too many non-list uses: emphasis, globs, diffs, etc.
597        // The parser handles valid unordered list items; we only do heuristic detection
598        // for ordered lists where "1.Text" is almost always a list item with missing space.
599
600        // Check for ordered list markers (digits followed by .) without proper spacing
601        if let Some(dot_pos) = trimmed.find('.') {
602            let before_dot = &trimmed[..dot_pos];
603            if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
604                let after_dot = &trimmed[dot_pos + 1..];
605                // Only flag if there's content directly after the marker (no space, no tab)
606                if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
607                    let first_char = after_dot.chars().next().unwrap_or(' ');
608
609                    // For CLEAR user intent, only flag if:
610                    // 1. Starts with uppercase letter (strong list indicator), OR
611                    // 2. Starts with [ or ( (link/paren content)
612                    // Lowercase and digits are ambiguous (could be decimal, version, etc.)
613                    let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
614
615                    if is_clear_intent {
616                        let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
617                        let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
618
619                        let marker = format!("{before_dot}.");
620                        let marker_pos = indent_len;
621                        let marker_end = marker_pos + marker.len();
622                        // Offset from the start of the original line (including blockquote prefix).
623                        let offset_in_line = bq_prefix_len + marker_end;
624
625                        let (start_line, start_col, end_line, end_col) =
626                            calculate_match_range(line_num, line, offset_in_line, 0);
627
628                        let correct_spaces = " ".repeat(expected_spaces);
629                        let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
630                        let fix_position = line_start_byte + offset_in_line;
631
632                        return Some(LintWarning {
633                            rule_name: Some("MD030".to_string()),
634                            severity: Severity::Warning,
635                            line: start_line,
636                            column: start_col,
637                            end_line,
638                            end_column: end_col,
639                            message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
640                            fix: Some(crate::rule::Fix::new(fix_position..fix_position, correct_spaces)),
641                        });
642                    }
643                }
644            }
645        }
646
647        None
648    }
649
650    /// Simplified multi-line check for unrecognized list items
651    fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
652        // For unrecognized list items, we can't rely on parser info
653        // Check if the next line exists and appears to be a continuation
654        if line_num < lines.len() {
655            let next_line = lines[line_num]; // line_num is 1-based, so this is the next line
656            let next_trimmed = next_line.trim();
657            // If next line is non-empty and indented, it might be a continuation
658            if !next_trimmed.is_empty() && next_line.starts_with(' ') {
659                return true;
660            }
661        }
662        false
663    }
664
665    /// Check if a line is part of an indented code block (4+ columns with blank line before)
666    fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
667        // Must have 4+ columns of indentation (accounting for tab expansion)
668        if calculate_indentation_width_default(line) < 4 {
669            return false;
670        }
671
672        // If it's the first line, it's not an indented code block
673        if line_idx == 0 {
674            return false;
675        }
676
677        // Check if there's a blank line before this line or before the start of the indented block
678        if self.has_blank_line_before_indented_block(line_idx, lines) {
679            return true;
680        }
681
682        false
683    }
684
685    /// Check if there's a blank line before the start of an indented block
686    fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
687        // Walk backwards to find the start of the indented block
688        let mut current_idx = line_idx;
689
690        // Find the first line in this indented block
691        while current_idx > 0 {
692            let current_line = lines[current_idx];
693            let prev_line = lines[current_idx - 1];
694
695            // If current line is not indented (< 4 columns), we've gone too far
696            if calculate_indentation_width_default(current_line) < 4 {
697                break;
698            }
699
700            // If previous line is not indented, check if it's blank
701            if calculate_indentation_width_default(prev_line) < 4 {
702                return prev_line.trim().is_empty();
703            }
704
705            current_idx -= 1;
706        }
707
708        false
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use crate::lint_context::LintContext;
716    use indoc::indoc;
717
718    /// Assert that running `fix()` on content with violations produces output that
719    /// passes `check()` with zero remaining violations.
720    fn assert_fix_resolves_all_violations(rule: &MD030ListMarkerSpace, content: &str) {
721        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722        let before = rule.check(&ctx).unwrap();
723        assert!(
724            !before.is_empty(),
725            "Expected violations but check() found none in:\n{content}"
726        );
727
728        let fixed = rule.fix(&ctx).unwrap();
729        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
730        let after = rule.check(&ctx_fixed).unwrap();
731        assert!(
732            after.is_empty(),
733            "fix() left {} violation(s) unresolved:\n{:?}\nOriginal:\n{content}\nFixed:\n{fixed}",
734            after.len(),
735            after
736        );
737    }
738
739    #[test]
740    fn test_basic_functionality() {
741        let rule = MD030ListMarkerSpace::default();
742        let content = "* Item 1\n* Item 2\n  * Nested item\n1. Ordered item";
743        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744        let result = rule.check(&ctx).unwrap();
745        assert!(
746            result.is_empty(),
747            "Correctly spaced list markers should not generate warnings"
748        );
749        let content = "*  Item 1 (too many spaces)\n* Item 2\n1.   Ordered item (too many spaces)";
750        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751        let result = rule.check(&ctx).unwrap();
752        // Expect warnings for lines with too many spaces after the marker
753        assert_eq!(
754            result.len(),
755            2,
756            "Should flag lines with too many spaces after list marker"
757        );
758        for warning in result {
759            assert!(
760                warning.message.starts_with("Spaces after list markers (Expected:")
761                    && warning.message.contains("Actual:"),
762                "Warning message should include expected and actual values, got: '{}'",
763                warning.message
764            );
765        }
766    }
767
768    #[test]
769    fn test_nested_emphasis_not_flagged_issue_278() {
770        // Issue #278: Nested emphasis like *text **bold** more* should not trigger MD030
771        let rule = MD030ListMarkerSpace::default();
772
773        // This is emphasis with nested bold - NOT a list item
774        let content = "*This text is **very** important*";
775        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776        let result = rule.check(&ctx).unwrap();
777        assert!(
778            result.is_empty(),
779            "Nested emphasis should not trigger MD030, got: {result:?}"
780        );
781
782        // Simple emphasis - NOT a list item
783        let content2 = "*Hello World*";
784        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
785        let result2 = rule.check(&ctx2).unwrap();
786        assert!(
787            result2.is_empty(),
788            "Simple emphasis should not trigger MD030, got: {result2:?}"
789        );
790
791        // Bold text - NOT a list item
792        let content3 = "**bold text**";
793        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
794        let result3 = rule.check(&ctx3).unwrap();
795        assert!(
796            result3.is_empty(),
797            "Bold text should not trigger MD030, got: {result3:?}"
798        );
799
800        // Bold+italic - NOT a list item
801        let content4 = "***bold and italic***";
802        let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
803        let result4 = rule.check(&ctx4).unwrap();
804        assert!(
805            result4.is_empty(),
806            "Bold+italic should not trigger MD030, got: {result4:?}"
807        );
808
809        // Actual list item with proper spacing - should NOT trigger
810        let content5 = "* Item with space";
811        let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
812        let result5 = rule.check(&ctx5).unwrap();
813        assert!(
814            result5.is_empty(),
815            "Properly spaced list item should not trigger MD030, got: {result5:?}"
816        );
817    }
818
819    #[test]
820    fn test_empty_marker_line_not_flagged_issue_288() {
821        // Issue #288: List items with no content on the marker line should not trigger MD030
822        // The space requirement only applies when there IS content after the marker
823        let rule = MD030ListMarkerSpace::default();
824
825        // Case 1: Unordered list with empty marker line followed by code block
826        let content = "-\n    ```python\n    print(\"code\")\n    ```\n";
827        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828        let result = rule.check(&ctx).unwrap();
829        assert!(
830            result.is_empty(),
831            "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
832        );
833
834        // Case 2: Ordered list with empty marker line followed by code block
835        let content = "1.\n    ```python\n    print(\"code\")\n    ```\n";
836        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837        let result = rule.check(&ctx).unwrap();
838        assert!(
839            result.is_empty(),
840            "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
841        );
842
843        // Case 3: Empty marker line followed by paragraph continuation
844        let content = "-\n    This is a paragraph continuation\n    of the list item.\n";
845        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846        let result = rule.check(&ctx).unwrap();
847        assert!(
848            result.is_empty(),
849            "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
850        );
851
852        // Case 4: Nested list with empty marker line
853        let content = "- Parent item\n  -\n      Nested content\n";
854        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855        let result = rule.check(&ctx).unwrap();
856        assert!(
857            result.is_empty(),
858            "Nested empty marker line should not trigger MD030, got: {result:?}"
859        );
860
861        // Case 5: Multiple list items, some with empty markers
862        let content = "- Item with content\n-\n    Code block\n- Another item\n";
863        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864        let result = rule.check(&ctx).unwrap();
865        assert!(
866            result.is_empty(),
867            "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
868        );
869    }
870
871    #[test]
872    fn test_marker_with_content_still_flagged_issue_288() {
873        // Ensure we still flag markers with content but wrong spacing
874        let rule = MD030ListMarkerSpace::default();
875
876        // Two spaces before content - should flag
877        let content = "-  Two spaces before content\n";
878        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879        let result = rule.check(&ctx).unwrap();
880        assert_eq!(
881            result.len(),
882            1,
883            "Two spaces after unordered marker should still trigger MD030"
884        );
885
886        // Ordered list with two spaces - should flag
887        let content = "1.  Two spaces\n";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889        let result = rule.check(&ctx).unwrap();
890        assert_eq!(
891            result.len(),
892            1,
893            "Two spaces after ordered marker should still trigger MD030"
894        );
895
896        // Normal list item - should NOT flag
897        let content = "- Normal item\n";
898        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899        let result = rule.check(&ctx).unwrap();
900        assert!(
901            result.is_empty(),
902            "Normal list item should not trigger MD030, got: {result:?}"
903        );
904    }
905
906    #[test]
907    fn test_nested_items_with_4space_indent_are_detected() {
908        // Nested list items indented with 4 spaces should be checked for marker spacing.
909        // Previously, the check skipped any line with >= 4 columns of indentation,
910        // treating them as indented code blocks even when the parser identified them as
911        // list items.
912        let rule = MD030ListMarkerSpace::new(3, 3, 1, 1);
913
914        // Tight nested list (no blank line): the exact scenario from issue #565.
915        // ul_single=3: the nested item "    - Nested wrong" has 1 space → violation.
916        let content = "-   Top-level correct\n    - Nested wrong spacing\n    -   Nested correct\n";
917        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918        let result = rule.check(&ctx).unwrap();
919        assert_eq!(
920            result.len(),
921            1,
922            "Nested item with 1 space (ul_single=3) should be flagged; got: {result:?}"
923        );
924        assert_eq!(result[0].line, 2, "Violation should be on line 2");
925        assert!(
926            result[0].message.contains("Expected: 3") && result[0].message.contains("Actual: 1"),
927            "Message should state expected/actual spaces; got: {}",
928            result[0].message
929        );
930
931        // fix() must produce correct output for the tight nested case.
932        let fixed = rule.fix(&ctx).unwrap();
933        assert_eq!(
934            fixed, "-   Top-level correct\n    -   Nested wrong spacing\n    -   Nested correct\n",
935            "fix() should expand 1 space to ul_single=3 on the nested item"
936        );
937
938        // Nested unordered item with correct spacing should not be flagged
939        let content_ok = "-   Top-level\n    -   Nested correct\n";
940        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
941        let result_ok = rule.check(&ctx_ok).unwrap();
942        assert!(
943            result_ok.is_empty(),
944            "Nested item with correct spacing should not be flagged; got: {result_ok:?}"
945        );
946
947        // Nested ordered item: 4 spaces indent, 1 space after marker → violation (ol_single=2)
948        let rule_ol = MD030ListMarkerSpace::new(1, 1, 2, 2);
949        let content_ol = "1.  Top-level multi\n    1. Nested wrong\n";
950        let ctx_ol = LintContext::new(content_ol, crate::config::MarkdownFlavor::Standard, None);
951        let result_ol = rule_ol.check(&ctx_ol).unwrap();
952        assert_eq!(
953            result_ol.len(),
954            1,
955            "Nested ordered item with 1 space (ol_single=2) should be flagged; got: {result_ol:?}"
956        );
957        let fixed_ol = rule_ol.fix(&ctx_ol).unwrap();
958        assert_eq!(
959            fixed_ol, "1.  Top-level multi\n    1.  Nested wrong\n",
960            "fix() should expand 1 space to ol_single=2 on the nested ordered item"
961        );
962
963        // Deeply nested (8+ spaces) items are also checked.
964        // Only the 8-space-indented item has wrong spacing; outer levels are correct.
965        let content_deep = "-   Level 1\n    -   Level 2\n        - Level 3 wrong\n        -   Level 3 correct\n";
966        let ctx_deep = LintContext::new(content_deep, crate::config::MarkdownFlavor::Standard, None);
967        let result_deep = rule.check(&ctx_deep).unwrap();
968        assert_eq!(
969            result_deep.len(),
970            1,
971            "Deeply nested (8-space) item with 1 space should be flagged; got: {result_deep:?}"
972        );
973        assert_eq!(result_deep[0].line, 3, "Violation should be on the deeply nested line");
974
975        // Verify the full roundtrip: fix() must resolve everything check() found.
976        assert_fix_resolves_all_violations(&rule, content);
977        assert_fix_resolves_all_violations(&rule_ol, content_ol);
978        assert_fix_resolves_all_violations(&rule, content_deep);
979    }
980
981    #[test]
982    fn test_loose_nested_item_fix_matches_check() {
983        // A loose nested list item (blank line between parent and child) with 4-space
984        // indentation must be both detected AND fixed. check() and fix() must agree.
985        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
986
987        let content = "- parent\n\n    -  nested wrong\n";
988        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989
990        // check() must detect it
991        let warnings = rule.check(&ctx).unwrap();
992        assert_eq!(
993            warnings.len(),
994            1,
995            "Loose nested item with 2 spaces should be detected; got: {warnings:?}"
996        );
997
998        // fix() must fix it
999        let fixed = rule.fix(&ctx).unwrap();
1000        assert_eq!(
1001            fixed, "- parent\n\n    - nested wrong\n",
1002            "fix() should reduce 2 spaces to 1 for loose nested item"
1003        );
1004
1005        // Verify the full roundtrip: fix() must resolve everything check() found.
1006        assert_fix_resolves_all_violations(&rule, content);
1007    }
1008
1009    #[test]
1010    fn test_ol_multi_reindents_nested_to_stay_attached() {
1011        // Regression: widening a multi-line marker (here ol-multi = 3) moves its
1012        // content right. Without re-indenting the nested list it would end up left of
1013        // the parent's content column and detach (the nested `1.` would flatten into a
1014        // sibling). The shifts accumulate down the levels so everything stays nested.
1015        let rule = MD030ListMarkerSpace::new(1, 1, 1, 3); // ol-multi = 3
1016        let content = indoc! {"
1017            1. outer
1018               1. inner
1019                  deep
1020        "};
1021        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1022        assert_eq!(
1023            rule.fix(&ctx).unwrap(),
1024            indoc! {"
1025                1.   outer
1026                     1.   inner
1027                          deep
1028            "}
1029        );
1030        assert_fix_resolves_all_violations(&rule, content);
1031    }
1032
1033    #[test]
1034    fn test_ol_multi_does_not_reindent_when_narrowing() {
1035        // The mirror case: removing extra spaces (narrowing) leaves content
1036        // over-indented but attached, which MD077 tightens, so MD030 leaves the
1037        // continuation alone rather than fighting that rule.
1038        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1); // defaults: markers narrow to 1
1039        let content = indoc! {"
1040            1.   outer
1041                 continuation
1042        "};
1043        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044        assert_eq!(
1045            rule.fix(&ctx).unwrap(),
1046            indoc! {"
1047                1. outer
1048                     continuation
1049            "},
1050            "marker narrows to 1 space; the over-indented continuation is left for MD077"
1051        );
1052    }
1053
1054    #[test]
1055    fn test_ol_align_column_off_by_default() {
1056        // Without ol-align-column (the default), a list with uniform single spaces
1057        // is valid even when markers differ in width.
1058        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
1059        let content = indoc! {"
1060            1. one
1061            9. nine
1062            10. ten
1063        "};
1064        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065        assert!(
1066            rule.check(&ctx).unwrap().is_empty(),
1067            "Default behaviour should not require column alignment"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_ol_align_column_basic() {
1073        // Issue #644: aligning to column 4 keeps the text column fixed across a
1074        // digit boundary (9. -> 10.).
1075        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1076        let content = indoc! {"
1077            1. one
1078            9. nine
1079            10. ten
1080        "};
1081        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082
1083        let warnings = rule.check(&ctx).unwrap();
1084        assert_eq!(
1085            warnings.len(),
1086            2,
1087            "Single-digit markers should be flagged; got: {warnings:?}"
1088        );
1089        assert!(warnings.iter().all(|w| w.line == 1 || w.line == 2));
1090        assert!(
1091            warnings[0].message.contains("Expected: 2") && warnings[0].message.contains("Actual: 1"),
1092            "Message should report the aligned target; got: {}",
1093            warnings[0].message
1094        );
1095        assert_eq!(
1096            warnings[0].column, 3,
1097            "Span should start at the whitespace after the marker"
1098        );
1099
1100        assert_eq!(
1101            rule.fix(&ctx).unwrap(),
1102            indoc! {"
1103                1.  one
1104                9.  nine
1105                10. ten
1106            "}
1107        );
1108        assert_fix_resolves_all_violations(&rule, content);
1109    }
1110
1111    #[test]
1112    fn test_ol_align_column_wide_marker_overflows() {
1113        // A marker too wide for the column overflows with a single space rather
1114        // than pushing the narrow entries further right.
1115        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1116        let content = indoc! {"
1117            1. a
1118            100. b
1119        "};
1120        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121
1122        assert_eq!(
1123            rule.fix(&ctx).unwrap(),
1124            indoc! {"
1125                1.  a
1126                100. b
1127            "},
1128            "narrow marker sits at column 4; wide marker overflows to column 5"
1129        );
1130        assert_fix_resolves_all_violations(&rule, content);
1131    }
1132
1133    #[test]
1134    fn test_ol_align_column_max_is_four_spaces() {
1135        // Column 6 is the maximum the config allows: a `1.` marker reaches it with
1136        // exactly 4 spaces, the CommonMark ceiling (5+ would start an indented code
1137        // block). Larger columns are rejected at the config layer, not clamped here.
1138        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(6);
1139        let content = indoc! {"
1140            1. one
1141            2. two
1142        "};
1143        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1144        assert_eq!(
1145            rule.fix(&ctx).unwrap(),
1146            indoc! {"
1147                1.    one
1148                2.    two
1149            "},
1150            "column 6 pads `1.` to exactly 4 spaces, never more"
1151        );
1152        assert_fix_resolves_all_violations(&rule, content);
1153    }
1154
1155    #[test]
1156    fn test_ol_align_column_already_aligned_is_clean() {
1157        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1158        let content = indoc! {"
1159            1.  one
1160            9.  nine
1161            10. ten
1162        "};
1163        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1164        assert!(
1165            rule.check(&ctx).unwrap().is_empty(),
1166            "Already-aligned list should produce no warnings"
1167        );
1168    }
1169
1170    #[test]
1171    fn test_ol_align_column_reindents_nested_list() {
1172        // A nested unordered list shifts with the ordered marker, and both bullets
1173        // get ul-single, including the first one, which shares the `1.` line (the
1174        // parser exposes only the outer `1.` marker there). With ol-align-column = 4
1175        // and ul-single = 3 the whole structure lands on a 4-column grid.
1176        let rule = MD030ListMarkerSpace::new(3, 1, 1, 1).with_ol_align_column(4); // ul-single = 3
1177        let content = indoc! {"
1178            1. - x
1179               - y
1180        "};
1181        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1182        assert_eq!(
1183            rule.fix(&ctx).unwrap(),
1184            indoc! {"
1185                1.  -   x
1186                    -   y
1187            "}
1188        );
1189        assert_fix_resolves_all_violations(&rule, content);
1190    }
1191
1192    #[test]
1193    fn test_ol_align_column_inline_non_marker_left_alone() {
1194        // Only a real inline bullet (marker + space) gets ul-single; content that
1195        // merely starts with `-`/`*` (a word, emphasis) must be left untouched, while
1196        // the ordered marker still aligns to column 4.
1197        let rule = MD030ListMarkerSpace::new(3, 1, 1, 1).with_ol_align_column(4);
1198        for (input, expected) in [
1199            ("1. -text\n", "1.  -text\n"),
1200            ("1. *emphasis* here\n", "1.  *emphasis* here\n"),
1201        ] {
1202            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
1203            assert_eq!(rule.fix(&ctx).unwrap(), expected, "input: {input:?}");
1204        }
1205    }
1206
1207    #[test]
1208    fn test_ol_align_column_reindents_multi_level() {
1209        // Shifts accumulate across nesting levels: the parent's widening and the
1210        // nested item's widening both move the deepest line, in a single pass.
1211        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1212        let content = indoc! {"
1213            1. text
1214               1. a
1215                  z
1216        "};
1217        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218        assert_eq!(
1219            rule.fix(&ctx).unwrap(),
1220            indoc! {"
1221                1.  text
1222                    1.  a
1223                        z
1224            "}
1225        );
1226        assert_fix_resolves_all_violations(&rule, content);
1227    }
1228
1229    #[test]
1230    fn test_ol_align_column_reindents_multiline_nested_unordered() {
1231        // A nested unordered list whose items span multiple lines: bullets align to
1232        // column 4 and get ul-multi (they're multi-line), and their continuation
1233        // lines shift to follow. The first bullet shares the `1.` line and is spaced
1234        // just like `- second` on its own line.
1235        let rule = MD030ListMarkerSpace::new(1, 3, 1, 1).with_ol_align_column(4); // ul-single=1, ul-multi=3
1236        let content = indoc! {"
1237            1. - first
1238                 more first
1239               - second
1240                 more second
1241        "};
1242        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1243        assert_eq!(
1244            rule.fix(&ctx).unwrap(),
1245            indoc! {"
1246                1.  -   first
1247                        more first
1248                    -   second
1249                        more second
1250            "}
1251        );
1252        assert_fix_resolves_all_violations(&rule, content);
1253    }
1254
1255    #[test]
1256    fn test_ol_align_column_reindents_multiline_nested_ordered() {
1257        // A multi-line ordered list nested in a multi-line ordered list: every
1258        // marker aligns to column 4 (relative to its own start), and continuation
1259        // lines shift to follow, accumulating across the two levels.
1260        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1261        let content = indoc! {"
1262            1. text
1263               more text
1264               1. inner
1265                  more inner
1266               2. inner2
1267                  more inner2
1268        "};
1269        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1270        assert_eq!(
1271            rule.fix(&ctx).unwrap(),
1272            indoc! {"
1273                1.  text
1274                    more text
1275                    1.  inner
1276                        more inner
1277                    2.  inner2
1278                        more inner2
1279            "}
1280        );
1281        assert_fix_resolves_all_violations(&rule, content);
1282    }
1283
1284    #[test]
1285    fn test_ol_align_column_nested_aligns_relative() {
1286        // A nested ordered list shifts to follow the widened parent and aligns to
1287        // column 4 relative to its own markers (single-digit → 2 spaces, `10.` → 1).
1288        // (A nested ordered list is only recognized when it starts at 1.)
1289        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1290        let content = indoc! {"
1291            1. p
1292               1. a
1293               2. b
1294               9. i
1295               10. j
1296        "};
1297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298        assert_eq!(
1299            rule.fix(&ctx).unwrap(),
1300            indoc! {"
1301                1.  p
1302                    1.  a
1303                    2.  b
1304                    9.  i
1305                    10. j
1306            "}
1307        );
1308        assert_fix_resolves_all_violations(&rule, content);
1309    }
1310
1311    #[test]
1312    fn test_ol_align_column_blockquote_items_in_a_list() {
1313        // Several blockquote items in one list: each marker reaches column 4 and
1314        // its blockquote continuation shifts to follow.
1315        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1316        let content = indoc! {"
1317            1. > a
1318               > b
1319            2. > c
1320        "};
1321        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322        assert_eq!(
1323            rule.fix(&ctx).unwrap(),
1324            indoc! {"
1325                1.  > a
1326                    > b
1327                2.  > c
1328            "}
1329        );
1330        assert_fix_resolves_all_violations(&rule, content);
1331    }
1332
1333    #[test]
1334    fn test_ol_align_column_detached_blockquote_left_alone() {
1335        // The blockquote sits at column 3 while the item's content is at column 4, so
1336        // the parser already treats `> y` as its own top-level block, not this item's
1337        // content. It must be left untouched (no re-attaching). The attached case is
1338        // covered by `test_ol_align_column_blockquote_items_in_a_list`.
1339        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1340        let detached = indoc! {"
1341            1.  > x
1342               > y
1343        "};
1344        let ctx = LintContext::new(detached, crate::config::MarkdownFlavor::Standard, None);
1345        assert_eq!(
1346            rule.fix(&ctx).unwrap(),
1347            detached,
1348            "a detached top-level blockquote must be left as is"
1349        );
1350    }
1351
1352    #[test]
1353    fn test_ol_align_column_preserves_blockquote_alignment() {
1354        // The motivating case: ordered items wrapping blockquotes whose content
1355        // already sits at column 4. Aligning keeps every outer marker at column 4
1356        // (rather than reducing the multi-line items 1 and 3), and the blockquote
1357        // structure is preserved.
1358        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1359        let content = indoc! {"
1360            1.  > 1.  x
1361                > 2.  y
1362
1363            2.  > z
1364
1365            3.  > 1.  a
1366                > 2.  b
1367        "};
1368        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369
1370        // Already at column 4, so nothing to change. Crucially, the multi-line
1371        // items 1 and 3 are not reduced to column 3.
1372        assert!(
1373            rule.check(&ctx).unwrap().is_empty(),
1374            "items already at column 4 must not be flagged; got: {:?}",
1375            rule.check(&ctx).unwrap()
1376        );
1377        assert_eq!(
1378            rule.fix(&ctx).unwrap(),
1379            content,
1380            "fix must leave the aligned input untouched"
1381        );
1382    }
1383
1384    #[test]
1385    fn test_ol_align_column_reindents_mixed_content() {
1386        // An item containing a blockquote and a nested list: every kind of attached
1387        // content shifts together to follow the widened marker.
1388        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1389        let content = indoc! {"
1390            1. > x
1391               - sub
1392        "};
1393        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394        assert_eq!(
1395            rule.fix(&ctx).unwrap(),
1396            indoc! {"
1397                1.  > x
1398                    - sub
1399            "}
1400        );
1401        assert_fix_resolves_all_violations(&rule, content);
1402    }
1403
1404    #[test]
1405    fn test_ol_align_column_in_blockquote() {
1406        // Blockquoted ordered lists align correctly (the column is measured from
1407        // the marker, independent of the blockquote prefix).
1408        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1409        let content = indoc! {"
1410            > 1. one
1411            > 9. nine
1412            > 10. ten
1413        "};
1414        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415        assert_eq!(
1416            rule.fix(&ctx).unwrap(),
1417            indoc! {"
1418                > 1.  one
1419                > 9.  nine
1420                > 10. ten
1421            "}
1422        );
1423        assert_fix_resolves_all_violations(&rule, content);
1424    }
1425
1426    #[test]
1427    fn test_ol_align_column_multiline_item_in_blockquote() {
1428        // A multi-line ordered item *inside* a blockquote. Its continuation indent
1429        // lives after the `>` prefix, not at the start of the line, so the generic
1430        // shift moves that, keeping `more` under `text` and the blockquote intact,
1431        // and the marker aligns to column 4 just like an item outside a blockquote.
1432        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1433        let content = indoc! {"
1434            > 1. text
1435            >    more
1436            > 2. second
1437        "};
1438        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1439        assert_eq!(
1440            rule.fix(&ctx).unwrap(),
1441            indoc! {"
1442                > 1.  text
1443                >     more
1444                > 2.  second
1445            "}
1446        );
1447        assert_fix_resolves_all_violations(&rule, content);
1448    }
1449
1450    #[test]
1451    fn test_ol_align_column_nested_list_in_blockquote() {
1452        // A nested ordered list inside a blockquoted item: shifts accumulate across
1453        // both levels in the blockquote's own coordinate system, so the inner marker
1454        // lands under the outer text and the deepest line under the inner content.
1455        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1456        let content = indoc! {"
1457            > 1. text
1458            >    1. inner
1459            >       more
1460        "};
1461        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462        assert_eq!(
1463            rule.fix(&ctx).unwrap(),
1464            indoc! {"
1465                > 1.  text
1466                >     1.  inner
1467                >         more
1468            "}
1469        );
1470        assert_fix_resolves_all_violations(&rule, content);
1471    }
1472
1473    #[test]
1474    fn test_ol_align_column_does_not_affect_unordered_lists() {
1475        // ol-align-column only governs ordered lists; unordered lists are unchanged.
1476        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1477        let content = indoc! {"
1478            - a
1479            - b
1480        "};
1481        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482        assert!(
1483            rule.check(&ctx).unwrap().is_empty(),
1484            "Unordered lists should be unaffected by ol-align-column"
1485        );
1486    }
1487
1488    #[test]
1489    fn test_has_content_after_marker() {
1490        // Direct unit tests for the helper function
1491        assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
1492        assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
1493        assert!(!MD030ListMarkerSpace::has_content_after_marker("-   ", 1));
1494        assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
1495        assert!(MD030ListMarkerSpace::has_content_after_marker("-  item", 1));
1496        assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
1497        assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
1498        assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
1499    }
1500}