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