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, parse_blockquote_prefix};
10use crate::utils::calculate_indentation_width_default;
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 && !line_info.in_footnote_definition
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 let Some(list_info) = &line_info.list_item {
90 let list_type = if list_info.is_ordered {
91 ListType::Ordered
92 } else {
93 ListType::Unordered
94 };
95
96 let marker_end = list_info.marker_column + list_info.marker.len();
98
99 if !Self::has_content_after_marker(line, marker_end) {
102 continue;
103 }
104
105 let actual_spaces = list_info.content_column.saturating_sub(marker_end);
106
107 let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, lines);
109 let expected_spaces = self.get_expected_spaces(list_type, is_multi_line);
110
111 if actual_spaces != expected_spaces {
112 let whitespace_start_pos = marker_end;
113 let whitespace_len = actual_spaces;
114
115 let (start_line, start_col, end_line, end_col) =
116 calculate_match_range(line_num_1based, line, whitespace_start_pos, whitespace_len);
117
118 let correct_spaces = " ".repeat(expected_spaces);
119 let line_start_byte = ctx.line_offsets.get(line_num).copied().unwrap_or(0);
120 let whitespace_start_byte = line_start_byte + whitespace_start_pos;
121 let whitespace_end_byte = whitespace_start_byte + whitespace_len;
122
123 let fix = Some(crate::rule::Fix {
124 range: whitespace_start_byte..whitespace_end_byte,
125 replacement: correct_spaces,
126 });
127
128 let message =
129 format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})");
130
131 warnings.push(LintWarning {
132 rule_name: Some(self.name().to_string()),
133 severity: Severity::Warning,
134 line: start_line,
135 column: start_col,
136 end_line,
137 end_column: end_col,
138 message,
139 fix,
140 });
141 }
142 }
143 }
144 }
145
146 for (line_idx, line) in lines.iter().enumerate() {
149 let line_num = line_idx + 1;
150
151 if processed_lines.contains(&line_num) {
153 continue;
154 }
155 if let Some(line_info) = ctx.lines.get(line_idx)
156 && (line_info.in_code_block
157 || line_info.in_front_matter
158 || line_info.in_html_comment
159 || line_info.in_mdx_comment
160 || line_info.in_math_block
161 || line_info.in_pymdown_block
162 || line_info.in_mkdocs_html_markdown
163 || line_info.in_footnote_definition)
164 {
165 continue;
166 }
167
168 if self.is_indented_code_block(line, line_idx, lines) {
170 continue;
171 }
172
173 if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, lines) {
175 warnings.push(warning);
176 }
177 }
178
179 Ok(warnings)
180 }
181
182 fn category(&self) -> RuleCategory {
183 RuleCategory::List
184 }
185
186 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
187 if ctx.content.is_empty() {
188 return true;
189 }
190
191 let bytes = ctx.content.as_bytes();
193 !bytes.contains(&b'*')
194 && !bytes.contains(&b'-')
195 && !bytes.contains(&b'+')
196 && !bytes.iter().any(|&b| b.is_ascii_digit())
197 }
198
199 fn as_any(&self) -> &dyn std::any::Any {
200 self
201 }
202
203 fn default_config_section(&self) -> Option<(String, toml::Value)> {
204 let default_config = MD030Config::default();
205 let json_value = serde_json::to_value(&default_config).ok()?;
206 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
207
208 if let toml::Value::Table(table) = toml_value {
209 if !table.is_empty() {
210 Some((MD030Config::RULE_NAME.to_string(), toml::Value::Table(table)))
211 } else {
212 None
213 }
214 } else {
215 None
216 }
217 }
218
219 fn from_config(config: &crate::config::Config) -> Box<dyn Rule> {
220 let rule_config = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
221 Box::new(Self::from_config_struct(rule_config))
222 }
223
224 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
225 if self.should_skip(ctx) {
226 return Ok(ctx.content.to_string());
227 }
228
229 let warnings = self.check(ctx)?;
233 if warnings.is_empty() {
234 return Ok(ctx.content.to_string());
235 }
236
237 let warnings =
238 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
239
240 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
241 .map_err(crate::rule::LintError::InvalidInput)
242 }
243}
244
245impl MD030ListMarkerSpace {
246 #[inline]
250 fn has_content_after_marker(line: &str, marker_end: usize) -> bool {
251 if marker_end >= line.len() {
252 return false;
253 }
254 !line[marker_end..].trim().is_empty()
255 }
256
257 fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
259 let current_line_info = match ctx.line_info(line_num) {
261 Some(info) if info.list_item.is_some() => info,
262 _ => return false,
263 };
264
265 let current_list = current_line_info.list_item.as_ref().unwrap();
266
267 for next_line_num in (line_num + 1)..=lines.len() {
269 if let Some(next_line_info) = ctx.line_info(next_line_num) {
270 if let Some(next_list) = &next_line_info.list_item {
272 if next_list.marker_column <= current_list.marker_column {
273 break; }
275 return true;
277 }
278
279 let line_content = lines.get(next_line_num - 1).unwrap_or(&"");
282 if !line_content.trim().is_empty() {
283 let bq_level = current_line_info
285 .blockquote
286 .as_ref()
287 .map(|bq| bq.nesting_level)
288 .unwrap_or(0);
289
290 let min_continuation_indent = if bq_level > 0 {
293 current_list.content_column.saturating_sub(
296 current_line_info
297 .blockquote
298 .as_ref()
299 .map(|bq| bq.prefix.len())
300 .unwrap_or(0),
301 )
302 } else {
303 current_list.content_column
304 };
305
306 let raw_indent = line_content.len() - line_content.trim_start().len();
308 let actual_indent = effective_indent_in_blockquote(line_content, bq_level, raw_indent);
309
310 if actual_indent < min_continuation_indent {
311 break; }
313
314 if actual_indent >= min_continuation_indent {
316 return true;
317 }
318 }
319
320 }
322 }
323
324 false
325 }
326
327 fn check_unrecognized_list_marker(
330 &self,
331 ctx: &crate::lint_context::LintContext,
332 line: &str,
333 line_num: usize,
334 lines: &[&str],
335 ) -> Option<LintWarning> {
336 let (bq_prefix_len, content) = match parse_blockquote_prefix(line) {
339 Some(parsed) => (parsed.prefix.len(), parsed.content),
340 None => (0, line),
341 };
342
343 let trimmed = content.trim_start();
344 let indent_len = content.len() - trimmed.len();
345
346 if let Some(dot_pos) = trimmed.find('.') {
353 let before_dot = &trimmed[..dot_pos];
354 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
355 let after_dot = &trimmed[dot_pos + 1..];
356 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
358 let first_char = after_dot.chars().next().unwrap_or(' ');
359
360 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
365
366 if is_clear_intent {
367 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
368 let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
369
370 let marker = format!("{before_dot}.");
371 let marker_pos = indent_len;
372 let marker_end = marker_pos + marker.len();
373 let offset_in_line = bq_prefix_len + marker_end;
375
376 let (start_line, start_col, end_line, end_col) =
377 calculate_match_range(line_num, line, offset_in_line, 0);
378
379 let correct_spaces = " ".repeat(expected_spaces);
380 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
381 let fix_position = line_start_byte + offset_in_line;
382
383 return Some(LintWarning {
384 rule_name: Some("MD030".to_string()),
385 severity: Severity::Warning,
386 line: start_line,
387 column: start_col,
388 end_line,
389 end_column: end_col,
390 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
391 fix: Some(crate::rule::Fix {
392 range: fix_position..fix_position,
393 replacement: correct_spaces,
394 }),
395 });
396 }
397 }
398 }
399 }
400
401 None
402 }
403
404 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
406 if line_num < lines.len() {
409 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
411 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
413 return true;
414 }
415 }
416 false
417 }
418
419 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
421 if calculate_indentation_width_default(line) < 4 {
423 return false;
424 }
425
426 if line_idx == 0 {
428 return false;
429 }
430
431 if self.has_blank_line_before_indented_block(line_idx, lines) {
433 return true;
434 }
435
436 false
437 }
438
439 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
441 let mut current_idx = line_idx;
443
444 while current_idx > 0 {
446 let current_line = lines[current_idx];
447 let prev_line = lines[current_idx - 1];
448
449 if calculate_indentation_width_default(current_line) < 4 {
451 break;
452 }
453
454 if calculate_indentation_width_default(prev_line) < 4 {
456 return prev_line.trim().is_empty();
457 }
458
459 current_idx -= 1;
460 }
461
462 false
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use crate::lint_context::LintContext;
470
471 fn assert_fix_resolves_all_violations(rule: &MD030ListMarkerSpace, content: &str) {
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475 let before = rule.check(&ctx).unwrap();
476 assert!(
477 !before.is_empty(),
478 "Expected violations but check() found none in:\n{content}"
479 );
480
481 let fixed = rule.fix(&ctx).unwrap();
482 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
483 let after = rule.check(&ctx_fixed).unwrap();
484 assert!(
485 after.is_empty(),
486 "fix() left {} violation(s) unresolved:\n{:?}\nOriginal:\n{content}\nFixed:\n{fixed}",
487 after.len(),
488 after
489 );
490 }
491
492 #[test]
493 fn test_basic_functionality() {
494 let rule = MD030ListMarkerSpace::default();
495 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let result = rule.check(&ctx).unwrap();
498 assert!(
499 result.is_empty(),
500 "Correctly spaced list markers should not generate warnings"
501 );
502 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
504 let result = rule.check(&ctx).unwrap();
505 assert_eq!(
507 result.len(),
508 2,
509 "Should flag lines with too many spaces after list marker"
510 );
511 for warning in result {
512 assert!(
513 warning.message.starts_with("Spaces after list markers (Expected:")
514 && warning.message.contains("Actual:"),
515 "Warning message should include expected and actual values, got: '{}'",
516 warning.message
517 );
518 }
519 }
520
521 #[test]
522 fn test_nested_emphasis_not_flagged_issue_278() {
523 let rule = MD030ListMarkerSpace::default();
525
526 let content = "*This text is **very** important*";
528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529 let result = rule.check(&ctx).unwrap();
530 assert!(
531 result.is_empty(),
532 "Nested emphasis should not trigger MD030, got: {result:?}"
533 );
534
535 let content2 = "*Hello World*";
537 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
538 let result2 = rule.check(&ctx2).unwrap();
539 assert!(
540 result2.is_empty(),
541 "Simple emphasis should not trigger MD030, got: {result2:?}"
542 );
543
544 let content3 = "**bold text**";
546 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
547 let result3 = rule.check(&ctx3).unwrap();
548 assert!(
549 result3.is_empty(),
550 "Bold text should not trigger MD030, got: {result3:?}"
551 );
552
553 let content4 = "***bold and italic***";
555 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
556 let result4 = rule.check(&ctx4).unwrap();
557 assert!(
558 result4.is_empty(),
559 "Bold+italic should not trigger MD030, got: {result4:?}"
560 );
561
562 let content5 = "* Item with space";
564 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
565 let result5 = rule.check(&ctx5).unwrap();
566 assert!(
567 result5.is_empty(),
568 "Properly spaced list item should not trigger MD030, got: {result5:?}"
569 );
570 }
571
572 #[test]
573 fn test_empty_marker_line_not_flagged_issue_288() {
574 let rule = MD030ListMarkerSpace::default();
577
578 let content = "-\n ```python\n print(\"code\")\n ```\n";
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let result = rule.check(&ctx).unwrap();
582 assert!(
583 result.is_empty(),
584 "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
585 );
586
587 let content = "1.\n ```python\n print(\"code\")\n ```\n";
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590 let result = rule.check(&ctx).unwrap();
591 assert!(
592 result.is_empty(),
593 "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
594 );
595
596 let content = "-\n This is a paragraph continuation\n of the list item.\n";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599 let result = rule.check(&ctx).unwrap();
600 assert!(
601 result.is_empty(),
602 "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
603 );
604
605 let content = "- Parent item\n -\n Nested content\n";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608 let result = rule.check(&ctx).unwrap();
609 assert!(
610 result.is_empty(),
611 "Nested empty marker line should not trigger MD030, got: {result:?}"
612 );
613
614 let content = "- Item with content\n-\n Code block\n- Another item\n";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617 let result = rule.check(&ctx).unwrap();
618 assert!(
619 result.is_empty(),
620 "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
621 );
622 }
623
624 #[test]
625 fn test_marker_with_content_still_flagged_issue_288() {
626 let rule = MD030ListMarkerSpace::default();
628
629 let content = "- Two spaces before content\n";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632 let result = rule.check(&ctx).unwrap();
633 assert_eq!(
634 result.len(),
635 1,
636 "Two spaces after unordered marker should still trigger MD030"
637 );
638
639 let content = "1. Two spaces\n";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642 let result = rule.check(&ctx).unwrap();
643 assert_eq!(
644 result.len(),
645 1,
646 "Two spaces after ordered marker should still trigger MD030"
647 );
648
649 let content = "- Normal item\n";
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 let result = rule.check(&ctx).unwrap();
653 assert!(
654 result.is_empty(),
655 "Normal list item should not trigger MD030, got: {result:?}"
656 );
657 }
658
659 #[test]
660 fn test_nested_items_with_4space_indent_are_detected() {
661 let rule = MD030ListMarkerSpace::new(3, 3, 1, 1);
666
667 let content = "- Top-level correct\n - Nested wrong spacing\n - Nested correct\n";
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671 let result = rule.check(&ctx).unwrap();
672 assert_eq!(
673 result.len(),
674 1,
675 "Nested item with 1 space (ul_single=3) should be flagged; got: {result:?}"
676 );
677 assert_eq!(result[0].line, 2, "Violation should be on line 2");
678 assert!(
679 result[0].message.contains("Expected: 3") && result[0].message.contains("Actual: 1"),
680 "Message should state expected/actual spaces; got: {}",
681 result[0].message
682 );
683
684 let fixed = rule.fix(&ctx).unwrap();
686 assert_eq!(
687 fixed, "- Top-level correct\n - Nested wrong spacing\n - Nested correct\n",
688 "fix() should expand 1 space to ul_single=3 on the nested item"
689 );
690
691 let content_ok = "- Top-level\n - Nested correct\n";
693 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
694 let result_ok = rule.check(&ctx_ok).unwrap();
695 assert!(
696 result_ok.is_empty(),
697 "Nested item with correct spacing should not be flagged; got: {result_ok:?}"
698 );
699
700 let rule_ol = MD030ListMarkerSpace::new(1, 1, 2, 2);
702 let content_ol = "1. Top-level multi\n 1. Nested wrong\n";
703 let ctx_ol = LintContext::new(content_ol, crate::config::MarkdownFlavor::Standard, None);
704 let result_ol = rule_ol.check(&ctx_ol).unwrap();
705 assert_eq!(
706 result_ol.len(),
707 1,
708 "Nested ordered item with 1 space (ol_single=2) should be flagged; got: {result_ol:?}"
709 );
710 let fixed_ol = rule_ol.fix(&ctx_ol).unwrap();
711 assert_eq!(
712 fixed_ol, "1. Top-level multi\n 1. Nested wrong\n",
713 "fix() should expand 1 space to ol_single=2 on the nested ordered item"
714 );
715
716 let content_deep = "- Level 1\n - Level 2\n - Level 3 wrong\n - Level 3 correct\n";
719 let ctx_deep = LintContext::new(content_deep, crate::config::MarkdownFlavor::Standard, None);
720 let result_deep = rule.check(&ctx_deep).unwrap();
721 assert_eq!(
722 result_deep.len(),
723 1,
724 "Deeply nested (8-space) item with 1 space should be flagged; got: {result_deep:?}"
725 );
726 assert_eq!(result_deep[0].line, 3, "Violation should be on the deeply nested line");
727
728 assert_fix_resolves_all_violations(&rule, content);
730 assert_fix_resolves_all_violations(&rule_ol, content_ol);
731 assert_fix_resolves_all_violations(&rule, content_deep);
732 }
733
734 #[test]
735 fn test_loose_nested_item_fix_matches_check() {
736 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
739
740 let content = "- parent\n\n - nested wrong\n";
741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742
743 let warnings = rule.check(&ctx).unwrap();
745 assert_eq!(
746 warnings.len(),
747 1,
748 "Loose nested item with 2 spaces should be detected; got: {warnings:?}"
749 );
750
751 let fixed = rule.fix(&ctx).unwrap();
753 assert_eq!(
754 fixed, "- parent\n\n - nested wrong\n",
755 "fix() should reduce 2 spaces to 1 for loose nested item"
756 );
757
758 assert_fix_resolves_all_violations(&rule, content);
760 }
761
762 #[test]
763 fn test_has_content_after_marker() {
764 assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
766 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
767 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
768 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
769 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
770 assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
771 assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
772 assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
773 }
774}