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 {
77 let line_num_1based = line_num + 1;
78 processed_lines.insert(line_num_1based);
79
80 let line = lines[line_num];
81
82 if ElementCache::calculate_indentation_width_default(line) >= 4 {
84 continue;
85 }
86
87 if let Some(list_info) = &line_info.list_item {
88 let list_type = if list_info.is_ordered {
89 ListType::Ordered
90 } else {
91 ListType::Unordered
92 };
93
94 let marker_end = list_info.marker_column + list_info.marker.len();
96 let actual_spaces = list_info.content_column.saturating_sub(marker_end);
97
98 let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, &lines);
100 let expected_spaces = self.get_expected_spaces(list_type, is_multi_line);
101
102 if actual_spaces != expected_spaces {
103 let whitespace_start_pos = marker_end;
104 let whitespace_len = actual_spaces;
105
106 let (start_line, start_col, end_line, end_col) =
107 calculate_match_range(line_num_1based, line, whitespace_start_pos, whitespace_len);
108
109 let correct_spaces = " ".repeat(expected_spaces);
110 let line_start_byte = ctx.line_offsets.get(line_num).copied().unwrap_or(0);
111 let whitespace_start_byte = line_start_byte + whitespace_start_pos;
112 let whitespace_end_byte = whitespace_start_byte + whitespace_len;
113
114 let fix = Some(crate::rule::Fix {
115 range: whitespace_start_byte..whitespace_end_byte,
116 replacement: correct_spaces,
117 });
118
119 let message =
120 format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})");
121
122 warnings.push(LintWarning {
123 rule_name: Some(self.name().to_string()),
124 severity: Severity::Warning,
125 line: start_line,
126 column: start_col,
127 end_line,
128 end_column: end_col,
129 message,
130 fix,
131 });
132 }
133 }
134 }
135 }
136
137 for (line_idx, line) in lines.iter().enumerate() {
140 let line_num = line_idx + 1;
141
142 if processed_lines.contains(&line_num) {
144 continue;
145 }
146 if let Some(line_info) = ctx.lines.get(line_idx)
147 && (line_info.in_code_block || line_info.in_front_matter || line_info.in_html_comment)
148 {
149 continue;
150 }
151
152 if self.is_indented_code_block(line, line_idx, &lines) {
154 continue;
155 }
156
157 if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, &lines) {
159 warnings.push(warning);
160 }
161 }
162
163 Ok(warnings)
164 }
165
166 fn category(&self) -> RuleCategory {
167 RuleCategory::List
168 }
169
170 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
171 if ctx.content.is_empty() {
172 return true;
173 }
174
175 let bytes = ctx.content.as_bytes();
177 !bytes.contains(&b'*')
178 && !bytes.contains(&b'-')
179 && !bytes.contains(&b'+')
180 && !bytes.iter().any(|&b| b.is_ascii_digit())
181 }
182
183 fn as_any(&self) -> &dyn std::any::Any {
184 self
185 }
186
187 fn default_config_section(&self) -> Option<(String, toml::Value)> {
188 let default_config = MD030Config::default();
189 let json_value = serde_json::to_value(&default_config).ok()?;
190 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
191
192 if let toml::Value::Table(table) = toml_value {
193 if !table.is_empty() {
194 Some((MD030Config::RULE_NAME.to_string(), toml::Value::Table(table)))
195 } else {
196 None
197 }
198 } else {
199 None
200 }
201 }
202
203 fn from_config(config: &crate::config::Config) -> Box<dyn Rule> {
204 let rule_config = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
205 Box::new(Self::from_config_struct(rule_config))
206 }
207
208 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
209 let content = ctx.content;
210
211 if self.should_skip(ctx) {
213 return Ok(content.to_string());
214 }
215
216 let lines: Vec<&str> = content.lines().collect();
217 let mut result_lines = Vec::with_capacity(lines.len());
218
219 for (line_idx, line) in lines.iter().enumerate() {
220 let line_num = line_idx + 1;
221
222 if let Some(line_info) = ctx.lines.get(line_idx)
224 && (line_info.in_code_block || line_info.in_front_matter || line_info.in_html_comment)
225 {
226 result_lines.push(line.to_string());
227 continue;
228 }
229
230 if self.is_indented_code_block(line, line_idx, &lines) {
232 result_lines.push(line.to_string());
233 continue;
234 }
235
236 let is_multi_line = self.is_multi_line_list_item(ctx, line_num, &lines);
241 if let Some(fixed_line) = self.try_fix_list_marker_spacing_with_context(line, is_multi_line) {
242 result_lines.push(fixed_line);
243 } else {
244 result_lines.push(line.to_string());
245 }
246 }
247
248 let result = result_lines.join("\n");
250 if content.ends_with('\n') && !result.ends_with('\n') {
251 Ok(result + "\n")
252 } else {
253 Ok(result)
254 }
255 }
256}
257
258impl MD030ListMarkerSpace {
259 fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
261 let current_line_info = match ctx.line_info(line_num) {
263 Some(info) if info.list_item.is_some() => info,
264 _ => return false,
265 };
266
267 let current_list = current_line_info.list_item.as_ref().unwrap();
268
269 for next_line_num in (line_num + 1)..=lines.len() {
271 if let Some(next_line_info) = ctx.line_info(next_line_num) {
272 if let Some(next_list) = &next_line_info.list_item {
274 if next_list.marker_column <= current_list.marker_column {
275 break; }
277 return true;
279 }
280
281 let line_content = lines.get(next_line_num - 1).unwrap_or(&"");
284 if !line_content.trim().is_empty() {
285 let expected_continuation_indent = current_list.content_column;
286 let actual_indent = line_content.len() - line_content.trim_start().len();
287
288 if actual_indent < expected_continuation_indent {
289 break; }
291
292 if actual_indent >= expected_continuation_indent {
294 return true;
295 }
296 }
297
298 }
300 }
301
302 false
303 }
304
305 fn fix_marker_spacing(
307 &self,
308 marker: &str,
309 after_marker: &str,
310 indent: &str,
311 is_multi_line: bool,
312 is_ordered: bool,
313 ) -> Option<String> {
314 if after_marker.starts_with('\t') {
318 return None;
319 }
320
321 let expected_spaces = if is_ordered {
323 if is_multi_line {
324 self.config.ol_multi.get()
325 } else {
326 self.config.ol_single.get()
327 }
328 } else if is_multi_line {
329 self.config.ul_multi.get()
330 } else {
331 self.config.ul_single.get()
332 };
333
334 if !after_marker.is_empty() && !after_marker.starts_with(' ') {
337 let spaces = " ".repeat(expected_spaces);
338 return Some(format!("{indent}{marker}{spaces}{after_marker}"));
339 }
340
341 if after_marker.starts_with(" ") {
343 let content = after_marker.trim_start_matches(' ');
344 if !content.is_empty() {
345 let spaces = " ".repeat(expected_spaces);
346 return Some(format!("{indent}{marker}{spaces}{content}"));
347 }
348 }
349
350 None
351 }
352
353 fn try_fix_list_marker_spacing_with_context(&self, line: &str, is_multi_line: bool) -> Option<String> {
355 let (blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
357
358 let trimmed = content.trim_start();
359 let indent = &content[..content.len() - trimmed.len()];
360
361 for marker in &["*", "-", "+"] {
363 if let Some(after_marker) = trimmed.strip_prefix(marker) {
364 if after_marker.starts_with(*marker) {
366 break;
367 }
368
369 if *marker == "*" && after_marker.contains('*') {
373 break;
374 }
375
376 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t') {
378 let first_char = after_marker.chars().next().unwrap_or(' ');
379
380 if (*marker == "-" || *marker == "+") && first_char.is_ascii_digit() {
382 break;
383 }
384
385 if *marker == "*" && first_char == '.' {
387 break;
388 }
389
390 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
394
395 if !is_clear_intent {
396 break;
397 }
398 }
399
400 if let Some(fixed) = self.fix_marker_spacing(marker, after_marker, indent, is_multi_line, false) {
401 return Some(format!("{blockquote_prefix}{fixed}"));
402 }
403 break; }
405 }
406
407 if let Some(dot_pos) = trimmed.find('.') {
409 let before_dot = &trimmed[..dot_pos];
410 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
411 let after_dot = &trimmed[dot_pos + 1..];
412
413 if after_dot.is_empty() {
415 return None;
416 }
417
418 if !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
420 let first_char = after_dot.chars().next().unwrap_or(' ');
421
422 if first_char.is_ascii_digit() {
424 return None;
425 }
426
427 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
431
432 if !is_clear_intent {
433 return None;
434 }
435 }
436 let marker = format!("{before_dot}.");
439 if let Some(fixed) = self.fix_marker_spacing(&marker, after_dot, indent, is_multi_line, true) {
440 return Some(format!("{blockquote_prefix}{fixed}"));
441 }
442 }
443 }
444
445 None
446 }
447
448 fn strip_blockquote_prefix(line: &str) -> (String, &str) {
450 let mut prefix = String::new();
451 let mut remaining = line;
452
453 loop {
454 let trimmed = remaining.trim_start();
455 if !trimmed.starts_with('>') {
456 break;
457 }
458 let leading_spaces = remaining.len() - trimmed.len();
460 prefix.push_str(&remaining[..leading_spaces]);
461 prefix.push('>');
462 remaining = &trimmed[1..];
463
464 if remaining.starts_with(' ') {
466 prefix.push(' ');
467 remaining = &remaining[1..];
468 }
469 }
470
471 (prefix, remaining)
472 }
473
474 fn check_unrecognized_list_marker(
477 &self,
478 ctx: &crate::lint_context::LintContext,
479 line: &str,
480 line_num: usize,
481 lines: &[&str],
482 ) -> Option<LintWarning> {
483 let (blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
485 let prefix_len = blockquote_prefix.len();
486
487 let trimmed = content.trim_start();
488 let indent_len = content.len() - trimmed.len();
489
490 for marker in &["*", "-", "+"] {
497 if let Some(after_marker) = trimmed.strip_prefix(marker) {
498 if after_marker.starts_with(*marker) {
501 break;
502 }
503
504 let emphasis_spans = ctx.emphasis_spans_on_line(line_num);
507 if emphasis_spans
508 .iter()
509 .any(|span| span.start_col == prefix_len + indent_len)
510 {
511 break;
512 }
513
514 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t') {
517 let first_char = after_marker.chars().next().unwrap_or(' ');
518
519 if (*marker == "-" || *marker == "+") && first_char.is_ascii_digit() {
521 break;
522 }
523
524 if *marker == "*" && first_char == '.' {
526 break;
527 }
528
529 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
534
535 if !is_clear_intent {
536 break;
537 }
538
539 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
540 let expected_spaces = self.get_expected_spaces(ListType::Unordered, is_multi_line);
541
542 let marker_pos = indent_len;
543 let marker_end = marker_pos + marker.len();
544
545 let (start_line, start_col, end_line, end_col) =
546 calculate_match_range(line_num, line, marker_end, 0);
547
548 let correct_spaces = " ".repeat(expected_spaces);
549 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
550 let fix_position = line_start_byte + marker_end;
551
552 return Some(LintWarning {
553 rule_name: Some("MD030".to_string()),
554 severity: Severity::Warning,
555 line: start_line,
556 column: start_col,
557 end_line,
558 end_column: end_col,
559 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
560 fix: Some(crate::rule::Fix {
561 range: fix_position..fix_position,
562 replacement: correct_spaces,
563 }),
564 });
565 }
566 break; }
568 }
569
570 if let Some(dot_pos) = trimmed.find('.') {
572 let before_dot = &trimmed[..dot_pos];
573 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
574 let after_dot = &trimmed[dot_pos + 1..];
575 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
577 let first_char = after_dot.chars().next().unwrap_or(' ');
578
579 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
584
585 if is_clear_intent {
586 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
587 let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
588
589 let marker = format!("{before_dot}.");
590 let marker_pos = indent_len;
591 let marker_end = marker_pos + marker.len();
592
593 let (start_line, start_col, end_line, end_col) =
594 calculate_match_range(line_num, line, marker_end, 0);
595
596 let correct_spaces = " ".repeat(expected_spaces);
597 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
598 let fix_position = line_start_byte + marker_end;
599
600 return Some(LintWarning {
601 rule_name: Some("MD030".to_string()),
602 severity: Severity::Warning,
603 line: start_line,
604 column: start_col,
605 end_line,
606 end_column: end_col,
607 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
608 fix: Some(crate::rule::Fix {
609 range: fix_position..fix_position,
610 replacement: correct_spaces,
611 }),
612 });
613 }
614 }
615 }
616 }
617
618 None
619 }
620
621 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
623 if line_num < lines.len() {
626 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
628 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
630 return true;
631 }
632 }
633 false
634 }
635
636 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
638 if ElementCache::calculate_indentation_width_default(line) < 4 {
640 return false;
641 }
642
643 if line_idx == 0 {
645 return false;
646 }
647
648 if self.has_blank_line_before_indented_block(line_idx, lines) {
650 return true;
651 }
652
653 false
654 }
655
656 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
658 let mut current_idx = line_idx;
660
661 while current_idx > 0 {
663 let current_line = lines[current_idx];
664 let prev_line = lines[current_idx - 1];
665
666 if ElementCache::calculate_indentation_width_default(current_line) < 4 {
668 break;
669 }
670
671 if ElementCache::calculate_indentation_width_default(prev_line) < 4 {
673 return prev_line.trim().is_empty();
674 }
675
676 current_idx -= 1;
677 }
678
679 false
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use crate::lint_context::LintContext;
687
688 #[test]
689 fn test_basic_functionality() {
690 let rule = MD030ListMarkerSpace::default();
691 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694 assert!(
695 result.is_empty(),
696 "Correctly spaced list markers should not generate warnings"
697 );
698 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
701 assert_eq!(
703 result.len(),
704 2,
705 "Should flag lines with too many spaces after list marker"
706 );
707 for warning in result {
708 assert!(
709 warning.message.starts_with("Spaces after list markers (Expected:")
710 && warning.message.contains("Actual:"),
711 "Warning message should include expected and actual values, got: '{}'",
712 warning.message
713 );
714 }
715 }
716}