Skip to main content

rumdl_lib/rules/
md030_list_marker_space.rs

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