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 = ctx.raw_lines();
70
71 let mut processed_lines = std::collections::HashSet::new();
73
74 for (line_num, line_info) in ctx.lines.iter().enumerate() {
76 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 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 let marker_end = list_info.marker_column + list_info.marker.len();
102
103 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 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 for (line_idx, line) in lines.iter().enumerate() {
153 let line_num = line_idx + 1;
154
155 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 if self.is_indented_code_block(line, line_idx, lines) {
172 continue;
173 }
174
175 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 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 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 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 if self.is_indented_code_block(line, line_idx, lines) {
250 result_lines.push(line.to_string());
251 continue;
252 }
253
254 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 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 #[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 fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
290 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 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 let Some(next_list) = &next_line_info.list_item {
303 if next_list.marker_column <= current_list.marker_column {
304 break; }
306 return true;
308 }
309
310 let line_content = lines.get(next_line_num - 1).unwrap_or(&"");
313 if !line_content.trim().is_empty() {
314 let bq_level = current_line_info
316 .blockquote
317 .as_ref()
318 .map(|bq| bq.nesting_level)
319 .unwrap_or(0);
320
321 let min_continuation_indent = if bq_level > 0 {
324 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 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; }
344
345 if actual_indent >= min_continuation_indent {
347 return true;
348 }
349 }
350
351 }
353 }
354
355 false
356 }
357
358 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 if after_marker.starts_with('\t') {
371 return None;
372 }
373
374 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 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 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 if after_marker.starts_with(' ') && !after_marker.starts_with(" ") && expected_spaces != 1 {
406 let content = &after_marker[1..]; 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 fn try_fix_list_marker_spacing_with_context(&self, line: &str, is_multi_line: bool) -> Option<String> {
418 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 for marker in &["*", "-", "+"] {
427 if let Some(after_marker) = trimmed.strip_prefix(marker) {
428 if after_marker.starts_with(*marker) {
430 break;
431 }
432
433 if *marker == "*" && after_marker.contains('*') {
435 break;
436 }
437
438 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; }
447 }
448
449 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 if after_dot.is_empty() {
457 return None;
458 }
459
460 if !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
462 let first_char = after_dot.chars().next().unwrap_or(' ');
463
464 if first_char.is_ascii_digit() {
466 return None;
467 }
468
469 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 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 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 let leading_spaces = remaining.len() - trimmed.len();
502 prefix.push_str(&remaining[..leading_spaces]);
503 prefix.push('>');
504 remaining = &trimmed[1..];
505
506 if remaining.starts_with(' ') {
508 prefix.push(' ');
509 remaining = &remaining[1..];
510 }
511 }
512
513 (prefix, remaining)
514 }
515
516 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 let (_blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
528 let bq_prefix_len = line.len() - content.len();
529
530 let trimmed = content.trim_start();
531 let indent_len = content.len() - trimmed.len();
532
533 if let Some(dot_pos) = trimmed.find('.') {
540 let before_dot = &trimmed[..dot_pos];
541 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
542 let after_dot = &trimmed[dot_pos + 1..];
543 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
545 let first_char = after_dot.chars().next().unwrap_or(' ');
546
547 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
552
553 if is_clear_intent {
554 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
555 let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
556
557 let marker = format!("{before_dot}.");
558 let marker_pos = indent_len;
559 let marker_end = marker_pos + marker.len();
560 let offset_in_line = bq_prefix_len + marker_end;
562
563 let (start_line, start_col, end_line, end_col) =
564 calculate_match_range(line_num, line, offset_in_line, 0);
565
566 let correct_spaces = " ".repeat(expected_spaces);
567 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
568 let fix_position = line_start_byte + offset_in_line;
569
570 return Some(LintWarning {
571 rule_name: Some("MD030".to_string()),
572 severity: Severity::Warning,
573 line: start_line,
574 column: start_col,
575 end_line,
576 end_column: end_col,
577 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
578 fix: Some(crate::rule::Fix {
579 range: fix_position..fix_position,
580 replacement: correct_spaces,
581 }),
582 });
583 }
584 }
585 }
586 }
587
588 None
589 }
590
591 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
593 if line_num < lines.len() {
596 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
598 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
600 return true;
601 }
602 }
603 false
604 }
605
606 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
608 if ElementCache::calculate_indentation_width_default(line) < 4 {
610 return false;
611 }
612
613 if line_idx == 0 {
615 return false;
616 }
617
618 if self.has_blank_line_before_indented_block(line_idx, lines) {
620 return true;
621 }
622
623 false
624 }
625
626 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
628 let mut current_idx = line_idx;
630
631 while current_idx > 0 {
633 let current_line = lines[current_idx];
634 let prev_line = lines[current_idx - 1];
635
636 if ElementCache::calculate_indentation_width_default(current_line) < 4 {
638 break;
639 }
640
641 if ElementCache::calculate_indentation_width_default(prev_line) < 4 {
643 return prev_line.trim().is_empty();
644 }
645
646 current_idx -= 1;
647 }
648
649 false
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use crate::lint_context::LintContext;
657
658 #[test]
659 fn test_basic_functionality() {
660 let rule = MD030ListMarkerSpace::default();
661 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663 let result = rule.check(&ctx).unwrap();
664 assert!(
665 result.is_empty(),
666 "Correctly spaced list markers should not generate warnings"
667 );
668 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670 let result = rule.check(&ctx).unwrap();
671 assert_eq!(
673 result.len(),
674 2,
675 "Should flag lines with too many spaces after list marker"
676 );
677 for warning in result {
678 assert!(
679 warning.message.starts_with("Spaces after list markers (Expected:")
680 && warning.message.contains("Actual:"),
681 "Warning message should include expected and actual values, got: '{}'",
682 warning.message
683 );
684 }
685 }
686
687 #[test]
688 fn test_nested_emphasis_not_flagged_issue_278() {
689 let rule = MD030ListMarkerSpace::default();
691
692 let content = "*This text is **very** important*";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx).unwrap();
696 assert!(
697 result.is_empty(),
698 "Nested emphasis should not trigger MD030, got: {result:?}"
699 );
700
701 let content2 = "*Hello World*";
703 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
704 let result2 = rule.check(&ctx2).unwrap();
705 assert!(
706 result2.is_empty(),
707 "Simple emphasis should not trigger MD030, got: {result2:?}"
708 );
709
710 let content3 = "**bold text**";
712 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
713 let result3 = rule.check(&ctx3).unwrap();
714 assert!(
715 result3.is_empty(),
716 "Bold text should not trigger MD030, got: {result3:?}"
717 );
718
719 let content4 = "***bold and italic***";
721 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
722 let result4 = rule.check(&ctx4).unwrap();
723 assert!(
724 result4.is_empty(),
725 "Bold+italic should not trigger MD030, got: {result4:?}"
726 );
727
728 let content5 = "* Item with space";
730 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
731 let result5 = rule.check(&ctx5).unwrap();
732 assert!(
733 result5.is_empty(),
734 "Properly spaced list item should not trigger MD030, got: {result5:?}"
735 );
736 }
737
738 #[test]
739 fn test_empty_marker_line_not_flagged_issue_288() {
740 let rule = MD030ListMarkerSpace::default();
743
744 let content = "-\n ```python\n print(\"code\")\n ```\n";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747 let result = rule.check(&ctx).unwrap();
748 assert!(
749 result.is_empty(),
750 "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
751 );
752
753 let content = "1.\n ```python\n print(\"code\")\n ```\n";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let result = rule.check(&ctx).unwrap();
757 assert!(
758 result.is_empty(),
759 "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
760 );
761
762 let content = "-\n This is a paragraph continuation\n of the list item.\n";
764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
765 let result = rule.check(&ctx).unwrap();
766 assert!(
767 result.is_empty(),
768 "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
769 );
770
771 let content = "- Parent item\n -\n Nested content\n";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let result = rule.check(&ctx).unwrap();
775 assert!(
776 result.is_empty(),
777 "Nested empty marker line should not trigger MD030, got: {result:?}"
778 );
779
780 let content = "- Item with content\n-\n Code block\n- Another item\n";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let result = rule.check(&ctx).unwrap();
784 assert!(
785 result.is_empty(),
786 "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
787 );
788 }
789
790 #[test]
791 fn test_marker_with_content_still_flagged_issue_288() {
792 let rule = MD030ListMarkerSpace::default();
794
795 let content = "- Two spaces before content\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 unordered marker should still trigger MD030"
803 );
804
805 let content = "1. Two spaces\n";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let result = rule.check(&ctx).unwrap();
809 assert_eq!(
810 result.len(),
811 1,
812 "Two spaces after ordered marker should still trigger MD030"
813 );
814
815 let content = "- Normal item\n";
817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
818 let result = rule.check(&ctx).unwrap();
819 assert!(
820 result.is_empty(),
821 "Normal list item should not trigger MD030, got: {result:?}"
822 );
823 }
824
825 #[test]
826 fn test_has_content_after_marker() {
827 assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
829 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
830 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
831 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
832 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
833 assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
834 assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
835 assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
836 }
837}