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 let actual_spaces = list_info.content_column.saturating_sub(marker_end);
98
99 let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, &lines);
101 let expected_spaces = self.get_expected_spaces(list_type, is_multi_line);
102
103 if actual_spaces != expected_spaces {
104 let whitespace_start_pos = marker_end;
105 let whitespace_len = actual_spaces;
106
107 let (start_line, start_col, end_line, end_col) =
108 calculate_match_range(line_num_1based, line, whitespace_start_pos, whitespace_len);
109
110 let correct_spaces = " ".repeat(expected_spaces);
111 let line_start_byte = ctx.line_offsets.get(line_num).copied().unwrap_or(0);
112 let whitespace_start_byte = line_start_byte + whitespace_start_pos;
113 let whitespace_end_byte = whitespace_start_byte + whitespace_len;
114
115 let fix = Some(crate::rule::Fix {
116 range: whitespace_start_byte..whitespace_end_byte,
117 replacement: correct_spaces,
118 });
119
120 let message =
121 format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})");
122
123 warnings.push(LintWarning {
124 rule_name: Some(self.name().to_string()),
125 severity: Severity::Warning,
126 line: start_line,
127 column: start_col,
128 end_line,
129 end_column: end_col,
130 message,
131 fix,
132 });
133 }
134 }
135 }
136 }
137
138 for (line_idx, line) in lines.iter().enumerate() {
141 let line_num = line_idx + 1;
142
143 if processed_lines.contains(&line_num) {
145 continue;
146 }
147 if let Some(line_info) = ctx.lines.get(line_idx)
148 && (line_info.in_code_block
149 || line_info.in_front_matter
150 || line_info.in_html_comment
151 || line_info.in_math_block)
152 {
153 continue;
154 }
155
156 if self.is_indented_code_block(line, line_idx, &lines) {
158 continue;
159 }
160
161 if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, &lines) {
163 warnings.push(warning);
164 }
165 }
166
167 Ok(warnings)
168 }
169
170 fn category(&self) -> RuleCategory {
171 RuleCategory::List
172 }
173
174 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
175 if ctx.content.is_empty() {
176 return true;
177 }
178
179 let bytes = ctx.content.as_bytes();
181 !bytes.contains(&b'*')
182 && !bytes.contains(&b'-')
183 && !bytes.contains(&b'+')
184 && !bytes.iter().any(|&b| b.is_ascii_digit())
185 }
186
187 fn as_any(&self) -> &dyn std::any::Any {
188 self
189 }
190
191 fn default_config_section(&self) -> Option<(String, toml::Value)> {
192 let default_config = MD030Config::default();
193 let json_value = serde_json::to_value(&default_config).ok()?;
194 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
195
196 if let toml::Value::Table(table) = toml_value {
197 if !table.is_empty() {
198 Some((MD030Config::RULE_NAME.to_string(), toml::Value::Table(table)))
199 } else {
200 None
201 }
202 } else {
203 None
204 }
205 }
206
207 fn from_config(config: &crate::config::Config) -> Box<dyn Rule> {
208 let rule_config = crate::rule_config_serde::load_rule_config::<MD030Config>(config);
209 Box::new(Self::from_config_struct(rule_config))
210 }
211
212 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
213 let content = ctx.content;
214
215 if self.should_skip(ctx) {
217 return Ok(content.to_string());
218 }
219
220 let lines: Vec<&str> = content.lines().collect();
221 let mut result_lines = Vec::with_capacity(lines.len());
222
223 for (line_idx, line) in lines.iter().enumerate() {
224 let line_num = line_idx + 1;
225
226 if let Some(line_info) = ctx.lines.get(line_idx)
228 && (line_info.in_code_block || line_info.in_front_matter || line_info.in_html_comment)
229 {
230 result_lines.push(line.to_string());
231 continue;
232 }
233
234 if self.is_indented_code_block(line, line_idx, &lines) {
236 result_lines.push(line.to_string());
237 continue;
238 }
239
240 let is_multi_line = self.is_multi_line_list_item(ctx, line_num, &lines);
245 if let Some(fixed_line) = self.try_fix_list_marker_spacing_with_context(line, is_multi_line) {
246 result_lines.push(fixed_line);
247 } else {
248 result_lines.push(line.to_string());
249 }
250 }
251
252 let result = result_lines.join("\n");
254 if content.ends_with('\n') && !result.ends_with('\n') {
255 Ok(result + "\n")
256 } else {
257 Ok(result)
258 }
259 }
260}
261
262impl MD030ListMarkerSpace {
263 fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
265 let current_line_info = match ctx.line_info(line_num) {
267 Some(info) if info.list_item.is_some() => info,
268 _ => return false,
269 };
270
271 let current_list = current_line_info.list_item.as_ref().unwrap();
272
273 for next_line_num in (line_num + 1)..=lines.len() {
275 if let Some(next_line_info) = ctx.line_info(next_line_num) {
276 if let Some(next_list) = &next_line_info.list_item {
278 if next_list.marker_column <= current_list.marker_column {
279 break; }
281 return true;
283 }
284
285 let line_content = lines.get(next_line_num - 1).unwrap_or(&"");
288 if !line_content.trim().is_empty() {
289 let expected_continuation_indent = current_list.content_column;
290 let actual_indent = line_content.len() - line_content.trim_start().len();
291
292 if actual_indent < expected_continuation_indent {
293 break; }
295
296 if actual_indent >= expected_continuation_indent {
298 return true;
299 }
300 }
301
302 }
304 }
305
306 false
307 }
308
309 fn fix_marker_spacing(
311 &self,
312 marker: &str,
313 after_marker: &str,
314 indent: &str,
315 is_multi_line: bool,
316 is_ordered: bool,
317 ) -> Option<String> {
318 if after_marker.starts_with('\t') {
322 return None;
323 }
324
325 let expected_spaces = if is_ordered {
327 if is_multi_line {
328 self.config.ol_multi.get()
329 } else {
330 self.config.ol_single.get()
331 }
332 } else if is_multi_line {
333 self.config.ul_multi.get()
334 } else {
335 self.config.ul_single.get()
336 };
337
338 if !after_marker.is_empty() && !after_marker.starts_with(' ') {
341 let spaces = " ".repeat(expected_spaces);
342 return Some(format!("{indent}{marker}{spaces}{after_marker}"));
343 }
344
345 if after_marker.starts_with(" ") {
347 let content = after_marker.trim_start_matches(' ');
348 if !content.is_empty() {
349 let spaces = " ".repeat(expected_spaces);
350 return Some(format!("{indent}{marker}{spaces}{content}"));
351 }
352 }
353
354 None
355 }
356
357 fn try_fix_list_marker_spacing_with_context(&self, line: &str, is_multi_line: bool) -> Option<String> {
359 let (blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
361
362 let trimmed = content.trim_start();
363 let indent = &content[..content.len() - trimmed.len()];
364
365 for marker in &["*", "-", "+"] {
368 if let Some(after_marker) = trimmed.strip_prefix(marker) {
369 if after_marker.starts_with(*marker) {
371 break;
372 }
373
374 if *marker == "*" && after_marker.contains('*') {
376 break;
377 }
378
379 if after_marker.starts_with(" ")
382 && let Some(fixed) = self.fix_marker_spacing(marker, after_marker, indent, is_multi_line, false)
383 {
384 return Some(format!("{blockquote_prefix}{fixed}"));
385 }
386 break; }
388 }
389
390 if let Some(dot_pos) = trimmed.find('.') {
392 let before_dot = &trimmed[..dot_pos];
393 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
394 let after_dot = &trimmed[dot_pos + 1..];
395
396 if after_dot.is_empty() {
398 return None;
399 }
400
401 if !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
403 let first_char = after_dot.chars().next().unwrap_or(' ');
404
405 if first_char.is_ascii_digit() {
407 return None;
408 }
409
410 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
414
415 if !is_clear_intent {
416 return None;
417 }
418 }
419 let marker = format!("{before_dot}.");
422 if let Some(fixed) = self.fix_marker_spacing(&marker, after_dot, indent, is_multi_line, true) {
423 return Some(format!("{blockquote_prefix}{fixed}"));
424 }
425 }
426 }
427
428 None
429 }
430
431 fn strip_blockquote_prefix(line: &str) -> (String, &str) {
433 let mut prefix = String::new();
434 let mut remaining = line;
435
436 loop {
437 let trimmed = remaining.trim_start();
438 if !trimmed.starts_with('>') {
439 break;
440 }
441 let leading_spaces = remaining.len() - trimmed.len();
443 prefix.push_str(&remaining[..leading_spaces]);
444 prefix.push('>');
445 remaining = &trimmed[1..];
446
447 if remaining.starts_with(' ') {
449 prefix.push(' ');
450 remaining = &remaining[1..];
451 }
452 }
453
454 (prefix, remaining)
455 }
456
457 fn check_unrecognized_list_marker(
460 &self,
461 ctx: &crate::lint_context::LintContext,
462 line: &str,
463 line_num: usize,
464 lines: &[&str],
465 ) -> Option<LintWarning> {
466 let (_blockquote_prefix, content) = Self::strip_blockquote_prefix(line);
468
469 let trimmed = content.trim_start();
470 let indent_len = content.len() - trimmed.len();
471
472 if let Some(dot_pos) = trimmed.find('.') {
479 let before_dot = &trimmed[..dot_pos];
480 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
481 let after_dot = &trimmed[dot_pos + 1..];
482 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
484 let first_char = after_dot.chars().next().unwrap_or(' ');
485
486 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
491
492 if is_clear_intent {
493 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
494 let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
495
496 let marker = format!("{before_dot}.");
497 let marker_pos = indent_len;
498 let marker_end = marker_pos + marker.len();
499
500 let (start_line, start_col, end_line, end_col) =
501 calculate_match_range(line_num, line, marker_end, 0);
502
503 let correct_spaces = " ".repeat(expected_spaces);
504 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
505 let fix_position = line_start_byte + marker_end;
506
507 return Some(LintWarning {
508 rule_name: Some("MD030".to_string()),
509 severity: Severity::Warning,
510 line: start_line,
511 column: start_col,
512 end_line,
513 end_column: end_col,
514 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
515 fix: Some(crate::rule::Fix {
516 range: fix_position..fix_position,
517 replacement: correct_spaces,
518 }),
519 });
520 }
521 }
522 }
523 }
524
525 None
526 }
527
528 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
530 if line_num < lines.len() {
533 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
535 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
537 return true;
538 }
539 }
540 false
541 }
542
543 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
545 if ElementCache::calculate_indentation_width_default(line) < 4 {
547 return false;
548 }
549
550 if line_idx == 0 {
552 return false;
553 }
554
555 if self.has_blank_line_before_indented_block(line_idx, lines) {
557 return true;
558 }
559
560 false
561 }
562
563 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
565 let mut current_idx = line_idx;
567
568 while current_idx > 0 {
570 let current_line = lines[current_idx];
571 let prev_line = lines[current_idx - 1];
572
573 if ElementCache::calculate_indentation_width_default(current_line) < 4 {
575 break;
576 }
577
578 if ElementCache::calculate_indentation_width_default(prev_line) < 4 {
580 return prev_line.trim().is_empty();
581 }
582
583 current_idx -= 1;
584 }
585
586 false
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use crate::lint_context::LintContext;
594
595 #[test]
596 fn test_basic_functionality() {
597 let rule = MD030ListMarkerSpace::default();
598 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let result = rule.check(&ctx).unwrap();
601 assert!(
602 result.is_empty(),
603 "Correctly spaced list markers should not generate warnings"
604 );
605 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607 let result = rule.check(&ctx).unwrap();
608 assert_eq!(
610 result.len(),
611 2,
612 "Should flag lines with too many spaces after list marker"
613 );
614 for warning in result {
615 assert!(
616 warning.message.starts_with("Spaces after list markers (Expected:")
617 && warning.message.contains("Actual:"),
618 "Warning message should include expected and actual values, got: '{}'",
619 warning.message
620 );
621 }
622 }
623
624 #[test]
625 fn test_nested_emphasis_not_flagged_issue_278() {
626 let rule = MD030ListMarkerSpace::default();
628
629 let content = "*This text is **very** important*";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632 let result = rule.check(&ctx).unwrap();
633 assert!(
634 result.is_empty(),
635 "Nested emphasis should not trigger MD030, got: {result:?}"
636 );
637
638 let content2 = "*Hello World*";
640 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
641 let result2 = rule.check(&ctx2).unwrap();
642 assert!(
643 result2.is_empty(),
644 "Simple emphasis should not trigger MD030, got: {result2:?}"
645 );
646
647 let content3 = "**bold text**";
649 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
650 let result3 = rule.check(&ctx3).unwrap();
651 assert!(
652 result3.is_empty(),
653 "Bold text should not trigger MD030, got: {result3:?}"
654 );
655
656 let content4 = "***bold and italic***";
658 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
659 let result4 = rule.check(&ctx4).unwrap();
660 assert!(
661 result4.is_empty(),
662 "Bold+italic should not trigger MD030, got: {result4:?}"
663 );
664
665 let content5 = "* Item with space";
667 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
668 let result5 = rule.check(&ctx5).unwrap();
669 assert!(
670 result5.is_empty(),
671 "Properly spaced list item should not trigger MD030, got: {result5:?}"
672 );
673 }
674}