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::element_cache::ElementCache;
10use crate::utils::range_utils::calculate_match_range;
11use toml;
12
13mod md030_config;
14use md030_config::MD030Config;
15
16#[derive(Clone, Default)]
17pub struct MD030ListMarkerSpace {
18    config: MD030Config,
19}
20
21impl MD030ListMarkerSpace {
22    pub fn new(ul_single: usize, ul_multi: usize, ol_single: usize, ol_multi: usize) -> Self {
23        Self {
24            config: MD030Config {
25                ul_single: crate::types::PositiveUsize::new(ul_single)
26                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
27                ul_multi: crate::types::PositiveUsize::new(ul_multi)
28                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
29                ol_single: crate::types::PositiveUsize::new(ol_single)
30                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
31                ol_multi: crate::types::PositiveUsize::new(ol_multi)
32                    .unwrap_or(crate::types::PositiveUsize::from_const(1)),
33            },
34        }
35    }
36
37    pub fn from_config_struct(config: MD030Config) -> Self {
38        Self { config }
39    }
40
41    pub fn get_expected_spaces(&self, list_type: ListType, is_multi: bool) -> usize {
42        match (list_type, is_multi) {
43            (ListType::Unordered, false) => self.config.ul_single.get(),
44            (ListType::Unordered, true) => self.config.ul_multi.get(),
45            (ListType::Ordered, false) => self.config.ol_single.get(),
46            (ListType::Ordered, true) => self.config.ol_multi.get(),
47        }
48    }
49}
50
51impl Rule for MD030ListMarkerSpace {
52    fn name(&self) -> &'static str {
53        "MD030"
54    }
55
56    fn description(&self) -> &'static str {
57        "Spaces after list markers should be consistent"
58    }
59
60    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
61        let mut warnings = Vec::new();
62
63        // Early return if no list content
64        if self.should_skip(ctx) {
65            return Ok(warnings);
66        }
67
68        // Collect lines once
69        let lines: Vec<&str> = ctx.content.lines().collect();
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            if line_info.list_item.is_some() && !line_info.in_code_block {
77                let line_num_1based = line_num + 1;
78                processed_lines.insert(line_num_1based);
79
80                let line = lines[line_num];
81
82                // Skip indented code blocks (4+ columns accounting for tab expansion)
83                if ElementCache::calculate_indentation_width_default(line) >= 4 {
84                    continue;
85                }
86
87                if let Some(list_info) = &line_info.list_item {
88                    let list_type = if list_info.is_ordered {
89                        ListType::Ordered
90                    } else {
91                        ListType::Unordered
92                    };
93
94                    // Calculate actual spacing after marker
95                    let marker_end = list_info.marker_column + list_info.marker.len();
96                    let actual_spaces = list_info.content_column.saturating_sub(marker_end);
97
98                    // Determine if this is a multi-line list item
99                    let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, &lines);
100                    let expected_spaces = self.get_expected_spaces(list_type, is_multi_line);
101
102                    if actual_spaces != expected_spaces {
103                        let whitespace_start_pos = marker_end;
104                        let whitespace_len = actual_spaces;
105
106                        let (start_line, start_col, end_line, end_col) =
107                            calculate_match_range(line_num_1based, line, whitespace_start_pos, whitespace_len);
108
109                        let correct_spaces = " ".repeat(expected_spaces);
110                        let line_start_byte = ctx.line_offsets.get(line_num).copied().unwrap_or(0);
111                        let whitespace_start_byte = line_start_byte + whitespace_start_pos;
112                        let whitespace_end_byte = whitespace_start_byte + whitespace_len;
113
114                        let fix = Some(crate::rule::Fix {
115                            range: whitespace_start_byte..whitespace_end_byte,
116                            replacement: correct_spaces,
117                        });
118
119                        let message =
120                            format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})");
121
122                        warnings.push(LintWarning {
123                            rule_name: Some(self.name().to_string()),
124                            severity: Severity::Warning,
125                            line: start_line,
126                            column: start_col,
127                            end_line,
128                            end_column: end_col,
129                            message,
130                            fix,
131                        });
132                    }
133                }
134            }
135        }
136
137        // Second pass: Detect list-like patterns the parser didn't recognize
138        // This handles cases like "1.Text" where there's no space after the marker
139        for (line_idx, line) in lines.iter().enumerate() {
140            let line_num = line_idx + 1;
141
142            // Skip if already processed or in code block/front matter
143            if processed_lines.contains(&line_num) {
144                continue;
145            }
146            if let Some(line_info) = ctx.lines.get(line_idx)
147                && (line_info.in_code_block || line_info.in_front_matter || line_info.in_html_comment)
148            {
149                continue;
150            }
151
152            // Skip indented code blocks
153            if self.is_indented_code_block(line, line_idx, &lines) {
154                continue;
155            }
156
157            // Try to detect list-like patterns using regex-based detection
158            if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, &lines) {
159                warnings.push(warning);
160            }
161        }
162
163        Ok(warnings)
164    }
165
166    fn category(&self) -> RuleCategory {
167        RuleCategory::List
168    }
169
170    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
171        if ctx.content.is_empty() {
172            return true;
173        }
174
175        // Fast byte-level check for list markers (including ordered lists)
176        let bytes = ctx.content.as_bytes();
177        !bytes.contains(&b'*')
178            && !bytes.contains(&b'-')
179            && !bytes.contains(&b'+')
180            && !bytes.iter().any(|&b| b.is_ascii_digit())
181    }
182
183    fn as_any(&self) -> &dyn std::any::Any {
184        self
185    }
186
187    fn default_config_section(&self) -> Option<(String, toml::Value)> {
188        let default_config = MD030Config::default();
189        let json_value = serde_json::to_value(&default_config).ok()?;
190        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
191
192        if let toml::Value::Table(table) = toml_value {
193            if !table.is_empty() {
194                Some((MD030Config::RULE_NAME.to_string(), toml::Value::Table(table)))
195            } else {
196                None
197            }
198        } else {
199            None
200        }
201    }
202
203    fn from_config(config: &crate::config::Config) -> Box<dyn Rule> {
204        let rule_config = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
205        Box::new(Self::from_config_struct(rule_config))
206    }
207
208    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
209        let content = ctx.content;
210
211        // Early return if no fixes needed
212        if self.should_skip(ctx) {
213            return Ok(content.to_string());
214        }
215
216        let lines: Vec<&str> = content.lines().collect();
217        let mut result_lines = Vec::with_capacity(lines.len());
218
219        for (line_idx, line) in lines.iter().enumerate() {
220            let line_num = line_idx + 1;
221
222            // Skip lines in code blocks, front matter, or HTML comments
223            if let Some(line_info) = ctx.lines.get(line_idx)
224                && (line_info.in_code_block || line_info.in_front_matter || line_info.in_html_comment)
225            {
226                result_lines.push(line.to_string());
227                continue;
228            }
229
230            // Skip if this is an indented code block (4+ spaces with blank line before)
231            if self.is_indented_code_block(line, line_idx, &lines) {
232                result_lines.push(line.to_string());
233                continue;
234            }
235
236            // Use regex-based detection to find list markers, not parser detection.
237            // This ensures we fix spacing on ALL lines that look like list items,
238            // even if the parser doesn't recognize them due to strict nesting rules.
239            // User intention matters: if it looks like a list item, fix it.
240            let is_multi_line = self.is_multi_line_list_item(ctx, line_num, &lines);
241            if let Some(fixed_line) = self.try_fix_list_marker_spacing_with_context(line, is_multi_line) {
242                result_lines.push(fixed_line);
243            } else {
244                result_lines.push(line.to_string());
245            }
246        }
247
248        // Preserve trailing newline if original content had one
249        let result = result_lines.join("\n");
250        if content.ends_with('\n') && !result.ends_with('\n') {
251            Ok(result + "\n")
252        } else {
253            Ok(result)
254        }
255    }
256}
257
258impl MD030ListMarkerSpace {
259    /// Check if a list item is multi-line (spans multiple lines or contains nested content)
260    fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
261        // Get the current list item info
262        let current_line_info = match ctx.line_info(line_num) {
263            Some(info) if info.list_item.is_some() => info,
264            _ => return false,
265        };
266
267        let current_list = current_line_info.list_item.as_ref().unwrap();
268
269        // Check subsequent lines to see if they are continuation of this list item
270        for next_line_num in (line_num + 1)..=lines.len() {
271            if let Some(next_line_info) = ctx.line_info(next_line_num) {
272                // If we encounter another list item at the same or higher level, this item is done
273                if let Some(next_list) = &next_line_info.list_item {
274                    if next_list.marker_column <= current_list.marker_column {
275                        break; // Found the next list item at same/higher level
276                    }
277                    // If there's a nested list item, this is multi-line
278                    return true;
279                }
280
281                // If we encounter a non-empty line that's not indented enough to be part of this list item,
282                // this list item is done
283                let line_content = lines.get(next_line_num - 1).unwrap_or(&"");
284                if !line_content.trim().is_empty() {
285                    let expected_continuation_indent = current_list.content_column;
286                    let actual_indent = line_content.len() - line_content.trim_start().len();
287
288                    if actual_indent < expected_continuation_indent {
289                        break; // Line is not indented enough to be part of this list item
290                    }
291
292                    // If we find a continuation line, this is multi-line
293                    if actual_indent >= expected_continuation_indent {
294                        return true;
295                    }
296                }
297
298                // Empty lines don't affect the multi-line status by themselves
299            }
300        }
301
302        false
303    }
304
305    /// Helper to fix marker spacing for both ordered and unordered lists
306    fn fix_marker_spacing(
307        &self,
308        marker: &str,
309        after_marker: &str,
310        indent: &str,
311        is_multi_line: bool,
312        is_ordered: bool,
313    ) -> Option<String> {
314        // MD030 only fixes multiple spaces, not tabs
315        // Tabs are handled by MD010 (no-hard-tabs), matching markdownlint behavior
316        // Skip if the spacing starts with a tab
317        if after_marker.starts_with('\t') {
318            return None;
319        }
320
321        // Calculate expected spacing based on list type and context
322        let expected_spaces = if is_ordered {
323            if is_multi_line {
324                self.config.ol_multi.get()
325            } else {
326                self.config.ol_single.get()
327            }
328        } else if is_multi_line {
329            self.config.ul_multi.get()
330        } else {
331            self.config.ul_single.get()
332        };
333
334        // Case 1: No space after marker (content directly follows marker)
335        // User intention: they meant to write a list item but forgot the space
336        if !after_marker.is_empty() && !after_marker.starts_with(' ') {
337            let spaces = " ".repeat(expected_spaces);
338            return Some(format!("{indent}{marker}{spaces}{after_marker}"));
339        }
340
341        // Case 2: Multiple spaces after marker
342        if after_marker.starts_with("  ") {
343            let content = after_marker.trim_start_matches(' ');
344            if !content.is_empty() {
345                let spaces = " ".repeat(expected_spaces);
346                return Some(format!("{indent}{marker}{spaces}{content}"));
347            }
348        }
349
350        None
351    }
352
353    /// Fix list marker spacing with context - handles tabs, multiple spaces, and mixed whitespace
354    fn try_fix_list_marker_spacing_with_context(&self, line: &str, is_multi_line: bool) -> Option<String> {
355        // Extract blockquote prefix if present
356        let (blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
357
358        let trimmed = content.trim_start();
359        let indent = &content[..content.len() - trimmed.len()];
360
361        // Check for unordered list markers
362        for marker in &["*", "-", "+"] {
363            if let Some(after_marker) = trimmed.strip_prefix(marker) {
364                // Skip emphasis patterns (**, --, ++)
365                if after_marker.starts_with(*marker) {
366                    break;
367                }
368
369                // Skip if this looks like emphasis: *text* or _text_
370                // Use simple heuristic here (fix function has no ctx access)
371                // Being conservative is fine for autofix
372                if *marker == "*" && after_marker.contains('*') {
373                    break;
374                }
375
376                // Skip patterns that don't CLEARLY look like list items
377                if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t') {
378                    let first_char = after_marker.chars().next().unwrap_or(' ');
379
380                    // Skip signed numbers: -1, +1, -123, etc.
381                    if (*marker == "-" || *marker == "+") && first_char.is_ascii_digit() {
382                        break;
383                    }
384
385                    // Skip glob/filename patterns: *.txt, *.md, etc.
386                    if *marker == "*" && first_char == '.' {
387                        break;
388                    }
389
390                    // For CLEAR user intent, only fix if:
391                    // 1. Starts with uppercase letter (strong list indicator), OR
392                    // 2. Starts with [ or ( (link/paren content)
393                    let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
394
395                    if !is_clear_intent {
396                        break;
397                    }
398                }
399
400                if let Some(fixed) = self.fix_marker_spacing(marker, after_marker, indent, is_multi_line, false) {
401                    return Some(format!("{blockquote_prefix}{fixed}"));
402                }
403                break; // Found a marker, don't check others
404            }
405        }
406
407        // Check for ordered list markers
408        if let Some(dot_pos) = trimmed.find('.') {
409            let before_dot = &trimmed[..dot_pos];
410            if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
411                let after_dot = &trimmed[dot_pos + 1..];
412
413                // Skip empty items
414                if after_dot.is_empty() {
415                    return None;
416                }
417
418                // For NO-SPACE case (content directly after dot), apply "clear user intent" filter
419                if !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
420                    let first_char = after_dot.chars().next().unwrap_or(' ');
421
422                    // Skip decimal numbers: 3.14, 2.5, etc.
423                    if first_char.is_ascii_digit() {
424                        return None;
425                    }
426
427                    // For CLEAR user intent, only fix if:
428                    // 1. Starts with uppercase letter (strong list indicator), OR
429                    // 2. Starts with [ or ( (link/paren content)
430                    let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
431
432                    if !is_clear_intent {
433                        return None;
434                    }
435                }
436                // For items with spaces (including multiple spaces), always let fix_marker_spacing handle it
437
438                let marker = format!("{before_dot}.");
439                if let Some(fixed) = self.fix_marker_spacing(&marker, after_dot, indent, is_multi_line, true) {
440                    return Some(format!("{blockquote_prefix}{fixed}"));
441                }
442            }
443        }
444
445        None
446    }
447
448    /// Strip blockquote prefix from a line, returning (prefix, content)
449    fn strip_blockquote_prefix(line: &str) -> (String, &str) {
450        let mut prefix = String::new();
451        let mut remaining = line;
452
453        loop {
454            let trimmed = remaining.trim_start();
455            if !trimmed.starts_with('>') {
456                break;
457            }
458            // Add leading spaces to prefix
459            let leading_spaces = remaining.len() - trimmed.len();
460            prefix.push_str(&remaining[..leading_spaces]);
461            prefix.push('>');
462            remaining = &trimmed[1..];
463
464            // Handle optional space after >
465            if remaining.starts_with(' ') {
466                prefix.push(' ');
467                remaining = &remaining[1..];
468            }
469        }
470
471        (prefix, remaining)
472    }
473
474    /// Detect list-like patterns that the parser didn't recognize (e.g., "1.Text" with no space)
475    /// This implements user-intention-based detection: if it looks like a list item, flag it
476    fn check_unrecognized_list_marker(
477        &self,
478        ctx: &crate::lint_context::LintContext,
479        line: &str,
480        line_num: usize,
481        lines: &[&str],
482    ) -> Option<LintWarning> {
483        // Strip blockquote prefix to analyze the content
484        let (blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
485        let prefix_len = blockquote_prefix.len();
486
487        let trimmed = content.trim_start();
488        let indent_len = content.len() - trimmed.len();
489
490        // Note: We intentionally do NOT skip continuation lines here.
491        // Even if a line is indented after a list item, if it looks like a new list item
492        // (starts with *, -, +, or number.), we want to flag the missing space.
493        // The user's intention matters more than strict CommonMark parsing.
494
495        // Check for unordered list markers (*, -, +) without proper spacing
496        for marker in &["*", "-", "+"] {
497            if let Some(after_marker) = trimmed.strip_prefix(marker) {
498                // Skip if this is emphasis (**, __, ++) or other non-list patterns
499                // A list marker followed immediately by the same character is likely emphasis
500                if after_marker.starts_with(*marker) {
501                    break;
502                }
503
504                // Skip if this line starts with emphasis (use parsed emphasis spans)
505                // Account for blockquote prefix when comparing column positions
506                let emphasis_spans = ctx.emphasis_spans_on_line(line_num);
507                if emphasis_spans
508                    .iter()
509                    .any(|span| span.start_col == prefix_len + indent_len)
510                {
511                    break;
512                }
513
514                // Only flag if there's content directly after the marker (no space, no tab)
515                // AND the content CLEARLY looks like list item content
516                if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t') {
517                    let first_char = after_marker.chars().next().unwrap_or(' ');
518
519                    // Skip signed numbers: -1, +1, -123, etc.
520                    if (*marker == "-" || *marker == "+") && first_char.is_ascii_digit() {
521                        break;
522                    }
523
524                    // Skip glob/filename patterns: *.txt, *.md, *.[ext], etc.
525                    if *marker == "*" && first_char == '.' {
526                        break;
527                    }
528
529                    // For CLEAR user intent, only flag if:
530                    // 1. Starts with uppercase letter (strong list indicator), OR
531                    // 2. Starts with [ or ( (link/paren content)
532                    // Lowercase content is ambiguous (could be flag, glob, etc.)
533                    let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
534
535                    if !is_clear_intent {
536                        break;
537                    }
538
539                    let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
540                    let expected_spaces = self.get_expected_spaces(ListType::Unordered, is_multi_line);
541
542                    let marker_pos = indent_len;
543                    let marker_end = marker_pos + marker.len();
544
545                    let (start_line, start_col, end_line, end_col) =
546                        calculate_match_range(line_num, line, marker_end, 0);
547
548                    let correct_spaces = " ".repeat(expected_spaces);
549                    let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
550                    let fix_position = line_start_byte + marker_end;
551
552                    return Some(LintWarning {
553                        rule_name: Some("MD030".to_string()),
554                        severity: Severity::Warning,
555                        line: start_line,
556                        column: start_col,
557                        end_line,
558                        end_column: end_col,
559                        message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
560                        fix: Some(crate::rule::Fix {
561                            range: fix_position..fix_position,
562                            replacement: correct_spaces,
563                        }),
564                    });
565                }
566                break; // Found a marker, don't check others
567            }
568        }
569
570        // Check for ordered list markers (digits followed by .) without proper spacing
571        if let Some(dot_pos) = trimmed.find('.') {
572            let before_dot = &trimmed[..dot_pos];
573            if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
574                let after_dot = &trimmed[dot_pos + 1..];
575                // Only flag if there's content directly after the marker (no space, no tab)
576                if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
577                    let first_char = after_dot.chars().next().unwrap_or(' ');
578
579                    // For CLEAR user intent, only flag if:
580                    // 1. Starts with uppercase letter (strong list indicator), OR
581                    // 2. Starts with [ or ( (link/paren content)
582                    // Lowercase and digits are ambiguous (could be decimal, version, etc.)
583                    let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
584
585                    if is_clear_intent {
586                        let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
587                        let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
588
589                        let marker = format!("{before_dot}.");
590                        let marker_pos = indent_len;
591                        let marker_end = marker_pos + marker.len();
592
593                        let (start_line, start_col, end_line, end_col) =
594                            calculate_match_range(line_num, line, marker_end, 0);
595
596                        let correct_spaces = " ".repeat(expected_spaces);
597                        let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
598                        let fix_position = line_start_byte + marker_end;
599
600                        return Some(LintWarning {
601                            rule_name: Some("MD030".to_string()),
602                            severity: Severity::Warning,
603                            line: start_line,
604                            column: start_col,
605                            end_line,
606                            end_column: end_col,
607                            message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
608                            fix: Some(crate::rule::Fix {
609                                range: fix_position..fix_position,
610                                replacement: correct_spaces,
611                            }),
612                        });
613                    }
614                }
615            }
616        }
617
618        None
619    }
620
621    /// Simplified multi-line check for unrecognized list items
622    fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
623        // For unrecognized list items, we can't rely on parser info
624        // Check if the next line exists and appears to be a continuation
625        if line_num < lines.len() {
626            let next_line = lines[line_num]; // line_num is 1-based, so this is the next line
627            let next_trimmed = next_line.trim();
628            // If next line is non-empty and indented, it might be a continuation
629            if !next_trimmed.is_empty() && next_line.starts_with(' ') {
630                return true;
631            }
632        }
633        false
634    }
635
636    /// Check if a line is part of an indented code block (4+ columns with blank line before)
637    fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
638        // Must have 4+ columns of indentation (accounting for tab expansion)
639        if ElementCache::calculate_indentation_width_default(line) < 4 {
640            return false;
641        }
642
643        // If it's the first line, it's not an indented code block
644        if line_idx == 0 {
645            return false;
646        }
647
648        // Check if there's a blank line before this line or before the start of the indented block
649        if self.has_blank_line_before_indented_block(line_idx, lines) {
650            return true;
651        }
652
653        false
654    }
655
656    /// Check if there's a blank line before the start of an indented block
657    fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
658        // Walk backwards to find the start of the indented block
659        let mut current_idx = line_idx;
660
661        // Find the first line in this indented block
662        while current_idx > 0 {
663            let current_line = lines[current_idx];
664            let prev_line = lines[current_idx - 1];
665
666            // If current line is not indented (< 4 columns), we've gone too far
667            if ElementCache::calculate_indentation_width_default(current_line) < 4 {
668                break;
669            }
670
671            // If previous line is not indented, check if it's blank
672            if ElementCache::calculate_indentation_width_default(prev_line) < 4 {
673                return prev_line.trim().is_empty();
674            }
675
676            current_idx -= 1;
677        }
678
679        false
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use crate::lint_context::LintContext;
687
688    #[test]
689    fn test_basic_functionality() {
690        let rule = MD030ListMarkerSpace::default();
691        let content = "* Item 1\n* Item 2\n  * Nested item\n1. Ordered item";
692        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693        let result = rule.check(&ctx).unwrap();
694        assert!(
695            result.is_empty(),
696            "Correctly spaced list markers should not generate warnings"
697        );
698        let content = "*  Item 1 (too many spaces)\n* Item 2\n1.   Ordered item (too many spaces)";
699        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700        let result = rule.check(&ctx).unwrap();
701        // Expect warnings for lines with too many spaces after the marker
702        assert_eq!(
703            result.len(),
704            2,
705            "Should flag lines with too many spaces after list marker"
706        );
707        for warning in result {
708            assert!(
709                warning.message.starts_with("Spaces after list markers (Expected:")
710                    && warning.message.contains("Actual:"),
711                "Warning message should include expected and actual values, got: '{}'",
712                warning.message
713            );
714        }
715    }
716}