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 None
373 }
374
375 fn try_fix_list_marker_spacing_with_context(&self, line: &str, is_multi_line: bool) -> Option<String> {
377 let (blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
379
380 let trimmed = content.trim_start();
381 let indent = &content[..content.len() - trimmed.len()];
382
383 for marker in &["*", "-", "+"] {
386 if let Some(after_marker) = trimmed.strip_prefix(marker) {
387 if after_marker.starts_with(*marker) {
389 break;
390 }
391
392 if *marker == "*" && after_marker.contains('*') {
394 break;
395 }
396
397 if after_marker.starts_with(" ")
400 && let Some(fixed) = self.fix_marker_spacing(marker, after_marker, indent, is_multi_line, false)
401 {
402 return Some(format!("{blockquote_prefix}{fixed}"));
403 }
404 break; }
406 }
407
408 if let Some(dot_pos) = trimmed.find('.') {
410 let before_dot = &trimmed[..dot_pos];
411 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
412 let after_dot = &trimmed[dot_pos + 1..];
413
414 if after_dot.is_empty() {
416 return None;
417 }
418
419 if !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
421 let first_char = after_dot.chars().next().unwrap_or(' ');
422
423 if first_char.is_ascii_digit() {
425 return None;
426 }
427
428 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
432
433 if !is_clear_intent {
434 return None;
435 }
436 }
437 let marker = format!("{before_dot}.");
440 if let Some(fixed) = self.fix_marker_spacing(&marker, after_dot, indent, is_multi_line, true) {
441 return Some(format!("{blockquote_prefix}{fixed}"));
442 }
443 }
444 }
445
446 None
447 }
448
449 fn strip_blockquote_prefix(line: &str) -> (String, &str) {
451 let mut prefix = String::new();
452 let mut remaining = line;
453
454 loop {
455 let trimmed = remaining.trim_start();
456 if !trimmed.starts_with('>') {
457 break;
458 }
459 let leading_spaces = remaining.len() - trimmed.len();
461 prefix.push_str(&remaining[..leading_spaces]);
462 prefix.push('>');
463 remaining = &trimmed[1..];
464
465 if remaining.starts_with(' ') {
467 prefix.push(' ');
468 remaining = &remaining[1..];
469 }
470 }
471
472 (prefix, remaining)
473 }
474
475 fn check_unrecognized_list_marker(
478 &self,
479 ctx: &crate::lint_context::LintContext,
480 line: &str,
481 line_num: usize,
482 lines: &[&str],
483 ) -> Option<LintWarning> {
484 let (_blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
486
487 let trimmed = content.trim_start();
488 let indent_len = content.len() - trimmed.len();
489
490 if let Some(dot_pos) = trimmed.find('.') {
497 let before_dot = &trimmed[..dot_pos];
498 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
499 let after_dot = &trimmed[dot_pos + 1..];
500 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
502 let first_char = after_dot.chars().next().unwrap_or(' ');
503
504 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
509
510 if is_clear_intent {
511 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
512 let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
513
514 let marker = format!("{before_dot}.");
515 let marker_pos = indent_len;
516 let marker_end = marker_pos + marker.len();
517
518 let (start_line, start_col, end_line, end_col) =
519 calculate_match_range(line_num, line, marker_end, 0);
520
521 let correct_spaces = " ".repeat(expected_spaces);
522 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
523 let fix_position = line_start_byte + marker_end;
524
525 return Some(LintWarning {
526 rule_name: Some("MD030".to_string()),
527 severity: Severity::Warning,
528 line: start_line,
529 column: start_col,
530 end_line,
531 end_column: end_col,
532 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
533 fix: Some(crate::rule::Fix {
534 range: fix_position..fix_position,
535 replacement: correct_spaces,
536 }),
537 });
538 }
539 }
540 }
541 }
542
543 None
544 }
545
546 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
548 if line_num < lines.len() {
551 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
553 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
555 return true;
556 }
557 }
558 false
559 }
560
561 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
563 if ElementCache::calculate_indentation_width_default(line) < 4 {
565 return false;
566 }
567
568 if line_idx == 0 {
570 return false;
571 }
572
573 if self.has_blank_line_before_indented_block(line_idx, lines) {
575 return true;
576 }
577
578 false
579 }
580
581 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
583 let mut current_idx = line_idx;
585
586 while current_idx > 0 {
588 let current_line = lines[current_idx];
589 let prev_line = lines[current_idx - 1];
590
591 if ElementCache::calculate_indentation_width_default(current_line) < 4 {
593 break;
594 }
595
596 if ElementCache::calculate_indentation_width_default(prev_line) < 4 {
598 return prev_line.trim().is_empty();
599 }
600
601 current_idx -= 1;
602 }
603
604 false
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::lint_context::LintContext;
612
613 #[test]
614 fn test_basic_functionality() {
615 let rule = MD030ListMarkerSpace::default();
616 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let result = rule.check(&ctx).unwrap();
619 assert!(
620 result.is_empty(),
621 "Correctly spaced list markers should not generate warnings"
622 );
623 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625 let result = rule.check(&ctx).unwrap();
626 assert_eq!(
628 result.len(),
629 2,
630 "Should flag lines with too many spaces after list marker"
631 );
632 for warning in result {
633 assert!(
634 warning.message.starts_with("Spaces after list markers (Expected:")
635 && warning.message.contains("Actual:"),
636 "Warning message should include expected and actual values, got: '{}'",
637 warning.message
638 );
639 }
640 }
641
642 #[test]
643 fn test_nested_emphasis_not_flagged_issue_278() {
644 let rule = MD030ListMarkerSpace::default();
646
647 let content = "*This text is **very** important*";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let result = rule.check(&ctx).unwrap();
651 assert!(
652 result.is_empty(),
653 "Nested emphasis should not trigger MD030, got: {result:?}"
654 );
655
656 let content2 = "*Hello World*";
658 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
659 let result2 = rule.check(&ctx2).unwrap();
660 assert!(
661 result2.is_empty(),
662 "Simple emphasis should not trigger MD030, got: {result2:?}"
663 );
664
665 let content3 = "**bold text**";
667 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
668 let result3 = rule.check(&ctx3).unwrap();
669 assert!(
670 result3.is_empty(),
671 "Bold text should not trigger MD030, got: {result3:?}"
672 );
673
674 let content4 = "***bold and italic***";
676 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
677 let result4 = rule.check(&ctx4).unwrap();
678 assert!(
679 result4.is_empty(),
680 "Bold+italic should not trigger MD030, got: {result4:?}"
681 );
682
683 let content5 = "* Item with space";
685 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
686 let result5 = rule.check(&ctx5).unwrap();
687 assert!(
688 result5.is_empty(),
689 "Properly spaced list item should not trigger MD030, got: {result5:?}"
690 );
691 }
692
693 #[test]
694 fn test_empty_marker_line_not_flagged_issue_288() {
695 let rule = MD030ListMarkerSpace::default();
698
699 let content = "-\n ```python\n print(\"code\")\n ```\n";
701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702 let result = rule.check(&ctx).unwrap();
703 assert!(
704 result.is_empty(),
705 "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
706 );
707
708 let content = "1.\n ```python\n print(\"code\")\n ```\n";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711 let result = rule.check(&ctx).unwrap();
712 assert!(
713 result.is_empty(),
714 "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
715 );
716
717 let content = "-\n This is a paragraph continuation\n of the list item.\n";
719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let result = rule.check(&ctx).unwrap();
721 assert!(
722 result.is_empty(),
723 "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
724 );
725
726 let content = "- Parent item\n -\n Nested content\n";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.check(&ctx).unwrap();
730 assert!(
731 result.is_empty(),
732 "Nested empty marker line should not trigger MD030, got: {result:?}"
733 );
734
735 let content = "- Item with content\n-\n Code block\n- Another item\n";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739 assert!(
740 result.is_empty(),
741 "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
742 );
743 }
744
745 #[test]
746 fn test_marker_with_content_still_flagged_issue_288() {
747 let rule = MD030ListMarkerSpace::default();
749
750 let content = "- Two spaces before content\n";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753 let result = rule.check(&ctx).unwrap();
754 assert_eq!(
755 result.len(),
756 1,
757 "Two spaces after unordered marker should still trigger MD030"
758 );
759
760 let content = "1. Two spaces\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 ordered marker should still trigger MD030"
768 );
769
770 let content = "- Normal 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 "Normal list item should not trigger MD030, got: {result:?}"
777 );
778 }
779
780 #[test]
781 fn test_has_content_after_marker() {
782 assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
784 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
785 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
786 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
787 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
788 assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
789 assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
790 assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
791 }
792}