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