1use crate::rule::{LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::rule_config_serde::RuleConfig;
8use crate::rules::list_utils::ListType;
9use crate::utils::element_cache::ElementCache;
10use crate::utils::range_utils::calculate_match_range;
11use toml;
12
13mod md030_config;
14use md030_config::MD030Config;
15
16#[derive(Clone, Default)]
17pub struct MD030ListMarkerSpace {
18 config: MD030Config,
19}
20
21impl MD030ListMarkerSpace {
22 pub fn new(ul_single: usize, ul_multi: usize, ol_single: usize, ol_multi: usize) -> Self {
23 Self {
24 config: MD030Config {
25 ul_single: crate::types::PositiveUsize::new(ul_single)
26 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
27 ul_multi: crate::types::PositiveUsize::new(ul_multi)
28 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
29 ol_single: crate::types::PositiveUsize::new(ol_single)
30 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
31 ol_multi: crate::types::PositiveUsize::new(ol_multi)
32 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
33 },
34 }
35 }
36
37 pub fn from_config_struct(config: MD030Config) -> Self {
38 Self { config }
39 }
40
41 pub fn get_expected_spaces(&self, list_type: ListType, is_multi: bool) -> usize {
42 match (list_type, is_multi) {
43 (ListType::Unordered, false) => self.config.ul_single.get(),
44 (ListType::Unordered, true) => self.config.ul_multi.get(),
45 (ListType::Ordered, false) => self.config.ol_single.get(),
46 (ListType::Ordered, true) => self.config.ol_multi.get(),
47 }
48 }
49}
50
51impl Rule for MD030ListMarkerSpace {
52 fn name(&self) -> &'static str {
53 "MD030"
54 }
55
56 fn description(&self) -> &'static str {
57 "Spaces after list markers should be consistent"
58 }
59
60 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
61 let mut warnings = Vec::new();
62
63 if self.should_skip(ctx) {
65 return Ok(warnings);
66 }
67
68 let lines: Vec<&str> = ctx.content.lines().collect();
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() && !line_info.in_code_block && !line_info.in_math_block {
78 let line_num_1based = line_num + 1;
79 processed_lines.insert(line_num_1based);
80
81 let line = lines[line_num];
82
83 if ElementCache::calculate_indentation_width_default(line) >= 4 {
85 continue;
86 }
87
88 if let Some(list_info) = &line_info.list_item {
89 let list_type = if list_info.is_ordered {
90 ListType::Ordered
91 } else {
92 ListType::Unordered
93 };
94
95 let marker_end = list_info.marker_column + list_info.marker.len();
97
98 if !Self::has_content_after_marker(line, marker_end) {
101 continue;
102 }
103
104 let actual_spaces = list_info.content_column.saturating_sub(marker_end);
105
106 let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, &lines);
108 let expected_spaces = self.get_expected_spaces(list_type, is_multi_line);
109
110 if actual_spaces != expected_spaces {
111 let whitespace_start_pos = marker_end;
112 let whitespace_len = actual_spaces;
113
114 let (start_line, start_col, end_line, end_col) =
115 calculate_match_range(line_num_1based, line, whitespace_start_pos, whitespace_len);
116
117 let correct_spaces = " ".repeat(expected_spaces);
118 let line_start_byte = ctx.line_offsets.get(line_num).copied().unwrap_or(0);
119 let whitespace_start_byte = line_start_byte + whitespace_start_pos;
120 let whitespace_end_byte = whitespace_start_byte + whitespace_len;
121
122 let fix = Some(crate::rule::Fix {
123 range: whitespace_start_byte..whitespace_end_byte,
124 replacement: correct_spaces,
125 });
126
127 let message =
128 format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})");
129
130 warnings.push(LintWarning {
131 rule_name: Some(self.name().to_string()),
132 severity: Severity::Warning,
133 line: start_line,
134 column: start_col,
135 end_line,
136 end_column: end_col,
137 message,
138 fix,
139 });
140 }
141 }
142 }
143 }
144
145 for (line_idx, line) in lines.iter().enumerate() {
148 let line_num = line_idx + 1;
149
150 if processed_lines.contains(&line_num) {
152 continue;
153 }
154 if let Some(line_info) = ctx.lines.get(line_idx)
155 && (line_info.in_code_block
156 || line_info.in_front_matter
157 || line_info.in_html_comment
158 || line_info.in_math_block)
159 {
160 continue;
161 }
162
163 if self.is_indented_code_block(line, line_idx, &lines) {
165 continue;
166 }
167
168 if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, &lines) {
170 warnings.push(warning);
171 }
172 }
173
174 Ok(warnings)
175 }
176
177 fn category(&self) -> RuleCategory {
178 RuleCategory::List
179 }
180
181 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
182 if ctx.content.is_empty() {
183 return true;
184 }
185
186 let bytes = ctx.content.as_bytes();
188 !bytes.contains(&b'*')
189 && !bytes.contains(&b'-')
190 && !bytes.contains(&b'+')
191 && !bytes.iter().any(|&b| b.is_ascii_digit())
192 }
193
194 fn as_any(&self) -> &dyn std::any::Any {
195 self
196 }
197
198 fn default_config_section(&self) -> Option<(String, toml::Value)> {
199 let default_config = MD030Config::default();
200 let json_value = serde_json::to_value(&default_config).ok()?;
201 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
202
203 if let toml::Value::Table(table) = toml_value {
204 if !table.is_empty() {
205 Some((MD030Config::RULE_NAME.to_string(), toml::Value::Table(table)))
206 } else {
207 None
208 }
209 } else {
210 None
211 }
212 }
213
214 fn from_config(config: &crate::config::Config) -> Box<dyn Rule> {
215 let rule_config = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
216 Box::new(Self::from_config_struct(rule_config))
217 }
218
219 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
220 let content = ctx.content;
221
222 if self.should_skip(ctx) {
224 return Ok(content.to_string());
225 }
226
227 let lines: Vec<&str> = content.lines().collect();
228 let mut result_lines = Vec::with_capacity(lines.len());
229
230 for (line_idx, line) in lines.iter().enumerate() {
231 let line_num = line_idx + 1;
232
233 if let Some(line_info) = ctx.lines.get(line_idx)
235 && (line_info.in_code_block || line_info.in_front_matter || line_info.in_html_comment)
236 {
237 result_lines.push(line.to_string());
238 continue;
239 }
240
241 if self.is_indented_code_block(line, line_idx, &lines) {
243 result_lines.push(line.to_string());
244 continue;
245 }
246
247 let is_multi_line = self.is_multi_line_list_item(ctx, line_num, &lines);
252 if let Some(fixed_line) = self.try_fix_list_marker_spacing_with_context(line, is_multi_line) {
253 result_lines.push(fixed_line);
254 } else {
255 result_lines.push(line.to_string());
256 }
257 }
258
259 let result = result_lines.join("\n");
261 if content.ends_with('\n') && !result.ends_with('\n') {
262 Ok(result + "\n")
263 } else {
264 Ok(result)
265 }
266 }
267}
268
269impl MD030ListMarkerSpace {
270 #[inline]
274 fn has_content_after_marker(line: &str, marker_end: usize) -> bool {
275 if marker_end >= line.len() {
276 return false;
277 }
278 !line[marker_end..].trim().is_empty()
279 }
280
281 fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
283 let current_line_info = match ctx.line_info(line_num) {
285 Some(info) if info.list_item.is_some() => info,
286 _ => return false,
287 };
288
289 let current_list = current_line_info.list_item.as_ref().unwrap();
290
291 for next_line_num in (line_num + 1)..=lines.len() {
293 if let Some(next_line_info) = ctx.line_info(next_line_num) {
294 if let Some(next_list) = &next_line_info.list_item {
296 if next_list.marker_column <= current_list.marker_column {
297 break; }
299 return true;
301 }
302
303 let line_content = lines.get(next_line_num - 1).unwrap_or(&"");
306 if !line_content.trim().is_empty() {
307 let expected_continuation_indent = current_list.content_column;
308 let actual_indent = line_content.len() - line_content.trim_start().len();
309
310 if actual_indent < expected_continuation_indent {
311 break; }
313
314 if actual_indent >= expected_continuation_indent {
316 return true;
317 }
318 }
319
320 }
322 }
323
324 false
325 }
326
327 fn fix_marker_spacing(
329 &self,
330 marker: &str,
331 after_marker: &str,
332 indent: &str,
333 is_multi_line: bool,
334 is_ordered: bool,
335 ) -> Option<String> {
336 if after_marker.starts_with('\t') {
340 return None;
341 }
342
343 let expected_spaces = if is_ordered {
345 if is_multi_line {
346 self.config.ol_multi.get()
347 } else {
348 self.config.ol_single.get()
349 }
350 } else if is_multi_line {
351 self.config.ul_multi.get()
352 } else {
353 self.config.ul_single.get()
354 };
355
356 if !after_marker.is_empty() && !after_marker.starts_with(' ') {
359 let spaces = " ".repeat(expected_spaces);
360 return Some(format!("{indent}{marker}{spaces}{after_marker}"));
361 }
362
363 if after_marker.starts_with(" ") {
365 let content = after_marker.trim_start_matches(' ');
366 if !content.is_empty() {
367 let spaces = " ".repeat(expected_spaces);
368 return Some(format!("{indent}{marker}{spaces}{content}"));
369 }
370 }
371
372 if after_marker.starts_with(' ') && !after_marker.starts_with(" ") && expected_spaces != 1 {
375 let content = &after_marker[1..]; if !content.is_empty() {
377 let spaces = " ".repeat(expected_spaces);
378 return Some(format!("{indent}{marker}{spaces}{content}"));
379 }
380 }
381
382 None
383 }
384
385 fn try_fix_list_marker_spacing_with_context(&self, line: &str, is_multi_line: bool) -> Option<String> {
387 let (blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
389
390 let trimmed = content.trim_start();
391 let indent = &content[..content.len() - trimmed.len()];
392
393 for marker in &["*", "-", "+"] {
396 if let Some(after_marker) = trimmed.strip_prefix(marker) {
397 if after_marker.starts_with(*marker) {
399 break;
400 }
401
402 if *marker == "*" && after_marker.contains('*') {
404 break;
405 }
406
407 if after_marker.starts_with(' ')
410 && let Some(fixed) = self.fix_marker_spacing(marker, after_marker, indent, is_multi_line, false)
411 {
412 return Some(format!("{blockquote_prefix}{fixed}"));
413 }
414 break; }
416 }
417
418 if let Some(dot_pos) = trimmed.find('.') {
420 let before_dot = &trimmed[..dot_pos];
421 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
422 let after_dot = &trimmed[dot_pos + 1..];
423
424 if after_dot.is_empty() {
426 return None;
427 }
428
429 if !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
431 let first_char = after_dot.chars().next().unwrap_or(' ');
432
433 if first_char.is_ascii_digit() {
435 return None;
436 }
437
438 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
442
443 if !is_clear_intent {
444 return None;
445 }
446 }
447 let marker = format!("{before_dot}.");
450 if let Some(fixed) = self.fix_marker_spacing(&marker, after_dot, indent, is_multi_line, true) {
451 return Some(format!("{blockquote_prefix}{fixed}"));
452 }
453 }
454 }
455
456 None
457 }
458
459 fn strip_blockquote_prefix(line: &str) -> (String, &str) {
461 let mut prefix = String::new();
462 let mut remaining = line;
463
464 loop {
465 let trimmed = remaining.trim_start();
466 if !trimmed.starts_with('>') {
467 break;
468 }
469 let leading_spaces = remaining.len() - trimmed.len();
471 prefix.push_str(&remaining[..leading_spaces]);
472 prefix.push('>');
473 remaining = &trimmed[1..];
474
475 if remaining.starts_with(' ') {
477 prefix.push(' ');
478 remaining = &remaining[1..];
479 }
480 }
481
482 (prefix, remaining)
483 }
484
485 fn check_unrecognized_list_marker(
488 &self,
489 ctx: &crate::lint_context::LintContext,
490 line: &str,
491 line_num: usize,
492 lines: &[&str],
493 ) -> Option<LintWarning> {
494 let (_blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
496
497 let trimmed = content.trim_start();
498 let indent_len = content.len() - trimmed.len();
499
500 if let Some(dot_pos) = trimmed.find('.') {
507 let before_dot = &trimmed[..dot_pos];
508 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
509 let after_dot = &trimmed[dot_pos + 1..];
510 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
512 let first_char = after_dot.chars().next().unwrap_or(' ');
513
514 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
519
520 if is_clear_intent {
521 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
522 let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
523
524 let marker = format!("{before_dot}.");
525 let marker_pos = indent_len;
526 let marker_end = marker_pos + marker.len();
527
528 let (start_line, start_col, end_line, end_col) =
529 calculate_match_range(line_num, line, marker_end, 0);
530
531 let correct_spaces = " ".repeat(expected_spaces);
532 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
533 let fix_position = line_start_byte + marker_end;
534
535 return Some(LintWarning {
536 rule_name: Some("MD030".to_string()),
537 severity: Severity::Warning,
538 line: start_line,
539 column: start_col,
540 end_line,
541 end_column: end_col,
542 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
543 fix: Some(crate::rule::Fix {
544 range: fix_position..fix_position,
545 replacement: correct_spaces,
546 }),
547 });
548 }
549 }
550 }
551 }
552
553 None
554 }
555
556 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
558 if line_num < lines.len() {
561 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
563 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
565 return true;
566 }
567 }
568 false
569 }
570
571 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
573 if ElementCache::calculate_indentation_width_default(line) < 4 {
575 return false;
576 }
577
578 if line_idx == 0 {
580 return false;
581 }
582
583 if self.has_blank_line_before_indented_block(line_idx, lines) {
585 return true;
586 }
587
588 false
589 }
590
591 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
593 let mut current_idx = line_idx;
595
596 while current_idx > 0 {
598 let current_line = lines[current_idx];
599 let prev_line = lines[current_idx - 1];
600
601 if ElementCache::calculate_indentation_width_default(current_line) < 4 {
603 break;
604 }
605
606 if ElementCache::calculate_indentation_width_default(prev_line) < 4 {
608 return prev_line.trim().is_empty();
609 }
610
611 current_idx -= 1;
612 }
613
614 false
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::lint_context::LintContext;
622
623 #[test]
624 fn test_basic_functionality() {
625 let rule = MD030ListMarkerSpace::default();
626 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629 assert!(
630 result.is_empty(),
631 "Correctly spaced list markers should not generate warnings"
632 );
633 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635 let result = rule.check(&ctx).unwrap();
636 assert_eq!(
638 result.len(),
639 2,
640 "Should flag lines with too many spaces after list marker"
641 );
642 for warning in result {
643 assert!(
644 warning.message.starts_with("Spaces after list markers (Expected:")
645 && warning.message.contains("Actual:"),
646 "Warning message should include expected and actual values, got: '{}'",
647 warning.message
648 );
649 }
650 }
651
652 #[test]
653 fn test_nested_emphasis_not_flagged_issue_278() {
654 let rule = MD030ListMarkerSpace::default();
656
657 let content = "*This text is **very** important*";
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 "Nested emphasis should not trigger MD030, got: {result:?}"
664 );
665
666 let content2 = "*Hello World*";
668 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
669 let result2 = rule.check(&ctx2).unwrap();
670 assert!(
671 result2.is_empty(),
672 "Simple emphasis should not trigger MD030, got: {result2:?}"
673 );
674
675 let content3 = "**bold text**";
677 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
678 let result3 = rule.check(&ctx3).unwrap();
679 assert!(
680 result3.is_empty(),
681 "Bold text should not trigger MD030, got: {result3:?}"
682 );
683
684 let content4 = "***bold and italic***";
686 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
687 let result4 = rule.check(&ctx4).unwrap();
688 assert!(
689 result4.is_empty(),
690 "Bold+italic should not trigger MD030, got: {result4:?}"
691 );
692
693 let content5 = "* Item with space";
695 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
696 let result5 = rule.check(&ctx5).unwrap();
697 assert!(
698 result5.is_empty(),
699 "Properly spaced list item should not trigger MD030, got: {result5:?}"
700 );
701 }
702
703 #[test]
704 fn test_empty_marker_line_not_flagged_issue_288() {
705 let rule = MD030ListMarkerSpace::default();
708
709 let content = "-\n ```python\n print(\"code\")\n ```\n";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713 assert!(
714 result.is_empty(),
715 "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
716 );
717
718 let content = "1.\n ```python\n print(\"code\")\n ```\n";
720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
721 let result = rule.check(&ctx).unwrap();
722 assert!(
723 result.is_empty(),
724 "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
725 );
726
727 let content = "-\n This is a paragraph continuation\n of the list item.\n";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let result = rule.check(&ctx).unwrap();
731 assert!(
732 result.is_empty(),
733 "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
734 );
735
736 let content = "- Parent item\n -\n Nested content\n";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx).unwrap();
740 assert!(
741 result.is_empty(),
742 "Nested empty marker line should not trigger MD030, got: {result:?}"
743 );
744
745 let content = "- Item with content\n-\n Code block\n- Another item\n";
747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
748 let result = rule.check(&ctx).unwrap();
749 assert!(
750 result.is_empty(),
751 "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
752 );
753 }
754
755 #[test]
756 fn test_marker_with_content_still_flagged_issue_288() {
757 let rule = MD030ListMarkerSpace::default();
759
760 let content = "- Two spaces before content\n";
762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let result = rule.check(&ctx).unwrap();
764 assert_eq!(
765 result.len(),
766 1,
767 "Two spaces after unordered marker should still trigger MD030"
768 );
769
770 let content = "1. Two spaces\n";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.check(&ctx).unwrap();
774 assert_eq!(
775 result.len(),
776 1,
777 "Two spaces after ordered marker should still trigger MD030"
778 );
779
780 let content = "- Normal 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 "Normal list item should not trigger MD030, got: {result:?}"
787 );
788 }
789
790 #[test]
791 fn test_has_content_after_marker() {
792 assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
794 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
795 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
796 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
797 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
798 assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
799 assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
800 assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
801 }
802}