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