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::rules::list_utils::ListType;
9use crate::utils::blockquote::{effective_indent_in_blockquote, parse_blockquote_prefix};
10use crate::utils::calculate_indentation_width_default;
11use crate::utils::range_utils::calculate_match_range;
12use toml;
13
14mod md030_config;
15use md030_config::MD030Config;
16
17#[derive(Clone, Default)]
18pub struct MD030ListMarkerSpace {
19    config: MD030Config,
20}
21
22impl MD030ListMarkerSpace {
23    pub fn new(ul_single: usize, ul_multi: usize, ol_single: usize, ol_multi: usize) -> Self {
24        Self {
25            config: MD030Config {
26                ul_single: crate::types::PositiveUsize::new(ul_single)
27                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
28                ul_multi: crate::types::PositiveUsize::new(ul_multi)
29                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
30                ol_single: crate::types::PositiveUsize::new(ol_single)
31                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
32                ol_multi: crate::types::PositiveUsize::new(ol_multi)
33                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
34            },
35        }
36    }
37
38    pub fn from_config_struct(config: MD030Config) -> Self {
39        Self { config }
40    }
41
42    pub fn get_expected_spaces(&self, list_type: ListType, is_multi: bool) -> usize {
43        match (list_type, is_multi) {
44            (ListType::Unordered, false) => self.config.ul_single.get(),
45            (ListType::Unordered, true) => self.config.ul_multi.get(),
46            (ListType::Ordered, false) => self.config.ol_single.get(),
47            (ListType::Ordered, true) => self.config.ol_multi.get(),
48        }
49    }
50}
51
52impl Rule for MD030ListMarkerSpace {
53    fn name(&self) -> &'static str {
54        "MD030"
55    }
56
57    fn description(&self) -> &'static str {
58        "Spaces after list markers should be consistent"
59    }
60
61    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
62        let mut warnings = Vec::new();
63
64        // Early return if no list content
65        if self.should_skip(ctx) {
66            return Ok(warnings);
67        }
68
69        let lines = ctx.raw_lines();
70
71        // Track which lines we've already processed (to avoid duplicates)
72        let mut processed_lines = std::collections::HashSet::new();
73
74        // First pass: Check parser-recognized list items
75        for (line_num, line_info) in ctx.lines.iter().enumerate() {
76            // Skip code blocks, math blocks, PyMdown blocks, and MkDocs markdown HTML divs (grid cards use custom spacing)
77            if line_info.list_item.is_some()
78                && !line_info.in_code_block
79                && !line_info.in_math_block
80                && !line_info.in_pymdown_block
81                && !line_info.in_mkdocs_html_markdown
82                && !line_info.in_footnote_definition
83            {
84                let line_num_1based = line_num + 1;
85                processed_lines.insert(line_num_1based);
86
87                let line = lines[line_num];
88
89                if let Some(list_info) = &line_info.list_item {
90                    let list_type = if list_info.is_ordered {
91                        ListType::Ordered
92                    } else {
93                        ListType::Unordered
94                    };
95
96                    // Calculate actual spacing after marker
97                    let marker_end = list_info.marker_column + list_info.marker.len();
98
99                    // Skip if there's no content on this line after the marker
100                    // MD030 only applies when there IS content after the marker
101                    if !Self::has_content_after_marker(line, marker_end) {
102                        continue;
103                    }
104
105                    let actual_spaces = list_info.content_column.saturating_sub(marker_end);
106
107                    // Determine if this is a multi-line list item
108                    let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, lines);
109                    let expected_spaces = self.get_expected_spaces(list_type, is_multi_line);
110
111                    if actual_spaces != expected_spaces {
112                        let whitespace_start_pos = marker_end;
113                        let whitespace_len = actual_spaces;
114
115                        let (start_line, start_col, end_line, end_col) =
116                            calculate_match_range(line_num_1based, line, whitespace_start_pos, whitespace_len);
117
118                        let correct_spaces = " ".repeat(expected_spaces);
119                        let line_start_byte = ctx.line_offsets.get(line_num).copied().unwrap_or(0);
120                        let whitespace_start_byte = line_start_byte + whitespace_start_pos;
121                        let whitespace_end_byte = whitespace_start_byte + whitespace_len;
122
123                        let fix = Some(crate::rule::Fix {
124                            range: whitespace_start_byte..whitespace_end_byte,
125                            replacement: correct_spaces,
126                        });
127
128                        let message =
129                            format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})");
130
131                        warnings.push(LintWarning {
132                            rule_name: Some(self.name().to_string()),
133                            severity: Severity::Warning,
134                            line: start_line,
135                            column: start_col,
136                            end_line,
137                            end_column: end_col,
138                            message,
139                            fix,
140                        });
141                    }
142                }
143            }
144        }
145
146        // Second pass: Detect list-like patterns the parser didn't recognize
147        // This handles cases like "1.Text" where there's no space after the marker
148        for (line_idx, line) in lines.iter().enumerate() {
149            let line_num = line_idx + 1;
150
151            // Skip if already processed or in code block/front matter/math block
152            if processed_lines.contains(&line_num) {
153                continue;
154            }
155            if let Some(line_info) = ctx.lines.get(line_idx)
156                && (line_info.in_code_block
157                    || line_info.in_front_matter
158                    || line_info.in_html_comment
159                    || line_info.in_mdx_comment
160                    || line_info.in_math_block
161                    || line_info.in_pymdown_block
162                    || line_info.in_mkdocs_html_markdown
163                    || line_info.in_footnote_definition)
164            {
165                continue;
166            }
167
168            // Skip indented code blocks
169            if self.is_indented_code_block(line, line_idx, lines) {
170                continue;
171            }
172
173            // Try to detect list-like patterns using regex-based detection
174            if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, lines) {
175                warnings.push(warning);
176            }
177        }
178
179        Ok(warnings)
180    }
181
182    fn category(&self) -> RuleCategory {
183        RuleCategory::List
184    }
185
186    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
187        if ctx.content.is_empty() {
188            return true;
189        }
190
191        // Fast byte-level check for list markers (including ordered lists)
192        let bytes = ctx.content.as_bytes();
193        !bytes.contains(&b'*')
194            && !bytes.contains(&b'-')
195            && !bytes.contains(&b'+')
196            && !bytes.iter().any(|&b| b.is_ascii_digit())
197    }
198
199    fn as_any(&self) -> &dyn std::any::Any {
200        self
201    }
202
203    fn default_config_section(&self) -> Option<(String, toml::Value)> {
204        let default_config = MD030Config::default();
205        let json_value = serde_json::to_value(&default_config).ok()?;
206        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
207
208        if let toml::Value::Table(table) = toml_value {
209            if !table.is_empty() {
210                Some((MD030Config::RULE_NAME.to_string(), toml::Value::Table(table)))
211            } else {
212                None
213            }
214        } else {
215            None
216        }
217    }
218
219    fn from_config(config: &crate::config::Config) -> Box<dyn Rule> {
220        let rule_config = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
221        Box::new(Self::from_config_struct(rule_config))
222    }
223
224    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
225        if self.should_skip(ctx) {
226            return Ok(ctx.content.to_string());
227        }
228
229        // Derive fixes directly from check() so detection and fixing share one code path.
230        // This guarantees that every violation check() reports is also fixed, with no
231        // possibility of the two paths diverging due to mismatched skip conditions.
232        let warnings = self.check(ctx)?;
233        if warnings.is_empty() {
234            return Ok(ctx.content.to_string());
235        }
236
237        let warnings =
238            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
239
240        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
241            .map_err(crate::rule::LintError::InvalidInput)
242    }
243}
244
245impl MD030ListMarkerSpace {
246    /// Check if a list item line has content after the marker
247    /// Returns false if the line ends after the marker (with optional whitespace)
248    /// MD030 only applies when there IS content on the same line as the marker
249    #[inline]
250    fn has_content_after_marker(line: &str, marker_end: usize) -> bool {
251        if marker_end >= line.len() {
252            return false;
253        }
254        !line[marker_end..].trim().is_empty()
255    }
256
257    /// Check if a list item is multi-line (spans multiple lines or contains nested content)
258    fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
259        // Get the current list item info
260        let current_line_info = match ctx.line_info(line_num) {
261            Some(info) if info.list_item.is_some() => info,
262            _ => return false,
263        };
264
265        let current_list = current_line_info.list_item.as_ref().unwrap();
266
267        // Check subsequent lines to see if they are continuation of this list item
268        for next_line_num in (line_num + 1)..=lines.len() {
269            if let Some(next_line_info) = ctx.line_info(next_line_num) {
270                // If we encounter another list item at the same or higher level, this item is done
271                if let Some(next_list) = &next_line_info.list_item {
272                    if next_list.marker_column <= current_list.marker_column {
273                        break; // Found the next list item at same/higher level
274                    }
275                    // If there's a nested list item, this is multi-line
276                    return true;
277                }
278
279                // If we encounter a non-empty line that's not indented enough to be part of this list item,
280                // this list item is done
281                let line_content = lines.get(next_line_num - 1).unwrap_or(&"");
282                if !line_content.trim().is_empty() {
283                    // Get blockquote level from the current list item's line
284                    let bq_level = current_line_info
285                        .blockquote
286                        .as_ref()
287                        .map(|bq| bq.nesting_level)
288                        .unwrap_or(0);
289
290                    // For blockquote lists, min continuation indent is just the marker width
291                    // (not the full content_column which includes blockquote prefix)
292                    let min_continuation_indent = if bq_level > 0 {
293                        // For lists in blockquotes, use marker width (2 for "* " or "- ")
294                        // content_column includes blockquote prefix, so subtract that
295                        current_list.content_column.saturating_sub(
296                            current_line_info
297                                .blockquote
298                                .as_ref()
299                                .map(|bq| bq.prefix.len())
300                                .unwrap_or(0),
301                        )
302                    } else {
303                        current_list.content_column
304                    };
305
306                    // Calculate effective indent (blockquote-aware)
307                    let raw_indent = line_content.len() - line_content.trim_start().len();
308                    let actual_indent = effective_indent_in_blockquote(line_content, bq_level, raw_indent);
309
310                    if actual_indent < min_continuation_indent {
311                        break; // Line is not indented enough to be part of this list item
312                    }
313
314                    // If we find a continuation line, this is multi-line
315                    if actual_indent >= min_continuation_indent {
316                        return true;
317                    }
318                }
319
320                // Empty lines don't affect the multi-line status by themselves
321            }
322        }
323
324        false
325    }
326
327    /// Detect list-like patterns that the parser didn't recognize (e.g., "1.Text" with no space)
328    /// This implements user-intention-based detection: if it looks like a list item, flag it
329    fn check_unrecognized_list_marker(
330        &self,
331        ctx: &crate::lint_context::LintContext,
332        line: &str,
333        line_num: usize,
334        lines: &[&str],
335    ) -> Option<LintWarning> {
336        // Strip blockquote prefix to analyze the content.
337        // Track the prefix length so fix positions are relative to the original line.
338        let (bq_prefix_len, content) = match parse_blockquote_prefix(line) {
339            Some(parsed) => (parsed.prefix.len(), parsed.content),
340            None => (0, line),
341        };
342
343        let trimmed = content.trim_start();
344        let indent_len = content.len() - trimmed.len();
345
346        // Note: We intentionally do NOT apply heuristic detection to unordered list markers
347        // (*, -, +) because they have too many non-list uses: emphasis, globs, diffs, etc.
348        // The parser handles valid unordered list items; we only do heuristic detection
349        // for ordered lists where "1.Text" is almost always a list item with missing space.
350
351        // Check for ordered list markers (digits followed by .) without proper spacing
352        if let Some(dot_pos) = trimmed.find('.') {
353            let before_dot = &trimmed[..dot_pos];
354            if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
355                let after_dot = &trimmed[dot_pos + 1..];
356                // Only flag if there's content directly after the marker (no space, no tab)
357                if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
358                    let first_char = after_dot.chars().next().unwrap_or(' ');
359
360                    // For CLEAR user intent, only flag if:
361                    // 1. Starts with uppercase letter (strong list indicator), OR
362                    // 2. Starts with [ or ( (link/paren content)
363                    // Lowercase and digits are ambiguous (could be decimal, version, etc.)
364                    let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
365
366                    if is_clear_intent {
367                        let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
368                        let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
369
370                        let marker = format!("{before_dot}.");
371                        let marker_pos = indent_len;
372                        let marker_end = marker_pos + marker.len();
373                        // Offset from the start of the original line (including blockquote prefix).
374                        let offset_in_line = bq_prefix_len + marker_end;
375
376                        let (start_line, start_col, end_line, end_col) =
377                            calculate_match_range(line_num, line, offset_in_line, 0);
378
379                        let correct_spaces = " ".repeat(expected_spaces);
380                        let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
381                        let fix_position = line_start_byte + offset_in_line;
382
383                        return Some(LintWarning {
384                            rule_name: Some("MD030".to_string()),
385                            severity: Severity::Warning,
386                            line: start_line,
387                            column: start_col,
388                            end_line,
389                            end_column: end_col,
390                            message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
391                            fix: Some(crate::rule::Fix {
392                                range: fix_position..fix_position,
393                                replacement: correct_spaces,
394                            }),
395                        });
396                    }
397                }
398            }
399        }
400
401        None
402    }
403
404    /// Simplified multi-line check for unrecognized list items
405    fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
406        // For unrecognized list items, we can't rely on parser info
407        // Check if the next line exists and appears to be a continuation
408        if line_num < lines.len() {
409            let next_line = lines[line_num]; // line_num is 1-based, so this is the next line
410            let next_trimmed = next_line.trim();
411            // If next line is non-empty and indented, it might be a continuation
412            if !next_trimmed.is_empty() && next_line.starts_with(' ') {
413                return true;
414            }
415        }
416        false
417    }
418
419    /// Check if a line is part of an indented code block (4+ columns with blank line before)
420    fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
421        // Must have 4+ columns of indentation (accounting for tab expansion)
422        if calculate_indentation_width_default(line) < 4 {
423            return false;
424        }
425
426        // If it's the first line, it's not an indented code block
427        if line_idx == 0 {
428            return false;
429        }
430
431        // Check if there's a blank line before this line or before the start of the indented block
432        if self.has_blank_line_before_indented_block(line_idx, lines) {
433            return true;
434        }
435
436        false
437    }
438
439    /// Check if there's a blank line before the start of an indented block
440    fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
441        // Walk backwards to find the start of the indented block
442        let mut current_idx = line_idx;
443
444        // Find the first line in this indented block
445        while current_idx > 0 {
446            let current_line = lines[current_idx];
447            let prev_line = lines[current_idx - 1];
448
449            // If current line is not indented (< 4 columns), we've gone too far
450            if calculate_indentation_width_default(current_line) < 4 {
451                break;
452            }
453
454            // If previous line is not indented, check if it's blank
455            if calculate_indentation_width_default(prev_line) < 4 {
456                return prev_line.trim().is_empty();
457            }
458
459            current_idx -= 1;
460        }
461
462        false
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use crate::lint_context::LintContext;
470
471    /// Assert that running `fix()` on content with violations produces output that
472    /// passes `check()` with zero remaining violations.
473    fn assert_fix_resolves_all_violations(rule: &MD030ListMarkerSpace, content: &str) {
474        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475        let before = rule.check(&ctx).unwrap();
476        assert!(
477            !before.is_empty(),
478            "Expected violations but check() found none in:\n{content}"
479        );
480
481        let fixed = rule.fix(&ctx).unwrap();
482        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
483        let after = rule.check(&ctx_fixed).unwrap();
484        assert!(
485            after.is_empty(),
486            "fix() left {} violation(s) unresolved:\n{:?}\nOriginal:\n{content}\nFixed:\n{fixed}",
487            after.len(),
488            after
489        );
490    }
491
492    #[test]
493    fn test_basic_functionality() {
494        let rule = MD030ListMarkerSpace::default();
495        let content = "* Item 1\n* Item 2\n  * Nested item\n1. Ordered item";
496        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497        let result = rule.check(&ctx).unwrap();
498        assert!(
499            result.is_empty(),
500            "Correctly spaced list markers should not generate warnings"
501        );
502        let content = "*  Item 1 (too many spaces)\n* Item 2\n1.   Ordered item (too many spaces)";
503        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
504        let result = rule.check(&ctx).unwrap();
505        // Expect warnings for lines with too many spaces after the marker
506        assert_eq!(
507            result.len(),
508            2,
509            "Should flag lines with too many spaces after list marker"
510        );
511        for warning in result {
512            assert!(
513                warning.message.starts_with("Spaces after list markers (Expected:")
514                    && warning.message.contains("Actual:"),
515                "Warning message should include expected and actual values, got: '{}'",
516                warning.message
517            );
518        }
519    }
520
521    #[test]
522    fn test_nested_emphasis_not_flagged_issue_278() {
523        // Issue #278: Nested emphasis like *text **bold** more* should not trigger MD030
524        let rule = MD030ListMarkerSpace::default();
525
526        // This is emphasis with nested bold - NOT a list item
527        let content = "*This text is **very** important*";
528        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529        let result = rule.check(&ctx).unwrap();
530        assert!(
531            result.is_empty(),
532            "Nested emphasis should not trigger MD030, got: {result:?}"
533        );
534
535        // Simple emphasis - NOT a list item
536        let content2 = "*Hello World*";
537        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
538        let result2 = rule.check(&ctx2).unwrap();
539        assert!(
540            result2.is_empty(),
541            "Simple emphasis should not trigger MD030, got: {result2:?}"
542        );
543
544        // Bold text - NOT a list item
545        let content3 = "**bold text**";
546        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
547        let result3 = rule.check(&ctx3).unwrap();
548        assert!(
549            result3.is_empty(),
550            "Bold text should not trigger MD030, got: {result3:?}"
551        );
552
553        // Bold+italic - NOT a list item
554        let content4 = "***bold and italic***";
555        let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
556        let result4 = rule.check(&ctx4).unwrap();
557        assert!(
558            result4.is_empty(),
559            "Bold+italic should not trigger MD030, got: {result4:?}"
560        );
561
562        // Actual list item with proper spacing - should NOT trigger
563        let content5 = "* Item with space";
564        let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
565        let result5 = rule.check(&ctx5).unwrap();
566        assert!(
567            result5.is_empty(),
568            "Properly spaced list item should not trigger MD030, got: {result5:?}"
569        );
570    }
571
572    #[test]
573    fn test_empty_marker_line_not_flagged_issue_288() {
574        // Issue #288: List items with no content on the marker line should not trigger MD030
575        // The space requirement only applies when there IS content after the marker
576        let rule = MD030ListMarkerSpace::default();
577
578        // Case 1: Unordered list with empty marker line followed by code block
579        let content = "-\n    ```python\n    print(\"code\")\n    ```\n";
580        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581        let result = rule.check(&ctx).unwrap();
582        assert!(
583            result.is_empty(),
584            "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
585        );
586
587        // Case 2: Ordered list with empty marker line followed by code block
588        let content = "1.\n    ```python\n    print(\"code\")\n    ```\n";
589        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590        let result = rule.check(&ctx).unwrap();
591        assert!(
592            result.is_empty(),
593            "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
594        );
595
596        // Case 3: Empty marker line followed by paragraph continuation
597        let content = "-\n    This is a paragraph continuation\n    of the list item.\n";
598        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599        let result = rule.check(&ctx).unwrap();
600        assert!(
601            result.is_empty(),
602            "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
603        );
604
605        // Case 4: Nested list with empty marker line
606        let content = "- Parent item\n  -\n      Nested content\n";
607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608        let result = rule.check(&ctx).unwrap();
609        assert!(
610            result.is_empty(),
611            "Nested empty marker line should not trigger MD030, got: {result:?}"
612        );
613
614        // Case 5: Multiple list items, some with empty markers
615        let content = "- Item with content\n-\n    Code block\n- Another item\n";
616        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617        let result = rule.check(&ctx).unwrap();
618        assert!(
619            result.is_empty(),
620            "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
621        );
622    }
623
624    #[test]
625    fn test_marker_with_content_still_flagged_issue_288() {
626        // Ensure we still flag markers with content but wrong spacing
627        let rule = MD030ListMarkerSpace::default();
628
629        // Two spaces before content - should flag
630        let content = "-  Two spaces before content\n";
631        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632        let result = rule.check(&ctx).unwrap();
633        assert_eq!(
634            result.len(),
635            1,
636            "Two spaces after unordered marker should still trigger MD030"
637        );
638
639        // Ordered list with two spaces - should flag
640        let content = "1.  Two spaces\n";
641        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642        let result = rule.check(&ctx).unwrap();
643        assert_eq!(
644            result.len(),
645            1,
646            "Two spaces after ordered marker should still trigger MD030"
647        );
648
649        // Normal list item - should NOT flag
650        let content = "- Normal item\n";
651        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652        let result = rule.check(&ctx).unwrap();
653        assert!(
654            result.is_empty(),
655            "Normal list item should not trigger MD030, got: {result:?}"
656        );
657    }
658
659    #[test]
660    fn test_nested_items_with_4space_indent_are_detected() {
661        // Nested list items indented with 4 spaces should be checked for marker spacing.
662        // Previously, the check skipped any line with >= 4 columns of indentation,
663        // treating them as indented code blocks even when the parser identified them as
664        // list items.
665        let rule = MD030ListMarkerSpace::new(3, 3, 1, 1);
666
667        // Tight nested list (no blank line): the exact scenario from issue #565.
668        // ul_single=3: the nested item "    - Nested wrong" has 1 space → violation.
669        let content = "-   Top-level correct\n    - Nested wrong spacing\n    -   Nested correct\n";
670        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671        let result = rule.check(&ctx).unwrap();
672        assert_eq!(
673            result.len(),
674            1,
675            "Nested item with 1 space (ul_single=3) should be flagged; got: {result:?}"
676        );
677        assert_eq!(result[0].line, 2, "Violation should be on line 2");
678        assert!(
679            result[0].message.contains("Expected: 3") && result[0].message.contains("Actual: 1"),
680            "Message should state expected/actual spaces; got: {}",
681            result[0].message
682        );
683
684        // fix() must produce correct output for the tight nested case.
685        let fixed = rule.fix(&ctx).unwrap();
686        assert_eq!(
687            fixed, "-   Top-level correct\n    -   Nested wrong spacing\n    -   Nested correct\n",
688            "fix() should expand 1 space to ul_single=3 on the nested item"
689        );
690
691        // Nested unordered item with correct spacing should not be flagged
692        let content_ok = "-   Top-level\n    -   Nested correct\n";
693        let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
694        let result_ok = rule.check(&ctx_ok).unwrap();
695        assert!(
696            result_ok.is_empty(),
697            "Nested item with correct spacing should not be flagged; got: {result_ok:?}"
698        );
699
700        // Nested ordered item: 4 spaces indent, 1 space after marker → violation (ol_single=2)
701        let rule_ol = MD030ListMarkerSpace::new(1, 1, 2, 2);
702        let content_ol = "1.  Top-level multi\n    1. Nested wrong\n";
703        let ctx_ol = LintContext::new(content_ol, crate::config::MarkdownFlavor::Standard, None);
704        let result_ol = rule_ol.check(&ctx_ol).unwrap();
705        assert_eq!(
706            result_ol.len(),
707            1,
708            "Nested ordered item with 1 space (ol_single=2) should be flagged; got: {result_ol:?}"
709        );
710        let fixed_ol = rule_ol.fix(&ctx_ol).unwrap();
711        assert_eq!(
712            fixed_ol, "1.  Top-level multi\n    1.  Nested wrong\n",
713            "fix() should expand 1 space to ol_single=2 on the nested ordered item"
714        );
715
716        // Deeply nested (8+ spaces) items are also checked.
717        // Only the 8-space-indented item has wrong spacing; outer levels are correct.
718        let content_deep = "-   Level 1\n    -   Level 2\n        - Level 3 wrong\n        -   Level 3 correct\n";
719        let ctx_deep = LintContext::new(content_deep, crate::config::MarkdownFlavor::Standard, None);
720        let result_deep = rule.check(&ctx_deep).unwrap();
721        assert_eq!(
722            result_deep.len(),
723            1,
724            "Deeply nested (8-space) item with 1 space should be flagged; got: {result_deep:?}"
725        );
726        assert_eq!(result_deep[0].line, 3, "Violation should be on the deeply nested line");
727
728        // Verify the full roundtrip: fix() must resolve everything check() found.
729        assert_fix_resolves_all_violations(&rule, content);
730        assert_fix_resolves_all_violations(&rule_ol, content_ol);
731        assert_fix_resolves_all_violations(&rule, content_deep);
732    }
733
734    #[test]
735    fn test_loose_nested_item_fix_matches_check() {
736        // A loose nested list item (blank line between parent and child) with 4-space
737        // indentation must be both detected AND fixed. check() and fix() must agree.
738        let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
739
740        let content = "- parent\n\n    -  nested wrong\n";
741        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742
743        // check() must detect it
744        let warnings = rule.check(&ctx).unwrap();
745        assert_eq!(
746            warnings.len(),
747            1,
748            "Loose nested item with 2 spaces should be detected; got: {warnings:?}"
749        );
750
751        // fix() must fix it
752        let fixed = rule.fix(&ctx).unwrap();
753        assert_eq!(
754            fixed, "- parent\n\n    - nested wrong\n",
755            "fix() should reduce 2 spaces to 1 for loose nested item"
756        );
757
758        // Verify the full roundtrip: fix() must resolve everything check() found.
759        assert_fix_resolves_all_violations(&rule, content);
760    }
761
762    #[test]
763    fn test_has_content_after_marker() {
764        // Direct unit tests for the helper function
765        assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
766        assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
767        assert!(!MD030ListMarkerSpace::has_content_after_marker("-   ", 1));
768        assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
769        assert!(MD030ListMarkerSpace::has_content_after_marker("-  item", 1));
770        assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
771        assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
772        assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
773    }
774}