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            // Skip code blocks and math blocks - content inside these is not markdown
77            if line_info.list_item.is_some() && !line_info.in_code_block && !line_info.in_math_block {
78                let line_num_1based = line_num + 1;
79                processed_lines.insert(line_num_1based);
80
81                let line = lines[line_num];
82
83                // Skip indented code blocks (4+ columns accounting for tab expansion)
84                if ElementCache::calculate_indentation_width_default(line) >= 4 {
85                    continue;
86                }
87
88                if let Some(list_info) = &line_info.list_item {
89                    let list_type = if list_info.is_ordered {
90                        ListType::Ordered
91                    } else {
92                        ListType::Unordered
93                    };
94
95                    // Calculate actual spacing after marker
96                    let marker_end = list_info.marker_column + list_info.marker.len();
97
98                    // Skip if there's no content on this line after the marker
99                    // MD030 only applies when there IS content after the marker
100                    if !Self::has_content_after_marker(line, marker_end) {
101                        continue;
102                    }
103
104                    let actual_spaces = list_info.content_column.saturating_sub(marker_end);
105
106                    // Determine if this is a multi-line list item
107                    let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, &lines);
108                    let expected_spaces = self.get_expected_spaces(list_type, is_multi_line);
109
110                    if actual_spaces != expected_spaces {
111                        let whitespace_start_pos = marker_end;
112                        let whitespace_len = actual_spaces;
113
114                        let (start_line, start_col, end_line, end_col) =
115                            calculate_match_range(line_num_1based, line, whitespace_start_pos, whitespace_len);
116
117                        let correct_spaces = " ".repeat(expected_spaces);
118                        let line_start_byte = ctx.line_offsets.get(line_num).copied().unwrap_or(0);
119                        let whitespace_start_byte = line_start_byte + whitespace_start_pos;
120                        let whitespace_end_byte = whitespace_start_byte + whitespace_len;
121
122                        let fix = Some(crate::rule::Fix {
123                            range: whitespace_start_byte..whitespace_end_byte,
124                            replacement: correct_spaces,
125                        });
126
127                        let message =
128                            format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})");
129
130                        warnings.push(LintWarning {
131                            rule_name: Some(self.name().to_string()),
132                            severity: Severity::Warning,
133                            line: start_line,
134                            column: start_col,
135                            end_line,
136                            end_column: end_col,
137                            message,
138                            fix,
139                        });
140                    }
141                }
142            }
143        }
144
145        // Second pass: Detect list-like patterns the parser didn't recognize
146        // This handles cases like "1.Text" where there's no space after the marker
147        for (line_idx, line) in lines.iter().enumerate() {
148            let line_num = line_idx + 1;
149
150            // Skip if already processed or in code block/front matter/math block
151            if processed_lines.contains(&line_num) {
152                continue;
153            }
154            if let Some(line_info) = ctx.lines.get(line_idx)
155                && (line_info.in_code_block
156                    || line_info.in_front_matter
157                    || line_info.in_html_comment
158                    || line_info.in_math_block)
159            {
160                continue;
161            }
162
163            // Skip indented code blocks
164            if self.is_indented_code_block(line, line_idx, &lines) {
165                continue;
166            }
167
168            // Try to detect list-like patterns using regex-based detection
169            if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, &lines) {
170                warnings.push(warning);
171            }
172        }
173
174        Ok(warnings)
175    }
176
177    fn category(&self) -> RuleCategory {
178        RuleCategory::List
179    }
180
181    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
182        if ctx.content.is_empty() {
183            return true;
184        }
185
186        // Fast byte-level check for list markers (including ordered lists)
187        let bytes = ctx.content.as_bytes();
188        !bytes.contains(&b'*')
189            && !bytes.contains(&b'-')
190            && !bytes.contains(&b'+')
191            && !bytes.iter().any(|&b| b.is_ascii_digit())
192    }
193
194    fn as_any(&self) -> &dyn std::any::Any {
195        self
196    }
197
198    fn default_config_section(&self) -> Option<(String, toml::Value)> {
199        let default_config = MD030Config::default();
200        let json_value = serde_json::to_value(&default_config).ok()?;
201        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
202
203        if let toml::Value::Table(table) = toml_value {
204            if !table.is_empty() {
205                Some((MD030Config::RULE_NAME.to_string(), toml::Value::Table(table)))
206            } else {
207                None
208            }
209        } else {
210            None
211        }
212    }
213
214    fn from_config(config: &crate::config::Config) -> Box<dyn Rule> {
215        let rule_config = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
216        Box::new(Self::from_config_struct(rule_config))
217    }
218
219    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
220        let content = ctx.content;
221
222        // Early return if no fixes needed
223        if self.should_skip(ctx) {
224            return Ok(content.to_string());
225        }
226
227        let lines: Vec<&str> = content.lines().collect();
228        let mut result_lines = Vec::with_capacity(lines.len());
229
230        for (line_idx, line) in lines.iter().enumerate() {
231            let line_num = line_idx + 1;
232
233            // Skip lines in code blocks, front matter, or HTML comments
234            if let Some(line_info) = ctx.lines.get(line_idx)
235                && (line_info.in_code_block || line_info.in_front_matter || line_info.in_html_comment)
236            {
237                result_lines.push(line.to_string());
238                continue;
239            }
240
241            // Skip if this is an indented code block (4+ spaces with blank line before)
242            if self.is_indented_code_block(line, line_idx, &lines) {
243                result_lines.push(line.to_string());
244                continue;
245            }
246
247            // Use regex-based detection to find list markers, not parser detection.
248            // This ensures we fix spacing on ALL lines that look like list items,
249            // even if the parser doesn't recognize them due to strict nesting rules.
250            // User intention matters: if it looks like a list item, fix it.
251            let is_multi_line = self.is_multi_line_list_item(ctx, line_num, &lines);
252            if let Some(fixed_line) = self.try_fix_list_marker_spacing_with_context(line, is_multi_line) {
253                result_lines.push(fixed_line);
254            } else {
255                result_lines.push(line.to_string());
256            }
257        }
258
259        // Preserve trailing newline if original content had one
260        let result = result_lines.join("\n");
261        if content.ends_with('\n') && !result.ends_with('\n') {
262            Ok(result + "\n")
263        } else {
264            Ok(result)
265        }
266    }
267}
268
269impl MD030ListMarkerSpace {
270    /// Check if a list item line has content after the marker
271    /// Returns false if the line ends after the marker (with optional whitespace)
272    /// MD030 only applies when there IS content on the same line as the marker
273    #[inline]
274    fn has_content_after_marker(line: &str, marker_end: usize) -> bool {
275        if marker_end >= line.len() {
276            return false;
277        }
278        !line[marker_end..].trim().is_empty()
279    }
280
281    /// Check if a list item is multi-line (spans multiple lines or contains nested content)
282    fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
283        // Get the current list item info
284        let current_line_info = match ctx.line_info(line_num) {
285            Some(info) if info.list_item.is_some() => info,
286            _ => return false,
287        };
288
289        let current_list = current_line_info.list_item.as_ref().unwrap();
290
291        // Check subsequent lines to see if they are continuation of this list item
292        for next_line_num in (line_num + 1)..=lines.len() {
293            if let Some(next_line_info) = ctx.line_info(next_line_num) {
294                // If we encounter another list item at the same or higher level, this item is done
295                if let Some(next_list) = &next_line_info.list_item {
296                    if next_list.marker_column <= current_list.marker_column {
297                        break; // Found the next list item at same/higher level
298                    }
299                    // If there's a nested list item, this is multi-line
300                    return true;
301                }
302
303                // If we encounter a non-empty line that's not indented enough to be part of this list item,
304                // this list item is done
305                let line_content = lines.get(next_line_num - 1).unwrap_or(&"");
306                if !line_content.trim().is_empty() {
307                    let expected_continuation_indent = current_list.content_column;
308                    let actual_indent = line_content.len() - line_content.trim_start().len();
309
310                    if actual_indent < expected_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 >= expected_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    /// Helper to fix marker spacing for both ordered and unordered lists
328    fn fix_marker_spacing(
329        &self,
330        marker: &str,
331        after_marker: &str,
332        indent: &str,
333        is_multi_line: bool,
334        is_ordered: bool,
335    ) -> Option<String> {
336        // MD030 only fixes multiple spaces, not tabs
337        // Tabs are handled by MD010 (no-hard-tabs), matching markdownlint behavior
338        // Skip if the spacing starts with a tab
339        if after_marker.starts_with('\t') {
340            return None;
341        }
342
343        // Calculate expected spacing based on list type and context
344        let expected_spaces = if is_ordered {
345            if is_multi_line {
346                self.config.ol_multi.get()
347            } else {
348                self.config.ol_single.get()
349            }
350        } else if is_multi_line {
351            self.config.ul_multi.get()
352        } else {
353            self.config.ul_single.get()
354        };
355
356        // Case 1: No space after marker (content directly follows marker)
357        // User intention: they meant to write a list item but forgot the space
358        if !after_marker.is_empty() && !after_marker.starts_with(' ') {
359            let spaces = " ".repeat(expected_spaces);
360            return Some(format!("{indent}{marker}{spaces}{after_marker}"));
361        }
362
363        // Case 2: Multiple spaces after marker
364        if after_marker.starts_with("  ") {
365            let content = after_marker.trim_start_matches(' ');
366            if !content.is_empty() {
367                let spaces = " ".repeat(expected_spaces);
368                return Some(format!("{indent}{marker}{spaces}{content}"));
369            }
370        }
371
372        None
373    }
374
375    /// Fix list marker spacing with context - handles tabs, multiple spaces, and mixed whitespace
376    fn try_fix_list_marker_spacing_with_context(&self, line: &str, is_multi_line: bool) -> Option<String> {
377        // Extract blockquote prefix if present
378        let (blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
379
380        let trimmed = content.trim_start();
381        let indent = &content[..content.len() - trimmed.len()];
382
383        // Check for unordered list markers - only fix multiple-space issues, not missing-space
384        // Unordered markers (*, -, +) have too many non-list uses to apply heuristic fixing
385        for marker in &["*", "-", "+"] {
386            if let Some(after_marker) = trimmed.strip_prefix(marker) {
387                // Skip emphasis patterns (**, --, ++)
388                if after_marker.starts_with(*marker) {
389                    break;
390                }
391
392                // Skip if this looks like emphasis: *text* or _text_
393                if *marker == "*" && after_marker.contains('*') {
394                    break;
395                }
396
397                // Only fix if there's already a space (fixing multiple spaces to single space)
398                // Don't add spaces where there are none - too ambiguous for unordered markers
399                if after_marker.starts_with("  ")
400                    && let Some(fixed) = self.fix_marker_spacing(marker, after_marker, indent, is_multi_line, false)
401                {
402                    return Some(format!("{blockquote_prefix}{fixed}"));
403                }
404                break; // Found a marker, don't check others
405            }
406        }
407
408        // Check for ordered list markers
409        if let Some(dot_pos) = trimmed.find('.') {
410            let before_dot = &trimmed[..dot_pos];
411            if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
412                let after_dot = &trimmed[dot_pos + 1..];
413
414                // Skip empty items
415                if after_dot.is_empty() {
416                    return None;
417                }
418
419                // For NO-SPACE case (content directly after dot), apply "clear user intent" filter
420                if !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
421                    let first_char = after_dot.chars().next().unwrap_or(' ');
422
423                    // Skip decimal numbers: 3.14, 2.5, etc.
424                    if first_char.is_ascii_digit() {
425                        return None;
426                    }
427
428                    // For CLEAR user intent, only fix if:
429                    // 1. Starts with uppercase letter (strong list indicator), OR
430                    // 2. Starts with [ or ( (link/paren content)
431                    let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
432
433                    if !is_clear_intent {
434                        return None;
435                    }
436                }
437                // For items with spaces (including multiple spaces), always let fix_marker_spacing handle it
438
439                let marker = format!("{before_dot}.");
440                if let Some(fixed) = self.fix_marker_spacing(&marker, after_dot, indent, is_multi_line, true) {
441                    return Some(format!("{blockquote_prefix}{fixed}"));
442                }
443            }
444        }
445
446        None
447    }
448
449    /// Strip blockquote prefix from a line, returning (prefix, content)
450    fn strip_blockquote_prefix(line: &str) -> (String, &str) {
451        let mut prefix = String::new();
452        let mut remaining = line;
453
454        loop {
455            let trimmed = remaining.trim_start();
456            if !trimmed.starts_with('>') {
457                break;
458            }
459            // Add leading spaces to prefix
460            let leading_spaces = remaining.len() - trimmed.len();
461            prefix.push_str(&remaining[..leading_spaces]);
462            prefix.push('>');
463            remaining = &trimmed[1..];
464
465            // Handle optional space after >
466            if remaining.starts_with(' ') {
467                prefix.push(' ');
468                remaining = &remaining[1..];
469            }
470        }
471
472        (prefix, remaining)
473    }
474
475    /// Detect list-like patterns that the parser didn't recognize (e.g., "1.Text" with no space)
476    /// This implements user-intention-based detection: if it looks like a list item, flag it
477    fn check_unrecognized_list_marker(
478        &self,
479        ctx: &crate::lint_context::LintContext,
480        line: &str,
481        line_num: usize,
482        lines: &[&str],
483    ) -> Option<LintWarning> {
484        // Strip blockquote prefix to analyze the content
485        let (_blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
486
487        let trimmed = content.trim_start();
488        let indent_len = content.len() - trimmed.len();
489
490        // Note: We intentionally do NOT apply heuristic detection to unordered list markers
491        // (*, -, +) because they have too many non-list uses: emphasis, globs, diffs, etc.
492        // The parser handles valid unordered list items; we only do heuristic detection
493        // for ordered lists where "1.Text" is almost always a list item with missing space.
494
495        // Check for ordered list markers (digits followed by .) without proper spacing
496        if let Some(dot_pos) = trimmed.find('.') {
497            let before_dot = &trimmed[..dot_pos];
498            if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
499                let after_dot = &trimmed[dot_pos + 1..];
500                // Only flag if there's content directly after the marker (no space, no tab)
501                if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
502                    let first_char = after_dot.chars().next().unwrap_or(' ');
503
504                    // For CLEAR user intent, only flag if:
505                    // 1. Starts with uppercase letter (strong list indicator), OR
506                    // 2. Starts with [ or ( (link/paren content)
507                    // Lowercase and digits are ambiguous (could be decimal, version, etc.)
508                    let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
509
510                    if is_clear_intent {
511                        let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
512                        let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
513
514                        let marker = format!("{before_dot}.");
515                        let marker_pos = indent_len;
516                        let marker_end = marker_pos + marker.len();
517
518                        let (start_line, start_col, end_line, end_col) =
519                            calculate_match_range(line_num, line, marker_end, 0);
520
521                        let correct_spaces = " ".repeat(expected_spaces);
522                        let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
523                        let fix_position = line_start_byte + marker_end;
524
525                        return Some(LintWarning {
526                            rule_name: Some("MD030".to_string()),
527                            severity: Severity::Warning,
528                            line: start_line,
529                            column: start_col,
530                            end_line,
531                            end_column: end_col,
532                            message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
533                            fix: Some(crate::rule::Fix {
534                                range: fix_position..fix_position,
535                                replacement: correct_spaces,
536                            }),
537                        });
538                    }
539                }
540            }
541        }
542
543        None
544    }
545
546    /// Simplified multi-line check for unrecognized list items
547    fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
548        // For unrecognized list items, we can't rely on parser info
549        // Check if the next line exists and appears to be a continuation
550        if line_num < lines.len() {
551            let next_line = lines[line_num]; // line_num is 1-based, so this is the next line
552            let next_trimmed = next_line.trim();
553            // If next line is non-empty and indented, it might be a continuation
554            if !next_trimmed.is_empty() && next_line.starts_with(' ') {
555                return true;
556            }
557        }
558        false
559    }
560
561    /// Check if a line is part of an indented code block (4+ columns with blank line before)
562    fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
563        // Must have 4+ columns of indentation (accounting for tab expansion)
564        if ElementCache::calculate_indentation_width_default(line) < 4 {
565            return false;
566        }
567
568        // If it's the first line, it's not an indented code block
569        if line_idx == 0 {
570            return false;
571        }
572
573        // Check if there's a blank line before this line or before the start of the indented block
574        if self.has_blank_line_before_indented_block(line_idx, lines) {
575            return true;
576        }
577
578        false
579    }
580
581    /// Check if there's a blank line before the start of an indented block
582    fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
583        // Walk backwards to find the start of the indented block
584        let mut current_idx = line_idx;
585
586        // Find the first line in this indented block
587        while current_idx > 0 {
588            let current_line = lines[current_idx];
589            let prev_line = lines[current_idx - 1];
590
591            // If current line is not indented (< 4 columns), we've gone too far
592            if ElementCache::calculate_indentation_width_default(current_line) < 4 {
593                break;
594            }
595
596            // If previous line is not indented, check if it's blank
597            if ElementCache::calculate_indentation_width_default(prev_line) < 4 {
598                return prev_line.trim().is_empty();
599            }
600
601            current_idx -= 1;
602        }
603
604        false
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::lint_context::LintContext;
612
613    #[test]
614    fn test_basic_functionality() {
615        let rule = MD030ListMarkerSpace::default();
616        let content = "* Item 1\n* Item 2\n  * Nested item\n1. Ordered item";
617        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618        let result = rule.check(&ctx).unwrap();
619        assert!(
620            result.is_empty(),
621            "Correctly spaced list markers should not generate warnings"
622        );
623        let content = "*  Item 1 (too many spaces)\n* Item 2\n1.   Ordered item (too many spaces)";
624        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625        let result = rule.check(&ctx).unwrap();
626        // Expect warnings for lines with too many spaces after the marker
627        assert_eq!(
628            result.len(),
629            2,
630            "Should flag lines with too many spaces after list marker"
631        );
632        for warning in result {
633            assert!(
634                warning.message.starts_with("Spaces after list markers (Expected:")
635                    && warning.message.contains("Actual:"),
636                "Warning message should include expected and actual values, got: '{}'",
637                warning.message
638            );
639        }
640    }
641
642    #[test]
643    fn test_nested_emphasis_not_flagged_issue_278() {
644        // Issue #278: Nested emphasis like *text **bold** more* should not trigger MD030
645        let rule = MD030ListMarkerSpace::default();
646
647        // This is emphasis with nested bold - NOT a list item
648        let content = "*This text is **very** important*";
649        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650        let result = rule.check(&ctx).unwrap();
651        assert!(
652            result.is_empty(),
653            "Nested emphasis should not trigger MD030, got: {result:?}"
654        );
655
656        // Simple emphasis - NOT a list item
657        let content2 = "*Hello World*";
658        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
659        let result2 = rule.check(&ctx2).unwrap();
660        assert!(
661            result2.is_empty(),
662            "Simple emphasis should not trigger MD030, got: {result2:?}"
663        );
664
665        // Bold text - NOT a list item
666        let content3 = "**bold text**";
667        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
668        let result3 = rule.check(&ctx3).unwrap();
669        assert!(
670            result3.is_empty(),
671            "Bold text should not trigger MD030, got: {result3:?}"
672        );
673
674        // Bold+italic - NOT a list item
675        let content4 = "***bold and italic***";
676        let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
677        let result4 = rule.check(&ctx4).unwrap();
678        assert!(
679            result4.is_empty(),
680            "Bold+italic should not trigger MD030, got: {result4:?}"
681        );
682
683        // Actual list item with proper spacing - should NOT trigger
684        let content5 = "* Item with space";
685        let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
686        let result5 = rule.check(&ctx5).unwrap();
687        assert!(
688            result5.is_empty(),
689            "Properly spaced list item should not trigger MD030, got: {result5:?}"
690        );
691    }
692
693    #[test]
694    fn test_empty_marker_line_not_flagged_issue_288() {
695        // Issue #288: List items with no content on the marker line should not trigger MD030
696        // The space requirement only applies when there IS content after the marker
697        let rule = MD030ListMarkerSpace::default();
698
699        // Case 1: Unordered list with empty marker line followed by code block
700        let content = "-\n    ```python\n    print(\"code\")\n    ```\n";
701        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702        let result = rule.check(&ctx).unwrap();
703        assert!(
704            result.is_empty(),
705            "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
706        );
707
708        // Case 2: Ordered list with empty marker line followed by code block
709        let content = "1.\n    ```python\n    print(\"code\")\n    ```\n";
710        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711        let result = rule.check(&ctx).unwrap();
712        assert!(
713            result.is_empty(),
714            "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
715        );
716
717        // Case 3: Empty marker line followed by paragraph continuation
718        let content = "-\n    This is a paragraph continuation\n    of the list item.\n";
719        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720        let result = rule.check(&ctx).unwrap();
721        assert!(
722            result.is_empty(),
723            "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
724        );
725
726        // Case 4: Nested list with empty marker line
727        let content = "- Parent item\n  -\n      Nested content\n";
728        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729        let result = rule.check(&ctx).unwrap();
730        assert!(
731            result.is_empty(),
732            "Nested empty marker line should not trigger MD030, got: {result:?}"
733        );
734
735        // Case 5: Multiple list items, some with empty markers
736        let content = "- Item with content\n-\n    Code block\n- Another item\n";
737        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738        let result = rule.check(&ctx).unwrap();
739        assert!(
740            result.is_empty(),
741            "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
742        );
743    }
744
745    #[test]
746    fn test_marker_with_content_still_flagged_issue_288() {
747        // Ensure we still flag markers with content but wrong spacing
748        let rule = MD030ListMarkerSpace::default();
749
750        // Two spaces before content - should flag
751        let content = "-  Two spaces before content\n";
752        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753        let result = rule.check(&ctx).unwrap();
754        assert_eq!(
755            result.len(),
756            1,
757            "Two spaces after unordered marker should still trigger MD030"
758        );
759
760        // Ordered list with two spaces - should flag
761        let content = "1.  Two spaces\n";
762        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763        let result = rule.check(&ctx).unwrap();
764        assert_eq!(
765            result.len(),
766            1,
767            "Two spaces after ordered marker should still trigger MD030"
768        );
769
770        // Normal list item - should NOT flag
771        let content = "- Normal item\n";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let result = rule.check(&ctx).unwrap();
774        assert!(
775            result.is_empty(),
776            "Normal list item should not trigger MD030, got: {result:?}"
777        );
778    }
779
780    #[test]
781    fn test_has_content_after_marker() {
782        // Direct unit tests for the helper function
783        assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
784        assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
785        assert!(!MD030ListMarkerSpace::has_content_after_marker("-   ", 1));
786        assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
787        assert!(MD030ListMarkerSpace::has_content_after_marker("-  item", 1));
788        assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
789        assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
790        assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
791    }
792}