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