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