1use 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 if self.should_skip(ctx) {
66 return Ok(warnings);
67 }
68
69 let lines: Vec<&str> = ctx.content.lines().collect();
71
72 let mut processed_lines = std::collections::HashSet::new();
74
75 for (line_num, line_info) in ctx.lines.iter().enumerate() {
77 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 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 let marker_end = list_info.marker_column + list_info.marker.len();
98
99 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 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 for (line_idx, line) in lines.iter().enumerate() {
149 let line_num = line_idx + 1;
150
151 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 if self.is_indented_code_block(line, line_idx, &lines) {
166 continue;
167 }
168
169 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 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 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 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 if self.is_indented_code_block(line, line_idx, &lines) {
244 result_lines.push(line.to_string());
245 continue;
246 }
247
248 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 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 #[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 fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
284 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 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 let Some(next_list) = &next_line_info.list_item {
297 if next_list.marker_column <= current_list.marker_column {
298 break; }
300 return true;
302 }
303
304 let line_content = lines.get(next_line_num - 1).unwrap_or(&"");
307 if !line_content.trim().is_empty() {
308 let bq_level = current_line_info
310 .blockquote
311 .as_ref()
312 .map(|bq| bq.nesting_level)
313 .unwrap_or(0);
314
315 let min_continuation_indent = if bq_level > 0 {
318 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 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; }
338
339 if actual_indent >= min_continuation_indent {
341 return true;
342 }
343 }
344
345 }
347 }
348
349 false
350 }
351
352 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 if after_marker.starts_with('\t') {
365 return None;
366 }
367
368 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 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 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 if after_marker.starts_with(' ') && !after_marker.starts_with(" ") && expected_spaces != 1 {
400 let content = &after_marker[1..]; 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 fn try_fix_list_marker_spacing_with_context(&self, line: &str, is_multi_line: bool) -> Option<String> {
412 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 for marker in &["*", "-", "+"] {
421 if let Some(after_marker) = trimmed.strip_prefix(marker) {
422 if after_marker.starts_with(*marker) {
424 break;
425 }
426
427 if *marker == "*" && after_marker.contains('*') {
429 break;
430 }
431
432 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; }
441 }
442
443 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 if after_dot.is_empty() {
451 return None;
452 }
453
454 if !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
456 let first_char = after_dot.chars().next().unwrap_or(' ');
457
458 if first_char.is_ascii_digit() {
460 return None;
461 }
462
463 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 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 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 let leading_spaces = remaining.len() - trimmed.len();
496 prefix.push_str(&remaining[..leading_spaces]);
497 prefix.push('>');
498 remaining = &trimmed[1..];
499
500 if remaining.starts_with(' ') {
502 prefix.push(' ');
503 remaining = &remaining[1..];
504 }
505 }
506
507 (prefix, remaining)
508 }
509
510 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 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 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 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 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 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
583 if line_num < lines.len() {
586 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
588 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
590 return true;
591 }
592 }
593 false
594 }
595
596 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
598 if ElementCache::calculate_indentation_width_default(line) < 4 {
600 return false;
601 }
602
603 if line_idx == 0 {
605 return false;
606 }
607
608 if self.has_blank_line_before_indented_block(line_idx, lines) {
610 return true;
611 }
612
613 false
614 }
615
616 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
618 let mut current_idx = line_idx;
620
621 while current_idx > 0 {
623 let current_line = lines[current_idx];
624 let prev_line = lines[current_idx - 1];
625
626 if ElementCache::calculate_indentation_width_default(current_line) < 4 {
628 break;
629 }
630
631 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 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 let rule = MD030ListMarkerSpace::default();
681
682 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 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 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 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 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 let rule = MD030ListMarkerSpace::default();
733
734 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 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 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 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 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 let rule = MD030ListMarkerSpace::default();
784
785 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 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 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 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}