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()
79 && !line_info.in_code_block
80 && !line_info.in_math_block
81 && !line_info.in_pymdown_block
82 && !line_info.in_mkdocs_html_markdown
83 {
84 let line_num_1based = line_num + 1;
85 processed_lines.insert(line_num_1based);
86
87 let line = lines[line_num];
88
89 if ElementCache::calculate_indentation_width_default(line) >= 4 {
91 continue;
92 }
93
94 if let Some(list_info) = &line_info.list_item {
95 let list_type = if list_info.is_ordered {
96 ListType::Ordered
97 } else {
98 ListType::Unordered
99 };
100
101 let marker_end = list_info.marker_column + list_info.marker.len();
103
104 if !Self::has_content_after_marker(line, marker_end) {
107 continue;
108 }
109
110 let actual_spaces = list_info.content_column.saturating_sub(marker_end);
111
112 let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, &lines);
114 let expected_spaces = self.get_expected_spaces(list_type, is_multi_line);
115
116 if actual_spaces != expected_spaces {
117 let whitespace_start_pos = marker_end;
118 let whitespace_len = actual_spaces;
119
120 let (start_line, start_col, end_line, end_col) =
121 calculate_match_range(line_num_1based, line, whitespace_start_pos, whitespace_len);
122
123 let correct_spaces = " ".repeat(expected_spaces);
124 let line_start_byte = ctx.line_offsets.get(line_num).copied().unwrap_or(0);
125 let whitespace_start_byte = line_start_byte + whitespace_start_pos;
126 let whitespace_end_byte = whitespace_start_byte + whitespace_len;
127
128 let fix = Some(crate::rule::Fix {
129 range: whitespace_start_byte..whitespace_end_byte,
130 replacement: correct_spaces,
131 });
132
133 let message =
134 format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})");
135
136 warnings.push(LintWarning {
137 rule_name: Some(self.name().to_string()),
138 severity: Severity::Warning,
139 line: start_line,
140 column: start_col,
141 end_line,
142 end_column: end_col,
143 message,
144 fix,
145 });
146 }
147 }
148 }
149 }
150
151 for (line_idx, line) in lines.iter().enumerate() {
154 let line_num = line_idx + 1;
155
156 if processed_lines.contains(&line_num) {
158 continue;
159 }
160 if let Some(line_info) = ctx.lines.get(line_idx)
161 && (line_info.in_code_block
162 || line_info.in_front_matter
163 || line_info.in_html_comment
164 || line_info.in_math_block
165 || line_info.in_pymdown_block
166 || line_info.in_mkdocs_html_markdown)
167 {
168 continue;
169 }
170
171 if self.is_indented_code_block(line, line_idx, &lines) {
173 continue;
174 }
175
176 if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, &lines) {
178 warnings.push(warning);
179 }
180 }
181
182 Ok(warnings)
183 }
184
185 fn category(&self) -> RuleCategory {
186 RuleCategory::List
187 }
188
189 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
190 if ctx.content.is_empty() {
191 return true;
192 }
193
194 let bytes = ctx.content.as_bytes();
196 !bytes.contains(&b'*')
197 && !bytes.contains(&b'-')
198 && !bytes.contains(&b'+')
199 && !bytes.iter().any(|&b| b.is_ascii_digit())
200 }
201
202 fn as_any(&self) -> &dyn std::any::Any {
203 self
204 }
205
206 fn default_config_section(&self) -> Option<(String, toml::Value)> {
207 let default_config = MD030Config::default();
208 let json_value = serde_json::to_value(&default_config).ok()?;
209 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
210
211 if let toml::Value::Table(table) = toml_value {
212 if !table.is_empty() {
213 Some((MD030Config::RULE_NAME.to_string(), toml::Value::Table(table)))
214 } else {
215 None
216 }
217 } else {
218 None
219 }
220 }
221
222 fn from_config(config: &crate::config::Config) -> Box<dyn Rule> {
223 let rule_config = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
224 Box::new(Self::from_config_struct(rule_config))
225 }
226
227 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
228 let content = ctx.content;
229
230 if self.should_skip(ctx) {
232 return Ok(content.to_string());
233 }
234
235 let lines: Vec<&str> = content.lines().collect();
236 let mut result_lines = Vec::with_capacity(lines.len());
237
238 for (line_idx, line) in lines.iter().enumerate() {
239 let line_num = line_idx + 1;
240
241 if let Some(line_info) = ctx.lines.get(line_idx)
243 && (line_info.in_code_block || line_info.in_front_matter || line_info.in_html_comment)
244 {
245 result_lines.push(line.to_string());
246 continue;
247 }
248
249 if self.is_indented_code_block(line, line_idx, &lines) {
251 result_lines.push(line.to_string());
252 continue;
253 }
254
255 let is_multi_line = self.is_multi_line_list_item(ctx, line_num, &lines);
260 if let Some(fixed_line) = self.try_fix_list_marker_spacing_with_context(line, is_multi_line) {
261 result_lines.push(fixed_line);
262 } else {
263 result_lines.push(line.to_string());
264 }
265 }
266
267 let result = result_lines.join("\n");
269 if content.ends_with('\n') && !result.ends_with('\n') {
270 Ok(result + "\n")
271 } else {
272 Ok(result)
273 }
274 }
275}
276
277impl MD030ListMarkerSpace {
278 #[inline]
282 fn has_content_after_marker(line: &str, marker_end: usize) -> bool {
283 if marker_end >= line.len() {
284 return false;
285 }
286 !line[marker_end..].trim().is_empty()
287 }
288
289 fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
291 let current_line_info = match ctx.line_info(line_num) {
293 Some(info) if info.list_item.is_some() => info,
294 _ => return false,
295 };
296
297 let current_list = current_line_info.list_item.as_ref().unwrap();
298
299 for next_line_num in (line_num + 1)..=lines.len() {
301 if let Some(next_line_info) = ctx.line_info(next_line_num) {
302 if let Some(next_list) = &next_line_info.list_item {
304 if next_list.marker_column <= current_list.marker_column {
305 break; }
307 return true;
309 }
310
311 let line_content = lines.get(next_line_num - 1).unwrap_or(&"");
314 if !line_content.trim().is_empty() {
315 let bq_level = current_line_info
317 .blockquote
318 .as_ref()
319 .map(|bq| bq.nesting_level)
320 .unwrap_or(0);
321
322 let min_continuation_indent = if bq_level > 0 {
325 current_list.content_column.saturating_sub(
328 current_line_info
329 .blockquote
330 .as_ref()
331 .map(|bq| bq.prefix.len())
332 .unwrap_or(0),
333 )
334 } else {
335 current_list.content_column
336 };
337
338 let raw_indent = line_content.len() - line_content.trim_start().len();
340 let actual_indent = effective_indent_in_blockquote(line_content, bq_level, raw_indent);
341
342 if actual_indent < min_continuation_indent {
343 break; }
345
346 if actual_indent >= min_continuation_indent {
348 return true;
349 }
350 }
351
352 }
354 }
355
356 false
357 }
358
359 fn fix_marker_spacing(
361 &self,
362 marker: &str,
363 after_marker: &str,
364 indent: &str,
365 is_multi_line: bool,
366 is_ordered: bool,
367 ) -> Option<String> {
368 if after_marker.starts_with('\t') {
372 return None;
373 }
374
375 let expected_spaces = if is_ordered {
377 if is_multi_line {
378 self.config.ol_multi.get()
379 } else {
380 self.config.ol_single.get()
381 }
382 } else if is_multi_line {
383 self.config.ul_multi.get()
384 } else {
385 self.config.ul_single.get()
386 };
387
388 if !after_marker.is_empty() && !after_marker.starts_with(' ') {
391 let spaces = " ".repeat(expected_spaces);
392 return Some(format!("{indent}{marker}{spaces}{after_marker}"));
393 }
394
395 if after_marker.starts_with(" ") {
397 let content = after_marker.trim_start_matches(' ');
398 if !content.is_empty() {
399 let spaces = " ".repeat(expected_spaces);
400 return Some(format!("{indent}{marker}{spaces}{content}"));
401 }
402 }
403
404 if after_marker.starts_with(' ') && !after_marker.starts_with(" ") && expected_spaces != 1 {
407 let content = &after_marker[1..]; if !content.is_empty() {
409 let spaces = " ".repeat(expected_spaces);
410 return Some(format!("{indent}{marker}{spaces}{content}"));
411 }
412 }
413
414 None
415 }
416
417 fn try_fix_list_marker_spacing_with_context(&self, line: &str, is_multi_line: bool) -> Option<String> {
419 let (blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
421
422 let trimmed = content.trim_start();
423 let indent = &content[..content.len() - trimmed.len()];
424
425 for marker in &["*", "-", "+"] {
428 if let Some(after_marker) = trimmed.strip_prefix(marker) {
429 if after_marker.starts_with(*marker) {
431 break;
432 }
433
434 if *marker == "*" && after_marker.contains('*') {
436 break;
437 }
438
439 if after_marker.starts_with(' ')
442 && let Some(fixed) = self.fix_marker_spacing(marker, after_marker, indent, is_multi_line, false)
443 {
444 return Some(format!("{blockquote_prefix}{fixed}"));
445 }
446 break; }
448 }
449
450 if let Some(dot_pos) = trimmed.find('.') {
452 let before_dot = &trimmed[..dot_pos];
453 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
454 let after_dot = &trimmed[dot_pos + 1..];
455
456 if after_dot.is_empty() {
458 return None;
459 }
460
461 if !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
463 let first_char = after_dot.chars().next().unwrap_or(' ');
464
465 if first_char.is_ascii_digit() {
467 return None;
468 }
469
470 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
474
475 if !is_clear_intent {
476 return None;
477 }
478 }
479 let marker = format!("{before_dot}.");
482 if let Some(fixed) = self.fix_marker_spacing(&marker, after_dot, indent, is_multi_line, true) {
483 return Some(format!("{blockquote_prefix}{fixed}"));
484 }
485 }
486 }
487
488 None
489 }
490
491 fn strip_blockquote_prefix(line: &str) -> (String, &str) {
493 let mut prefix = String::new();
494 let mut remaining = line;
495
496 loop {
497 let trimmed = remaining.trim_start();
498 if !trimmed.starts_with('>') {
499 break;
500 }
501 let leading_spaces = remaining.len() - trimmed.len();
503 prefix.push_str(&remaining[..leading_spaces]);
504 prefix.push('>');
505 remaining = &trimmed[1..];
506
507 if remaining.starts_with(' ') {
509 prefix.push(' ');
510 remaining = &remaining[1..];
511 }
512 }
513
514 (prefix, remaining)
515 }
516
517 fn check_unrecognized_list_marker(
520 &self,
521 ctx: &crate::lint_context::LintContext,
522 line: &str,
523 line_num: usize,
524 lines: &[&str],
525 ) -> Option<LintWarning> {
526 let (_blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
528
529 let trimmed = content.trim_start();
530 let indent_len = content.len() - trimmed.len();
531
532 if let Some(dot_pos) = trimmed.find('.') {
539 let before_dot = &trimmed[..dot_pos];
540 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
541 let after_dot = &trimmed[dot_pos + 1..];
542 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
544 let first_char = after_dot.chars().next().unwrap_or(' ');
545
546 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
551
552 if is_clear_intent {
553 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
554 let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
555
556 let marker = format!("{before_dot}.");
557 let marker_pos = indent_len;
558 let marker_end = marker_pos + marker.len();
559
560 let (start_line, start_col, end_line, end_col) =
561 calculate_match_range(line_num, line, marker_end, 0);
562
563 let correct_spaces = " ".repeat(expected_spaces);
564 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
565 let fix_position = line_start_byte + marker_end;
566
567 return Some(LintWarning {
568 rule_name: Some("MD030".to_string()),
569 severity: Severity::Warning,
570 line: start_line,
571 column: start_col,
572 end_line,
573 end_column: end_col,
574 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
575 fix: Some(crate::rule::Fix {
576 range: fix_position..fix_position,
577 replacement: correct_spaces,
578 }),
579 });
580 }
581 }
582 }
583 }
584
585 None
586 }
587
588 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
590 if line_num < lines.len() {
593 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
595 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
597 return true;
598 }
599 }
600 false
601 }
602
603 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
605 if ElementCache::calculate_indentation_width_default(line) < 4 {
607 return false;
608 }
609
610 if line_idx == 0 {
612 return false;
613 }
614
615 if self.has_blank_line_before_indented_block(line_idx, lines) {
617 return true;
618 }
619
620 false
621 }
622
623 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
625 let mut current_idx = line_idx;
627
628 while current_idx > 0 {
630 let current_line = lines[current_idx];
631 let prev_line = lines[current_idx - 1];
632
633 if ElementCache::calculate_indentation_width_default(current_line) < 4 {
635 break;
636 }
637
638 if ElementCache::calculate_indentation_width_default(prev_line) < 4 {
640 return prev_line.trim().is_empty();
641 }
642
643 current_idx -= 1;
644 }
645
646 false
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use crate::lint_context::LintContext;
654
655 #[test]
656 fn test_basic_functionality() {
657 let rule = MD030ListMarkerSpace::default();
658 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660 let result = rule.check(&ctx).unwrap();
661 assert!(
662 result.is_empty(),
663 "Correctly spaced list markers should not generate warnings"
664 );
665 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667 let result = rule.check(&ctx).unwrap();
668 assert_eq!(
670 result.len(),
671 2,
672 "Should flag lines with too many spaces after list marker"
673 );
674 for warning in result {
675 assert!(
676 warning.message.starts_with("Spaces after list markers (Expected:")
677 && warning.message.contains("Actual:"),
678 "Warning message should include expected and actual values, got: '{}'",
679 warning.message
680 );
681 }
682 }
683
684 #[test]
685 fn test_nested_emphasis_not_flagged_issue_278() {
686 let rule = MD030ListMarkerSpace::default();
688
689 let content = "*This text is **very** important*";
691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let result = rule.check(&ctx).unwrap();
693 assert!(
694 result.is_empty(),
695 "Nested emphasis should not trigger MD030, got: {result:?}"
696 );
697
698 let content2 = "*Hello World*";
700 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
701 let result2 = rule.check(&ctx2).unwrap();
702 assert!(
703 result2.is_empty(),
704 "Simple emphasis should not trigger MD030, got: {result2:?}"
705 );
706
707 let content3 = "**bold text**";
709 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
710 let result3 = rule.check(&ctx3).unwrap();
711 assert!(
712 result3.is_empty(),
713 "Bold text should not trigger MD030, got: {result3:?}"
714 );
715
716 let content4 = "***bold and italic***";
718 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
719 let result4 = rule.check(&ctx4).unwrap();
720 assert!(
721 result4.is_empty(),
722 "Bold+italic should not trigger MD030, got: {result4:?}"
723 );
724
725 let content5 = "* Item with space";
727 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
728 let result5 = rule.check(&ctx5).unwrap();
729 assert!(
730 result5.is_empty(),
731 "Properly spaced list item should not trigger MD030, got: {result5:?}"
732 );
733 }
734
735 #[test]
736 fn test_empty_marker_line_not_flagged_issue_288() {
737 let rule = MD030ListMarkerSpace::default();
740
741 let content = "-\n ```python\n print(\"code\")\n ```\n";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let result = rule.check(&ctx).unwrap();
745 assert!(
746 result.is_empty(),
747 "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
748 );
749
750 let content = "1.\n ```python\n print(\"code\")\n ```\n";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753 let result = rule.check(&ctx).unwrap();
754 assert!(
755 result.is_empty(),
756 "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
757 );
758
759 let content = "-\n This is a paragraph continuation\n of the list item.\n";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763 assert!(
764 result.is_empty(),
765 "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
766 );
767
768 let content = "- Parent item\n -\n Nested content\n";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
772 assert!(
773 result.is_empty(),
774 "Nested empty marker line should not trigger MD030, got: {result:?}"
775 );
776
777 let content = "- Item with content\n-\n Code block\n- Another item\n";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 let result = rule.check(&ctx).unwrap();
781 assert!(
782 result.is_empty(),
783 "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
784 );
785 }
786
787 #[test]
788 fn test_marker_with_content_still_flagged_issue_288() {
789 let rule = MD030ListMarkerSpace::default();
791
792 let content = "- Two spaces before content\n";
794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 let result = rule.check(&ctx).unwrap();
796 assert_eq!(
797 result.len(),
798 1,
799 "Two spaces after unordered marker should still trigger MD030"
800 );
801
802 let content = "1. Two spaces\n";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let result = rule.check(&ctx).unwrap();
806 assert_eq!(
807 result.len(),
808 1,
809 "Two spaces after ordered marker should still trigger MD030"
810 );
811
812 let content = "- Normal item\n";
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let result = rule.check(&ctx).unwrap();
816 assert!(
817 result.is_empty(),
818 "Normal list item should not trigger MD030, got: {result:?}"
819 );
820 }
821
822 #[test]
823 fn test_has_content_after_marker() {
824 assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
826 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
827 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
828 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
829 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
830 assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
831 assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
832 assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
833 }
834}