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        // Case 3: Single space after marker but expected spacing differs
373        // This handles custom configurations like ul_single = 3
374        if after_marker.starts_with(' ') && !after_marker.starts_with("  ") && expected_spaces != 1 {
375            let content = &after_marker[1..]; // Skip the single space
376            if !content.is_empty() {
377                let spaces = " ".repeat(expected_spaces);
378                return Some(format!("{indent}{marker}{spaces}{content}"));
379            }
380        }
381
382        None
383    }
384
385    /// Fix list marker spacing with context - handles tabs, multiple spaces, and mixed whitespace
386    fn try_fix_list_marker_spacing_with_context(&self, line: &str, is_multi_line: bool) -> Option<String> {
387        // Extract blockquote prefix if present
388        let (blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
389
390        let trimmed = content.trim_start();
391        let indent = &content[..content.len() - trimmed.len()];
392
393        // Check for unordered list markers - only fix multiple-space issues, not missing-space
394        // Unordered markers (*, -, +) have too many non-list uses to apply heuristic fixing
395        for marker in &["*", "-", "+"] {
396            if let Some(after_marker) = trimmed.strip_prefix(marker) {
397                // Skip emphasis patterns (**, --, ++)
398                if after_marker.starts_with(*marker) {
399                    break;
400                }
401
402                // Skip if this looks like emphasis: *text* or _text_
403                if *marker == "*" && after_marker.contains('*') {
404                    break;
405                }
406
407                // Fix if there's a space (handling both multiple spaces and single space with non-default config)
408                // Don't add spaces where there are none - too ambiguous for unordered markers
409                if after_marker.starts_with(' ')
410                    && let Some(fixed) = self.fix_marker_spacing(marker, after_marker, indent, is_multi_line, false)
411                {
412                    return Some(format!("{blockquote_prefix}{fixed}"));
413                }
414                break; // Found a marker, don't check others
415            }
416        }
417
418        // Check for ordered list markers
419        if let Some(dot_pos) = trimmed.find('.') {
420            let before_dot = &trimmed[..dot_pos];
421            if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
422                let after_dot = &trimmed[dot_pos + 1..];
423
424                // Skip empty items
425                if after_dot.is_empty() {
426                    return None;
427                }
428
429                // For NO-SPACE case (content directly after dot), apply "clear user intent" filter
430                if !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
431                    let first_char = after_dot.chars().next().unwrap_or(' ');
432
433                    // Skip decimal numbers: 3.14, 2.5, etc.
434                    if first_char.is_ascii_digit() {
435                        return None;
436                    }
437
438                    // For CLEAR user intent, only fix if:
439                    // 1. Starts with uppercase letter (strong list indicator), OR
440                    // 2. Starts with [ or ( (link/paren content)
441                    let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
442
443                    if !is_clear_intent {
444                        return None;
445                    }
446                }
447                // For items with spaces (including multiple spaces), always let fix_marker_spacing handle it
448
449                let marker = format!("{before_dot}.");
450                if let Some(fixed) = self.fix_marker_spacing(&marker, after_dot, indent, is_multi_line, true) {
451                    return Some(format!("{blockquote_prefix}{fixed}"));
452                }
453            }
454        }
455
456        None
457    }
458
459    /// Strip blockquote prefix from a line, returning (prefix, content)
460    fn strip_blockquote_prefix(line: &str) -> (String, &str) {
461        let mut prefix = String::new();
462        let mut remaining = line;
463
464        loop {
465            let trimmed = remaining.trim_start();
466            if !trimmed.starts_with('>') {
467                break;
468            }
469            // Add leading spaces to prefix
470            let leading_spaces = remaining.len() - trimmed.len();
471            prefix.push_str(&remaining[..leading_spaces]);
472            prefix.push('>');
473            remaining = &trimmed[1..];
474
475            // Handle optional space after >
476            if remaining.starts_with(' ') {
477                prefix.push(' ');
478                remaining = &remaining[1..];
479            }
480        }
481
482        (prefix, remaining)
483    }
484
485    /// Detect list-like patterns that the parser didn't recognize (e.g., "1.Text" with no space)
486    /// This implements user-intention-based detection: if it looks like a list item, flag it
487    fn check_unrecognized_list_marker(
488        &self,
489        ctx: &crate::lint_context::LintContext,
490        line: &str,
491        line_num: usize,
492        lines: &[&str],
493    ) -> Option<LintWarning> {
494        // Strip blockquote prefix to analyze the content
495        let (_blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
496
497        let trimmed = content.trim_start();
498        let indent_len = content.len() - trimmed.len();
499
500        // Note: We intentionally do NOT apply heuristic detection to unordered list markers
501        // (*, -, +) because they have too many non-list uses: emphasis, globs, diffs, etc.
502        // The parser handles valid unordered list items; we only do heuristic detection
503        // for ordered lists where "1.Text" is almost always a list item with missing space.
504
505        // Check for ordered list markers (digits followed by .) without proper spacing
506        if let Some(dot_pos) = trimmed.find('.') {
507            let before_dot = &trimmed[..dot_pos];
508            if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
509                let after_dot = &trimmed[dot_pos + 1..];
510                // Only flag if there's content directly after the marker (no space, no tab)
511                if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
512                    let first_char = after_dot.chars().next().unwrap_or(' ');
513
514                    // For CLEAR user intent, only flag if:
515                    // 1. Starts with uppercase letter (strong list indicator), OR
516                    // 2. Starts with [ or ( (link/paren content)
517                    // Lowercase and digits are ambiguous (could be decimal, version, etc.)
518                    let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
519
520                    if is_clear_intent {
521                        let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
522                        let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
523
524                        let marker = format!("{before_dot}.");
525                        let marker_pos = indent_len;
526                        let marker_end = marker_pos + marker.len();
527
528                        let (start_line, start_col, end_line, end_col) =
529                            calculate_match_range(line_num, line, marker_end, 0);
530
531                        let correct_spaces = " ".repeat(expected_spaces);
532                        let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
533                        let fix_position = line_start_byte + marker_end;
534
535                        return Some(LintWarning {
536                            rule_name: Some("MD030".to_string()),
537                            severity: Severity::Warning,
538                            line: start_line,
539                            column: start_col,
540                            end_line,
541                            end_column: end_col,
542                            message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
543                            fix: Some(crate::rule::Fix {
544                                range: fix_position..fix_position,
545                                replacement: correct_spaces,
546                            }),
547                        });
548                    }
549                }
550            }
551        }
552
553        None
554    }
555
556    /// Simplified multi-line check for unrecognized list items
557    fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
558        // For unrecognized list items, we can't rely on parser info
559        // Check if the next line exists and appears to be a continuation
560        if line_num < lines.len() {
561            let next_line = lines[line_num]; // line_num is 1-based, so this is the next line
562            let next_trimmed = next_line.trim();
563            // If next line is non-empty and indented, it might be a continuation
564            if !next_trimmed.is_empty() && next_line.starts_with(' ') {
565                return true;
566            }
567        }
568        false
569    }
570
571    /// Check if a line is part of an indented code block (4+ columns with blank line before)
572    fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
573        // Must have 4+ columns of indentation (accounting for tab expansion)
574        if ElementCache::calculate_indentation_width_default(line) < 4 {
575            return false;
576        }
577
578        // If it's the first line, it's not an indented code block
579        if line_idx == 0 {
580            return false;
581        }
582
583        // Check if there's a blank line before this line or before the start of the indented block
584        if self.has_blank_line_before_indented_block(line_idx, lines) {
585            return true;
586        }
587
588        false
589    }
590
591    /// Check if there's a blank line before the start of an indented block
592    fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
593        // Walk backwards to find the start of the indented block
594        let mut current_idx = line_idx;
595
596        // Find the first line in this indented block
597        while current_idx > 0 {
598            let current_line = lines[current_idx];
599            let prev_line = lines[current_idx - 1];
600
601            // If current line is not indented (< 4 columns), we've gone too far
602            if ElementCache::calculate_indentation_width_default(current_line) < 4 {
603                break;
604            }
605
606            // If previous line is not indented, check if it's blank
607            if ElementCache::calculate_indentation_width_default(prev_line) < 4 {
608                return prev_line.trim().is_empty();
609            }
610
611            current_idx -= 1;
612        }
613
614        false
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::lint_context::LintContext;
622
623    #[test]
624    fn test_basic_functionality() {
625        let rule = MD030ListMarkerSpace::default();
626        let content = "* Item 1\n* Item 2\n  * Nested item\n1. Ordered item";
627        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628        let result = rule.check(&ctx).unwrap();
629        assert!(
630            result.is_empty(),
631            "Correctly spaced list markers should not generate warnings"
632        );
633        let content = "*  Item 1 (too many spaces)\n* Item 2\n1.   Ordered item (too many spaces)";
634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635        let result = rule.check(&ctx).unwrap();
636        // Expect warnings for lines with too many spaces after the marker
637        assert_eq!(
638            result.len(),
639            2,
640            "Should flag lines with too many spaces after list marker"
641        );
642        for warning in result {
643            assert!(
644                warning.message.starts_with("Spaces after list markers (Expected:")
645                    && warning.message.contains("Actual:"),
646                "Warning message should include expected and actual values, got: '{}'",
647                warning.message
648            );
649        }
650    }
651
652    #[test]
653    fn test_nested_emphasis_not_flagged_issue_278() {
654        // Issue #278: Nested emphasis like *text **bold** more* should not trigger MD030
655        let rule = MD030ListMarkerSpace::default();
656
657        // This is emphasis with nested bold - NOT a list item
658        let content = "*This text is **very** important*";
659        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660        let result = rule.check(&ctx).unwrap();
661        assert!(
662            result.is_empty(),
663            "Nested emphasis should not trigger MD030, got: {result:?}"
664        );
665
666        // Simple emphasis - NOT a list item
667        let content2 = "*Hello World*";
668        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
669        let result2 = rule.check(&ctx2).unwrap();
670        assert!(
671            result2.is_empty(),
672            "Simple emphasis should not trigger MD030, got: {result2:?}"
673        );
674
675        // Bold text - NOT a list item
676        let content3 = "**bold text**";
677        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
678        let result3 = rule.check(&ctx3).unwrap();
679        assert!(
680            result3.is_empty(),
681            "Bold text should not trigger MD030, got: {result3:?}"
682        );
683
684        // Bold+italic - NOT a list item
685        let content4 = "***bold and italic***";
686        let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
687        let result4 = rule.check(&ctx4).unwrap();
688        assert!(
689            result4.is_empty(),
690            "Bold+italic should not trigger MD030, got: {result4:?}"
691        );
692
693        // Actual list item with proper spacing - should NOT trigger
694        let content5 = "* Item with space";
695        let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
696        let result5 = rule.check(&ctx5).unwrap();
697        assert!(
698            result5.is_empty(),
699            "Properly spaced list item should not trigger MD030, got: {result5:?}"
700        );
701    }
702
703    #[test]
704    fn test_empty_marker_line_not_flagged_issue_288() {
705        // Issue #288: List items with no content on the marker line should not trigger MD030
706        // The space requirement only applies when there IS content after the marker
707        let rule = MD030ListMarkerSpace::default();
708
709        // Case 1: Unordered list with empty marker line followed by code block
710        let content = "-\n    ```python\n    print(\"code\")\n    ```\n";
711        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712        let result = rule.check(&ctx).unwrap();
713        assert!(
714            result.is_empty(),
715            "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
716        );
717
718        // Case 2: Ordered list with empty marker line followed by code block
719        let content = "1.\n    ```python\n    print(\"code\")\n    ```\n";
720        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
721        let result = rule.check(&ctx).unwrap();
722        assert!(
723            result.is_empty(),
724            "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
725        );
726
727        // Case 3: Empty marker line followed by paragraph continuation
728        let content = "-\n    This is a paragraph continuation\n    of the list item.\n";
729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730        let result = rule.check(&ctx).unwrap();
731        assert!(
732            result.is_empty(),
733            "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
734        );
735
736        // Case 4: Nested list with empty marker line
737        let content = "- Parent item\n  -\n      Nested content\n";
738        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739        let result = rule.check(&ctx).unwrap();
740        assert!(
741            result.is_empty(),
742            "Nested empty marker line should not trigger MD030, got: {result:?}"
743        );
744
745        // Case 5: Multiple list items, some with empty markers
746        let content = "- Item with content\n-\n    Code block\n- Another item\n";
747        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
748        let result = rule.check(&ctx).unwrap();
749        assert!(
750            result.is_empty(),
751            "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
752        );
753    }
754
755    #[test]
756    fn test_marker_with_content_still_flagged_issue_288() {
757        // Ensure we still flag markers with content but wrong spacing
758        let rule = MD030ListMarkerSpace::default();
759
760        // Two spaces before content - should flag
761        let content = "-  Two spaces before content\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 unordered marker should still trigger MD030"
768        );
769
770        // Ordered list with two spaces - should flag
771        let content = "1.  Two spaces\n";
772        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773        let result = rule.check(&ctx).unwrap();
774        assert_eq!(
775            result.len(),
776            1,
777            "Two spaces after ordered marker should still trigger MD030"
778        );
779
780        // Normal list item - should NOT flag
781        let content = "- Normal item\n";
782        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783        let result = rule.check(&ctx).unwrap();
784        assert!(
785            result.is_empty(),
786            "Normal list item should not trigger MD030, got: {result:?}"
787        );
788    }
789
790    #[test]
791    fn test_has_content_after_marker() {
792        // Direct unit tests for the helper function
793        assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
794        assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
795        assert!(!MD030ListMarkerSpace::has_content_after_marker("-   ", 1));
796        assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
797        assert!(MD030ListMarkerSpace::has_content_after_marker("-  item", 1));
798        assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
799        assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
800        assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
801    }
802}