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