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);
527
528 let trimmed = content.trim_start();
529 let indent_len = content.len() - trimmed.len();
530
531 if let Some(dot_pos) = trimmed.find('.') {
538 let before_dot = &trimmed[..dot_pos];
539 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
540 let after_dot = &trimmed[dot_pos + 1..];
541 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
543 let first_char = after_dot.chars().next().unwrap_or(' ');
544
545 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
550
551 if is_clear_intent {
552 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
553 let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
554
555 let marker = format!("{before_dot}.");
556 let marker_pos = indent_len;
557 let marker_end = marker_pos + marker.len();
558
559 let (start_line, start_col, end_line, end_col) =
560 calculate_match_range(line_num, line, marker_end, 0);
561
562 let correct_spaces = " ".repeat(expected_spaces);
563 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
564 let fix_position = line_start_byte + marker_end;
565
566 return Some(LintWarning {
567 rule_name: Some("MD030".to_string()),
568 severity: Severity::Warning,
569 line: start_line,
570 column: start_col,
571 end_line,
572 end_column: end_col,
573 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
574 fix: Some(crate::rule::Fix {
575 range: fix_position..fix_position,
576 replacement: correct_spaces,
577 }),
578 });
579 }
580 }
581 }
582 }
583
584 None
585 }
586
587 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
589 if line_num < lines.len() {
592 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
594 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
596 return true;
597 }
598 }
599 false
600 }
601
602 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
604 if ElementCache::calculate_indentation_width_default(line) < 4 {
606 return false;
607 }
608
609 if line_idx == 0 {
611 return false;
612 }
613
614 if self.has_blank_line_before_indented_block(line_idx, lines) {
616 return true;
617 }
618
619 false
620 }
621
622 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
624 let mut current_idx = line_idx;
626
627 while current_idx > 0 {
629 let current_line = lines[current_idx];
630 let prev_line = lines[current_idx - 1];
631
632 if ElementCache::calculate_indentation_width_default(current_line) < 4 {
634 break;
635 }
636
637 if ElementCache::calculate_indentation_width_default(prev_line) < 4 {
639 return prev_line.trim().is_empty();
640 }
641
642 current_idx -= 1;
643 }
644
645 false
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use crate::lint_context::LintContext;
653
654 #[test]
655 fn test_basic_functionality() {
656 let rule = MD030ListMarkerSpace::default();
657 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660 assert!(
661 result.is_empty(),
662 "Correctly spaced list markers should not generate warnings"
663 );
664 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let result = rule.check(&ctx).unwrap();
667 assert_eq!(
669 result.len(),
670 2,
671 "Should flag lines with too many spaces after list marker"
672 );
673 for warning in result {
674 assert!(
675 warning.message.starts_with("Spaces after list markers (Expected:")
676 && warning.message.contains("Actual:"),
677 "Warning message should include expected and actual values, got: '{}'",
678 warning.message
679 );
680 }
681 }
682
683 #[test]
684 fn test_nested_emphasis_not_flagged_issue_278() {
685 let rule = MD030ListMarkerSpace::default();
687
688 let content = "*This text is **very** important*";
690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
691 let result = rule.check(&ctx).unwrap();
692 assert!(
693 result.is_empty(),
694 "Nested emphasis should not trigger MD030, got: {result:?}"
695 );
696
697 let content2 = "*Hello World*";
699 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
700 let result2 = rule.check(&ctx2).unwrap();
701 assert!(
702 result2.is_empty(),
703 "Simple emphasis should not trigger MD030, got: {result2:?}"
704 );
705
706 let content3 = "**bold text**";
708 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
709 let result3 = rule.check(&ctx3).unwrap();
710 assert!(
711 result3.is_empty(),
712 "Bold text should not trigger MD030, got: {result3:?}"
713 );
714
715 let content4 = "***bold and italic***";
717 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
718 let result4 = rule.check(&ctx4).unwrap();
719 assert!(
720 result4.is_empty(),
721 "Bold+italic should not trigger MD030, got: {result4:?}"
722 );
723
724 let content5 = "* Item with space";
726 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
727 let result5 = rule.check(&ctx5).unwrap();
728 assert!(
729 result5.is_empty(),
730 "Properly spaced list item should not trigger MD030, got: {result5:?}"
731 );
732 }
733
734 #[test]
735 fn test_empty_marker_line_not_flagged_issue_288() {
736 let rule = MD030ListMarkerSpace::default();
739
740 let content = "-\n ```python\n print(\"code\")\n ```\n";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let result = rule.check(&ctx).unwrap();
744 assert!(
745 result.is_empty(),
746 "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
747 );
748
749 let content = "1.\n ```python\n print(\"code\")\n ```\n";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752 let result = rule.check(&ctx).unwrap();
753 assert!(
754 result.is_empty(),
755 "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
756 );
757
758 let content = "-\n This is a paragraph continuation\n of the list item.\n";
760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
761 let result = rule.check(&ctx).unwrap();
762 assert!(
763 result.is_empty(),
764 "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
765 );
766
767 let content = "- Parent item\n -\n Nested content\n";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert!(
772 result.is_empty(),
773 "Nested empty marker line should not trigger MD030, got: {result:?}"
774 );
775
776 let content = "- Item with content\n-\n Code block\n- Another item\n";
778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779 let result = rule.check(&ctx).unwrap();
780 assert!(
781 result.is_empty(),
782 "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
783 );
784 }
785
786 #[test]
787 fn test_marker_with_content_still_flagged_issue_288() {
788 let rule = MD030ListMarkerSpace::default();
790
791 let content = "- Two spaces before content\n";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794 let result = rule.check(&ctx).unwrap();
795 assert_eq!(
796 result.len(),
797 1,
798 "Two spaces after unordered marker should still trigger MD030"
799 );
800
801 let content = "1. Two spaces\n";
803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804 let result = rule.check(&ctx).unwrap();
805 assert_eq!(
806 result.len(),
807 1,
808 "Two spaces after ordered marker should still trigger MD030"
809 );
810
811 let content = "- Normal item\n";
813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814 let result = rule.check(&ctx).unwrap();
815 assert!(
816 result.is_empty(),
817 "Normal list item should not trigger MD030, got: {result:?}"
818 );
819 }
820
821 #[test]
822 fn test_has_content_after_marker() {
823 assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
825 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
826 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
827 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
828 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
829 assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
830 assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
831 assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
832 }
833}