1use crate::rule::{LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::rule_config_serde::RuleConfig;
8use crate::utils::blockquote::{effective_indent_in_blockquote, parse_blockquote_prefix};
9use crate::utils::calculate_indentation_width_default;
10use crate::utils::range_utils::calculate_match_range;
11use toml;
12
13mod md030_config;
14use md030_config::MD030Config;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17enum ListType {
18 Unordered,
19 Ordered,
20}
21
22#[derive(Clone, Default)]
23pub struct MD030ListMarkerSpace {
24 config: MD030Config,
25}
26
27impl MD030ListMarkerSpace {
28 pub fn new(ul_single: usize, ul_multi: usize, ol_single: usize, ol_multi: usize) -> Self {
29 Self {
30 config: MD030Config {
31 ul_single: crate::types::PositiveUsize::new(ul_single)
32 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
33 ul_multi: crate::types::PositiveUsize::new(ul_multi)
34 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
35 ol_single: crate::types::PositiveUsize::new(ol_single)
36 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
37 ol_multi: crate::types::PositiveUsize::new(ol_multi)
38 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
39 },
40 }
41 }
42
43 fn from_config_struct(config: MD030Config) -> Self {
44 Self { config }
45 }
46
47 fn get_expected_spaces(&self, list_type: ListType, is_multi: bool) -> usize {
48 match (list_type, is_multi) {
49 (ListType::Unordered, false) => self.config.ul_single.get(),
50 (ListType::Unordered, true) => self.config.ul_multi.get(),
51 (ListType::Ordered, false) => self.config.ol_single.get(),
52 (ListType::Ordered, true) => self.config.ol_multi.get(),
53 }
54 }
55}
56
57impl Rule for MD030ListMarkerSpace {
58 fn name(&self) -> &'static str {
59 "MD030"
60 }
61
62 fn description(&self) -> &'static str {
63 "Spaces after list markers should be consistent"
64 }
65
66 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
67 let mut warnings = Vec::new();
68
69 if self.should_skip(ctx) {
71 return Ok(warnings);
72 }
73
74 let lines = ctx.raw_lines();
75
76 let mut processed_lines = std::collections::HashSet::new();
78
79 for (line_num, line_info) in ctx.lines.iter().enumerate() {
81 if line_info.list_item.is_some()
83 && !line_info.in_code_block
84 && !line_info.in_math_block
85 && !line_info.in_pymdown_block
86 && !line_info.in_mkdocs_html_markdown
87 && !line_info.in_footnote_definition
88 {
89 let line_num_1based = line_num + 1;
90 processed_lines.insert(line_num_1based);
91
92 let line = lines[line_num];
93
94 if let Some(list_info) = &line_info.list_item {
95 let list_type = if list_info.is_ordered {
96 ListType::Ordered
97 } else {
98 ListType::Unordered
99 };
100
101 let marker_end = list_info.marker_column + list_info.marker.len();
103
104 if !Self::has_content_after_marker(line, marker_end) {
107 continue;
108 }
109
110 let actual_spaces = list_info.content_column.saturating_sub(marker_end);
111
112 let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, lines);
114 let expected_spaces = self.get_expected_spaces(list_type, is_multi_line);
115
116 if actual_spaces != expected_spaces {
117 let whitespace_start_pos = marker_end;
118 let whitespace_len = actual_spaces;
119
120 let (start_line, start_col, end_line, end_col) =
121 calculate_match_range(line_num_1based, line, whitespace_start_pos, whitespace_len);
122
123 let correct_spaces = " ".repeat(expected_spaces);
124 let line_start_byte = ctx.line_offsets.get(line_num).copied().unwrap_or(0);
125 let whitespace_start_byte = line_start_byte + whitespace_start_pos;
126 let whitespace_end_byte = whitespace_start_byte + whitespace_len;
127
128 let fix = Some(crate::rule::Fix {
129 range: whitespace_start_byte..whitespace_end_byte,
130 replacement: correct_spaces,
131 });
132
133 let message =
134 format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})");
135
136 warnings.push(LintWarning {
137 rule_name: Some(self.name().to_string()),
138 severity: Severity::Warning,
139 line: start_line,
140 column: start_col,
141 end_line,
142 end_column: end_col,
143 message,
144 fix,
145 });
146 }
147 }
148 }
149 }
150
151 for (line_idx, line) in lines.iter().enumerate() {
154 let line_num = line_idx + 1;
155
156 if processed_lines.contains(&line_num) {
158 continue;
159 }
160 if let Some(line_info) = ctx.lines.get(line_idx)
161 && (line_info.in_code_block
162 || line_info.in_front_matter
163 || line_info.in_html_comment
164 || line_info.in_mdx_comment
165 || line_info.in_math_block
166 || line_info.in_pymdown_block
167 || line_info.in_mkdocs_html_markdown
168 || line_info.in_footnote_definition)
169 {
170 continue;
171 }
172
173 if self.is_indented_code_block(line, line_idx, lines) {
175 continue;
176 }
177
178 if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, lines) {
180 warnings.push(warning);
181 }
182 }
183
184 Ok(warnings)
185 }
186
187 fn category(&self) -> RuleCategory {
188 RuleCategory::List
189 }
190
191 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
192 if ctx.content.is_empty() {
193 return true;
194 }
195
196 let bytes = ctx.content.as_bytes();
198 !bytes.contains(&b'*')
199 && !bytes.contains(&b'-')
200 && !bytes.contains(&b'+')
201 && !bytes.iter().any(|&b| b.is_ascii_digit())
202 }
203
204 fn as_any(&self) -> &dyn std::any::Any {
205 self
206 }
207
208 fn default_config_section(&self) -> Option<(String, toml::Value)> {
209 let default_config = MD030Config::default();
210 let json_value = serde_json::to_value(&default_config).ok()?;
211 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
212
213 if let toml::Value::Table(table) = toml_value {
214 if !table.is_empty() {
215 Some((MD030Config::RULE_NAME.to_string(), toml::Value::Table(table)))
216 } else {
217 None
218 }
219 } else {
220 None
221 }
222 }
223
224 fn from_config(config: &crate::config::Config) -> Box<dyn Rule> {
225 let rule_config = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
226 Box::new(Self::from_config_struct(rule_config))
227 }
228
229 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
230 if self.should_skip(ctx) {
231 return Ok(ctx.content.to_string());
232 }
233
234 let warnings = self.check(ctx)?;
238 if warnings.is_empty() {
239 return Ok(ctx.content.to_string());
240 }
241
242 let warnings =
243 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
244
245 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
246 .map_err(crate::rule::LintError::InvalidInput)
247 }
248}
249
250impl MD030ListMarkerSpace {
251 #[inline]
255 fn has_content_after_marker(line: &str, marker_end: usize) -> bool {
256 if marker_end >= line.len() {
257 return false;
258 }
259 !line[marker_end..].trim().is_empty()
260 }
261
262 fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
264 let current_line_info = match ctx.line_info(line_num) {
266 Some(info) if info.list_item.is_some() => info,
267 _ => return false,
268 };
269
270 let current_list = current_line_info.list_item.as_ref().unwrap();
271
272 for next_line_num in (line_num + 1)..=lines.len() {
274 if let Some(next_line_info) = ctx.line_info(next_line_num) {
275 if let Some(next_list) = &next_line_info.list_item {
277 if next_list.marker_column <= current_list.marker_column {
278 break; }
280 return true;
282 }
283
284 let line_content = lines.get(next_line_num - 1).unwrap_or(&"");
287 if !line_content.trim().is_empty() {
288 let bq_level = current_line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
290
291 let min_continuation_indent = if bq_level > 0 {
294 current_list
297 .content_column
298 .saturating_sub(current_line_info.blockquote.as_ref().map_or(0, |bq| bq.prefix.len()))
299 } else {
300 current_list.content_column
301 };
302
303 let raw_indent = line_content.len() - line_content.trim_start().len();
305 let actual_indent = effective_indent_in_blockquote(line_content, bq_level, raw_indent);
306
307 if actual_indent < min_continuation_indent {
308 break; }
310
311 if actual_indent >= min_continuation_indent {
313 return true;
314 }
315 }
316
317 }
319 }
320
321 false
322 }
323
324 fn check_unrecognized_list_marker(
327 &self,
328 ctx: &crate::lint_context::LintContext,
329 line: &str,
330 line_num: usize,
331 lines: &[&str],
332 ) -> Option<LintWarning> {
333 let (bq_prefix_len, content) = match parse_blockquote_prefix(line) {
336 Some(parsed) => (parsed.prefix.len(), parsed.content),
337 None => (0, line),
338 };
339
340 let trimmed = content.trim_start();
341 let indent_len = content.len() - trimmed.len();
342
343 if let Some(dot_pos) = trimmed.find('.') {
350 let before_dot = &trimmed[..dot_pos];
351 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
352 let after_dot = &trimmed[dot_pos + 1..];
353 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
355 let first_char = after_dot.chars().next().unwrap_or(' ');
356
357 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
362
363 if is_clear_intent {
364 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
365 let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
366
367 let marker = format!("{before_dot}.");
368 let marker_pos = indent_len;
369 let marker_end = marker_pos + marker.len();
370 let offset_in_line = bq_prefix_len + marker_end;
372
373 let (start_line, start_col, end_line, end_col) =
374 calculate_match_range(line_num, line, offset_in_line, 0);
375
376 let correct_spaces = " ".repeat(expected_spaces);
377 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
378 let fix_position = line_start_byte + offset_in_line;
379
380 return Some(LintWarning {
381 rule_name: Some("MD030".to_string()),
382 severity: Severity::Warning,
383 line: start_line,
384 column: start_col,
385 end_line,
386 end_column: end_col,
387 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
388 fix: Some(crate::rule::Fix {
389 range: fix_position..fix_position,
390 replacement: correct_spaces,
391 }),
392 });
393 }
394 }
395 }
396 }
397
398 None
399 }
400
401 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
403 if line_num < lines.len() {
406 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
408 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
410 return true;
411 }
412 }
413 false
414 }
415
416 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
418 if calculate_indentation_width_default(line) < 4 {
420 return false;
421 }
422
423 if line_idx == 0 {
425 return false;
426 }
427
428 if self.has_blank_line_before_indented_block(line_idx, lines) {
430 return true;
431 }
432
433 false
434 }
435
436 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
438 let mut current_idx = line_idx;
440
441 while current_idx > 0 {
443 let current_line = lines[current_idx];
444 let prev_line = lines[current_idx - 1];
445
446 if calculate_indentation_width_default(current_line) < 4 {
448 break;
449 }
450
451 if calculate_indentation_width_default(prev_line) < 4 {
453 return prev_line.trim().is_empty();
454 }
455
456 current_idx -= 1;
457 }
458
459 false
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::lint_context::LintContext;
467
468 fn assert_fix_resolves_all_violations(rule: &MD030ListMarkerSpace, content: &str) {
471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472 let before = rule.check(&ctx).unwrap();
473 assert!(
474 !before.is_empty(),
475 "Expected violations but check() found none in:\n{content}"
476 );
477
478 let fixed = rule.fix(&ctx).unwrap();
479 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
480 let after = rule.check(&ctx_fixed).unwrap();
481 assert!(
482 after.is_empty(),
483 "fix() left {} violation(s) unresolved:\n{:?}\nOriginal:\n{content}\nFixed:\n{fixed}",
484 after.len(),
485 after
486 );
487 }
488
489 #[test]
490 fn test_basic_functionality() {
491 let rule = MD030ListMarkerSpace::default();
492 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let result = rule.check(&ctx).unwrap();
495 assert!(
496 result.is_empty(),
497 "Correctly spaced list markers should not generate warnings"
498 );
499 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
500 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
501 let result = rule.check(&ctx).unwrap();
502 assert_eq!(
504 result.len(),
505 2,
506 "Should flag lines with too many spaces after list marker"
507 );
508 for warning in result {
509 assert!(
510 warning.message.starts_with("Spaces after list markers (Expected:")
511 && warning.message.contains("Actual:"),
512 "Warning message should include expected and actual values, got: '{}'",
513 warning.message
514 );
515 }
516 }
517
518 #[test]
519 fn test_nested_emphasis_not_flagged_issue_278() {
520 let rule = MD030ListMarkerSpace::default();
522
523 let content = "*This text is **very** important*";
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let result = rule.check(&ctx).unwrap();
527 assert!(
528 result.is_empty(),
529 "Nested emphasis should not trigger MD030, got: {result:?}"
530 );
531
532 let content2 = "*Hello World*";
534 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
535 let result2 = rule.check(&ctx2).unwrap();
536 assert!(
537 result2.is_empty(),
538 "Simple emphasis should not trigger MD030, got: {result2:?}"
539 );
540
541 let content3 = "**bold text**";
543 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
544 let result3 = rule.check(&ctx3).unwrap();
545 assert!(
546 result3.is_empty(),
547 "Bold text should not trigger MD030, got: {result3:?}"
548 );
549
550 let content4 = "***bold and italic***";
552 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
553 let result4 = rule.check(&ctx4).unwrap();
554 assert!(
555 result4.is_empty(),
556 "Bold+italic should not trigger MD030, got: {result4:?}"
557 );
558
559 let content5 = "* Item with space";
561 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
562 let result5 = rule.check(&ctx5).unwrap();
563 assert!(
564 result5.is_empty(),
565 "Properly spaced list item should not trigger MD030, got: {result5:?}"
566 );
567 }
568
569 #[test]
570 fn test_empty_marker_line_not_flagged_issue_288() {
571 let rule = MD030ListMarkerSpace::default();
574
575 let content = "-\n ```python\n print(\"code\")\n ```\n";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let result = rule.check(&ctx).unwrap();
579 assert!(
580 result.is_empty(),
581 "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
582 );
583
584 let content = "1.\n ```python\n print(\"code\")\n ```\n";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert!(
589 result.is_empty(),
590 "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
591 );
592
593 let content = "-\n This is a paragraph continuation\n of the list item.\n";
595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596 let result = rule.check(&ctx).unwrap();
597 assert!(
598 result.is_empty(),
599 "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
600 );
601
602 let content = "- Parent item\n -\n Nested content\n";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let result = rule.check(&ctx).unwrap();
606 assert!(
607 result.is_empty(),
608 "Nested empty marker line should not trigger MD030, got: {result:?}"
609 );
610
611 let content = "- Item with content\n-\n Code block\n- Another item\n";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let result = rule.check(&ctx).unwrap();
615 assert!(
616 result.is_empty(),
617 "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
618 );
619 }
620
621 #[test]
622 fn test_marker_with_content_still_flagged_issue_288() {
623 let rule = MD030ListMarkerSpace::default();
625
626 let content = "- Two spaces before content\n";
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let result = rule.check(&ctx).unwrap();
630 assert_eq!(
631 result.len(),
632 1,
633 "Two spaces after unordered marker should still trigger MD030"
634 );
635
636 let content = "1. Two spaces\n";
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639 let result = rule.check(&ctx).unwrap();
640 assert_eq!(
641 result.len(),
642 1,
643 "Two spaces after ordered marker should still trigger MD030"
644 );
645
646 let content = "- Normal item\n";
648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649 let result = rule.check(&ctx).unwrap();
650 assert!(
651 result.is_empty(),
652 "Normal list item should not trigger MD030, got: {result:?}"
653 );
654 }
655
656 #[test]
657 fn test_nested_items_with_4space_indent_are_detected() {
658 let rule = MD030ListMarkerSpace::new(3, 3, 1, 1);
663
664 let content = "- Top-level correct\n - Nested wrong spacing\n - Nested correct\n";
667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668 let result = rule.check(&ctx).unwrap();
669 assert_eq!(
670 result.len(),
671 1,
672 "Nested item with 1 space (ul_single=3) should be flagged; got: {result:?}"
673 );
674 assert_eq!(result[0].line, 2, "Violation should be on line 2");
675 assert!(
676 result[0].message.contains("Expected: 3") && result[0].message.contains("Actual: 1"),
677 "Message should state expected/actual spaces; got: {}",
678 result[0].message
679 );
680
681 let fixed = rule.fix(&ctx).unwrap();
683 assert_eq!(
684 fixed, "- Top-level correct\n - Nested wrong spacing\n - Nested correct\n",
685 "fix() should expand 1 space to ul_single=3 on the nested item"
686 );
687
688 let content_ok = "- Top-level\n - Nested correct\n";
690 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
691 let result_ok = rule.check(&ctx_ok).unwrap();
692 assert!(
693 result_ok.is_empty(),
694 "Nested item with correct spacing should not be flagged; got: {result_ok:?}"
695 );
696
697 let rule_ol = MD030ListMarkerSpace::new(1, 1, 2, 2);
699 let content_ol = "1. Top-level multi\n 1. Nested wrong\n";
700 let ctx_ol = LintContext::new(content_ol, crate::config::MarkdownFlavor::Standard, None);
701 let result_ol = rule_ol.check(&ctx_ol).unwrap();
702 assert_eq!(
703 result_ol.len(),
704 1,
705 "Nested ordered item with 1 space (ol_single=2) should be flagged; got: {result_ol:?}"
706 );
707 let fixed_ol = rule_ol.fix(&ctx_ol).unwrap();
708 assert_eq!(
709 fixed_ol, "1. Top-level multi\n 1. Nested wrong\n",
710 "fix() should expand 1 space to ol_single=2 on the nested ordered item"
711 );
712
713 let content_deep = "- Level 1\n - Level 2\n - Level 3 wrong\n - Level 3 correct\n";
716 let ctx_deep = LintContext::new(content_deep, crate::config::MarkdownFlavor::Standard, None);
717 let result_deep = rule.check(&ctx_deep).unwrap();
718 assert_eq!(
719 result_deep.len(),
720 1,
721 "Deeply nested (8-space) item with 1 space should be flagged; got: {result_deep:?}"
722 );
723 assert_eq!(result_deep[0].line, 3, "Violation should be on the deeply nested line");
724
725 assert_fix_resolves_all_violations(&rule, content);
727 assert_fix_resolves_all_violations(&rule_ol, content_ol);
728 assert_fix_resolves_all_violations(&rule, content_deep);
729 }
730
731 #[test]
732 fn test_loose_nested_item_fix_matches_check() {
733 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
736
737 let content = "- parent\n\n - nested wrong\n";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739
740 let warnings = rule.check(&ctx).unwrap();
742 assert_eq!(
743 warnings.len(),
744 1,
745 "Loose nested item with 2 spaces should be detected; got: {warnings:?}"
746 );
747
748 let fixed = rule.fix(&ctx).unwrap();
750 assert_eq!(
751 fixed, "- parent\n\n - nested wrong\n",
752 "fix() should reduce 2 spaces to 1 for loose nested item"
753 );
754
755 assert_fix_resolves_all_violations(&rule, content);
757 }
758
759 #[test]
760 fn test_has_content_after_marker() {
761 assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
763 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
764 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
765 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
766 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
767 assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
768 assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
769 assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
770 }
771}