1use crate::rule::{LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::blockquote::parse_blockquote_prefix;
8use crate::utils::calculate_indentation_width_default;
9use crate::utils::list_fix_guard::{Allowed, drop_structure_changing_fixes, protect_literal_code};
10use crate::utils::list_indent_shift::{
11 Continuation, EndedItems, Nesting, OwnerFrame, classify_continuation, close_ended_items, continuation_params,
12 keep_ended_items_closed, move_owned_line,
13};
14use crate::utils::range_utils::calculate_match_range;
15use toml;
16
17mod md030_config;
18pub use md030_config::MD030Config;
19
20#[derive(Clone, Default)]
21pub struct MD030ListMarkerSpace {
22 config: MD030Config,
23}
24
25impl MD030ListMarkerSpace {
26 pub fn new(ul_single: usize, ul_multi: usize, ol_single: usize, ol_multi: usize) -> Self {
27 Self {
28 config: MD030Config {
29 ul_single: crate::types::PositiveUsize::new(ul_single)
30 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
31 ul_multi: crate::types::PositiveUsize::new(ul_multi)
32 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
33 ol_single: crate::types::PositiveUsize::new(ol_single)
34 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
35 ol_multi: crate::types::PositiveUsize::new(ol_multi)
36 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
37 ol_align_column: crate::types::OlAlignColumn::default(),
38 },
39 }
40 }
41
42 fn from_config_struct(config: MD030Config) -> Self {
43 Self { config }
44 }
45
46 #[cfg(test)]
50 fn with_ol_align_column(mut self, column: usize) -> Self {
51 self.config.ol_align_column =
52 crate::types::OlAlignColumn::new(column).expect("test ol-align-column out of range");
53 self
54 }
55}
56
57impl Rule for MD030ListMarkerSpace {
58 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
59 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(30))
60 }
61
62 fn name(&self) -> &'static str {
63 "MD030"
64 }
65
66 fn description(&self) -> &'static str {
67 "Spaces after list markers should be consistent"
68 }
69
70 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
71 Ok(self.check_with_clean_proof(ctx, true))
72 }
73
74 fn category(&self) -> RuleCategory {
75 RuleCategory::List
76 }
77
78 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
79 if ctx.content.is_empty() {
80 return true;
81 }
82
83 let bytes = ctx.content.as_bytes();
85 !bytes.contains(&b'*')
86 && !bytes.contains(&b'-')
87 && !bytes.contains(&b'+')
88 && !bytes.iter().any(|&b| b.is_ascii_digit())
89 }
90
91 fn as_any(&self) -> &dyn std::any::Any {
92 self
93 }
94
95 crate::impl_rule_config_methods!(MD030Config);
96
97 fn fixes_from_diagnostics(&self) -> bool {
98 true
99 }
100
101 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
102 if self.should_skip(ctx) {
103 return Ok(ctx.content.to_string());
104 }
105
106 let warnings = self.check(ctx)?;
110 if warnings.is_empty() {
111 return Ok(ctx.content.to_string());
112 }
113
114 let warnings =
115 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
116
117 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
118 .map_err(crate::rule::LintError::InvalidInput)
119 }
120}
121
122impl MD030ListMarkerSpace {
123 fn is_checked_list_item(info: &crate::lint_context::LineInfo) -> bool {
124 info.list_item.is_some()
125 && !info.in_code_block
126 && !info.in_math_block
127 && !info.in_pymdown_block
128 && !info.in_mkdocs_html_markdown
129 && !info.in_footnote_definition
130 }
131
132 fn recognized_spacing_is_clean(&self, ctx: &crate::lint_context::LintContext, lines: &[&str]) -> bool {
137 if self.config.ul_single != self.config.ul_multi
138 || (self.config.ol_align_column.enabled().is_none() && self.config.ol_single != self.config.ol_multi)
139 {
140 return false;
141 }
142 ctx.list_items().all(|item| {
143 if !Self::is_checked_list_item(item.line_info()) {
144 return true;
145 }
146 let line = lines[item.line_num() - 1];
147 let end = item.marker_column() + item.marker().len();
148 if !Self::has_content_after_marker(line, end) {
149 return true;
150 }
151 if item.content_column().saturating_sub(end)
152 != self
153 .config
154 .expected_spaces(item.is_ordered(), false, item.marker().len())
155 {
156 return false;
157 }
158 !item.is_ordered()
159 || Self::inline_unordered_spaces(line, item.content_column())
160 .is_none_or(|(_, actual)| actual == self.config.expected_spaces(false, false, 1))
161 })
162 }
163
164 fn check_with_clean_proof(
165 &self,
166 ctx: &crate::lint_context::LintContext,
167 allow_clean_proof: bool,
168 ) -> Vec<LintWarning> {
169 let mut warnings = Vec::new();
170
171 if self.should_skip(ctx) {
173 return warnings;
174 }
175
176 let lines = ctx.raw_lines();
177
178 let recognized_clean = allow_clean_proof && self.recognized_spacing_is_clean(ctx, lines);
179
180 let mut processed_lines = std::collections::HashSet::new();
182
183 if !recognized_clean {
184 let mut stack: Vec<OwnerFrame> = Vec::new();
195 let mut list_warnings = 0;
200 let mut unfixable = false;
201
202 for (line_num, line_info) in ctx.lines.iter().enumerate() {
205 let line_num_1based = line_num + 1;
206 let line = lines[line_num];
207
208 let before = stack.clone();
211 let lazy = close_ended_items(ctx, line_num_1based, lines, &mut stack);
212
213 let is_list_item = Self::is_checked_list_item(line_info);
215
216 if !is_list_item {
217 if !lazy && !line.trim().is_empty() {
220 let mut owners = stack.clone();
221 match keep_ended_items_closed(ctx, line_num, &before, stack.len(), Nesting::Relative) {
222 EndedItems::StayClosed => {}
223 EndedItems::ShiftOut(frame) => owners.push(frame),
224 EndedItems::Unfixable => unfixable = true,
225 }
226 move_owned_line(ctx, line, line_num, &owners, &mut warnings);
227 }
228 continue;
229 }
230
231 processed_lines.insert(line_num_1based);
232 let Some(list_info) = &line_info.list_item else {
233 continue;
234 };
235
236 if stack.is_empty() {
237 if std::mem::take(&mut unfixable) {
238 Self::decline_fixes(&mut warnings[list_warnings..]);
239 }
240 list_warnings = warnings.len();
241 }
242
243 move_owned_line(ctx, line, line_num, &stack, &mut warnings);
245
246 let marker_end = list_info.marker_column + list_info.marker.len();
247
248 if !Self::has_content_after_marker(line, marker_end) {
250 continue;
251 }
252
253 let actual_spaces = list_info.content_column.saturating_sub(marker_end);
254
255 let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, lines);
258 let expected_spaces =
259 self.config
260 .expected_spaces(list_info.is_ordered, is_multi_line, list_info.marker.len());
261
262 let warning = (actual_spaces != expected_spaces).then(|| {
263 warnings.push(self.spacing_fix_warning(
264 ctx,
265 line,
266 line_num,
267 marker_end..marker_end + actual_spaces,
268 expected_spaces,
269 format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})"),
270 ));
271 warnings.len() - 1
272 });
273
274 let own = expected_spaces as isize - actual_spaces as isize;
277 if let Some(frame) = OwnerFrame::for_item(ctx, line_num_1based, own, warning) {
278 stack.push(frame);
279 if list_info.is_ordered {
280 self.align_inline_bullet(
281 ctx,
282 line_num_1based,
283 lines,
284 list_info.content_column,
285 &mut stack,
286 &mut warnings,
287 );
288 }
289 }
290 }
291
292 if unfixable {
293 Self::decline_fixes(&mut warnings[list_warnings..]);
294 }
295 protect_literal_code(ctx, &mut warnings);
298 drop_structure_changing_fixes(ctx, &mut warnings, Allowed::Nothing);
299 }
300
301 for (line_idx, line) in lines.iter().enumerate() {
304 let line_num = line_idx + 1;
305
306 if processed_lines.contains(&line_num)
308 || (recognized_clean && Self::is_checked_list_item(&ctx.lines[line_idx]))
309 {
310 continue;
311 }
312 if let Some(line_info) = ctx.lines.get(line_idx)
313 && (line_info.in_code_block
314 || line_info.in_front_matter
315 || line_info.in_html_comment
316 || line_info.in_mdx_comment
317 || line_info.in_math_block
318 || line_info.in_pymdown_block
319 || line_info.in_mkdocs_html_markdown
320 || line_info.in_footnote_definition)
321 {
322 continue;
323 }
324
325 if allow_clean_proof {
328 let content = parse_blockquote_prefix(line).map_or(*line, |p| p.content);
329 if !content.trim_start().as_bytes().first().is_some_and(u8::is_ascii_digit) {
330 continue;
331 }
332 }
333
334 if self.is_indented_code_block(line, line_idx, lines) {
336 continue;
337 }
338
339 if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, lines) {
341 warnings.push(warning);
342 }
343 }
344
345 protect_literal_code(ctx, &mut warnings);
347 warnings
348 }
349
350 #[inline]
354 fn decline_fixes(warnings: &mut [LintWarning]) {
358 for warning in warnings {
359 warning.fix = None;
360 }
361 }
362
363 fn has_content_after_marker(line: &str, marker_end: usize) -> bool {
364 if marker_end >= line.len() {
365 return false;
366 }
367 !line[marker_end..].trim().is_empty()
368 }
369
370 fn spacing_fix_warning(
374 &self,
375 ctx: &crate::lint_context::LintContext,
376 line: &str,
377 line_idx: usize,
378 span: std::ops::Range<usize>,
379 want: usize,
380 message: String,
381 ) -> LintWarning {
382 let (start_line, start_col, end_line, end_col) =
383 calculate_match_range(line_idx + 1, line, span.start, span.len());
384 let base = ctx.line_offsets.get(line_idx).copied().unwrap_or(0);
385 LintWarning {
386 rule_name: Some(self.name().to_string()),
387 severity: Severity::Warning,
388 line: start_line,
389 column: start_col,
390 end_line,
391 end_column: end_col,
392 message,
393 fix: Some(crate::rule::Fix::new(
394 base + span.start..base + span.end,
395 " ".repeat(want),
396 )),
397 }
398 }
399
400 fn inline_unordered_spaces(line: &str, content_col: usize) -> Option<(usize, usize)> {
405 if !matches!(line.as_bytes().get(content_col), Some(b'-' | b'*' | b'+')) {
406 return None;
407 }
408 let offset = content_col + 1;
409 let rest = line.get(offset..)?;
410 let spaces = rest.len() - rest.trim_start_matches(' ').len();
411 if spaces == 0 || rest[spaces..].is_empty() {
412 return None;
413 }
414 Some((offset, spaces))
415 }
416
417 fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
429 let Some((marker_column, bq_level, min_indent)) = continuation_params(ctx, line_num) else {
430 return false;
431 };
432 Self::has_continuation(ctx, line_num, lines, marker_column, bq_level, min_indent)
433 }
434
435 fn has_continuation(
439 ctx: &crate::lint_context::LintContext,
440 line_num: usize,
441 lines: &[&str],
442 marker_column: usize,
443 bq_level: usize,
444 min_indent: usize,
445 ) -> bool {
446 for next in (line_num + 1)..=lines.len() {
447 match classify_continuation(ctx, next, lines, marker_column, bq_level, min_indent) {
448 Continuation::Belongs => return true,
449 Continuation::Ends => break,
450 Continuation::Skip => {}
451 }
452 }
453 false
454 }
455
456 fn align_inline_bullet(
461 &self,
462 ctx: &crate::lint_context::LintContext,
463 line_num: usize,
464 lines: &[&str],
465 content_column: usize,
466 stack: &mut Vec<OwnerFrame>,
467 warnings: &mut Vec<LintWarning>,
468 ) {
469 let line = lines[line_num - 1];
470 let Some((offset, spaces)) = Self::inline_unordered_spaces(line, content_column) else {
471 return;
472 };
473 let bullet_content_col = offset + spaces;
474 let multi = Self::has_continuation(ctx, line_num, lines, content_column, 0, bullet_content_col);
478 let want = self.config.expected_spaces(false, multi, 1);
479 if spaces == want {
480 return;
481 }
482 warnings.push(self.spacing_fix_warning(
483 ctx,
484 line,
485 line_num - 1,
486 offset..offset + spaces,
487 want,
488 format!("Spaces after list markers (Expected: {want}; Actual: {spaces})"),
489 ));
490 stack.push(OwnerFrame {
491 marker_column: content_column,
492 bq_level: 0,
493 min_indent: bullet_content_col,
494 own: want as isize - spaces as isize,
495 warning: Some(warnings.len() - 1),
496 });
497 }
498
499 fn check_unrecognized_list_marker(
502 &self,
503 ctx: &crate::lint_context::LintContext,
504 line: &str,
505 line_num: usize,
506 lines: &[&str],
507 ) -> Option<LintWarning> {
508 let (bq_prefix_len, content) = match parse_blockquote_prefix(line) {
511 Some(parsed) => (parsed.prefix.len(), parsed.content),
512 None => (0, line),
513 };
514
515 let trimmed = content.trim_start();
516 let indent_len = content.len() - trimmed.len();
517
518 if let Some(dot_pos) = trimmed.find('.') {
525 let before_dot = &trimmed[..dot_pos];
526 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
527 let after_dot = &trimmed[dot_pos + 1..];
528 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
530 let first_char = after_dot.chars().next().unwrap_or(' ');
531
532 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
537
538 if is_clear_intent {
539 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
540 let expected_spaces = self.config.expected_spaces(true, is_multi_line, before_dot.len() + 1);
542
543 let marker = format!("{before_dot}.");
544 let marker_pos = indent_len;
545 let marker_end = marker_pos + marker.len();
546 let offset_in_line = bq_prefix_len + marker_end;
548
549 let (start_line, start_col, end_line, end_col) =
550 calculate_match_range(line_num, line, offset_in_line, 0);
551
552 let correct_spaces = " ".repeat(expected_spaces);
553 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
554 let fix_position = line_start_byte + offset_in_line;
555
556 return Some(LintWarning {
557 rule_name: Some("MD030".to_string()),
558 severity: Severity::Warning,
559 line: start_line,
560 column: start_col,
561 end_line,
562 end_column: end_col,
563 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
564 fix: Some(crate::rule::Fix::new(fix_position..fix_position, correct_spaces)),
565 });
566 }
567 }
568 }
569 }
570
571 None
572 }
573
574 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
576 if line_num < lines.len() {
579 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
581 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
583 return true;
584 }
585 }
586 false
587 }
588
589 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
591 if calculate_indentation_width_default(line) < 4 {
593 return false;
594 }
595
596 if line_idx == 0 {
598 return false;
599 }
600
601 if self.has_blank_line_before_indented_block(line_idx, lines) {
603 return true;
604 }
605
606 false
607 }
608
609 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
611 let mut current_idx = line_idx;
613
614 while current_idx > 0 {
616 let current_line = lines[current_idx];
617 let prev_line = lines[current_idx - 1];
618
619 if calculate_indentation_width_default(current_line) < 4 {
621 break;
622 }
623
624 if calculate_indentation_width_default(prev_line) < 4 {
626 return prev_line.trim().is_empty();
627 }
628
629 current_idx -= 1;
630 }
631
632 false
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use crate::lint_context::LintContext;
640 use indoc::indoc;
641
642 fn assert_fix_resolves_all_violations(rule: &MD030ListMarkerSpace, content: &str) {
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let before = rule.check(&ctx).unwrap();
647 assert!(
648 !before.is_empty(),
649 "Expected violations but check() found none in:\n{content}"
650 );
651
652 let fixed = rule.fix(&ctx).unwrap();
653 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
654 let after = rule.check(&ctx_fixed).unwrap();
655 assert!(
656 after.is_empty(),
657 "fix() left {} violation(s) unresolved:\n{:?}\nOriginal:\n{content}\nFixed:\n{fixed}",
658 after.len(),
659 after
660 );
661 }
662
663 fn assert_clean_proof_parity(source: &str, flavor: crate::config::MarkdownFlavor, rule: &MD030ListMarkerSpace) {
664 let ctx = LintContext::new(source, flavor, None);
665 assert_eq!(
666 rule.check_with_clean_proof(&ctx, true),
667 rule.check_with_clean_proof(&ctx, false),
668 "{flavor:?} {source:?}"
669 );
670 }
671
672 #[test]
673 fn clean_proof_preserves_nested_and_malformed_list_fixes() {
674 for source in [
675 "- parent\n - child\n paragraph\n",
676 "1. - nested\n - sibling\n",
677 "1. - nested\n continuation\n",
678 "- parent\n\n - child\n\n paragraph\n",
679 "> - list\n> continuation\n> 1.Item\n",
680 "- ```md\n 1.Bad\n ```\n\n1.Text\n",
681 "!!! note\n - list\n 1.Text\n",
682 "<div markdown>\n- item\n</div>\n",
683 "---\ntitle: 1.Text\n---\n\n1.Text\n",
684 "{::comment}\n- item\n{:/comment}\n",
685 "- \titem\n\t1.Text\n",
686 "- é\r\n 1.Text\r\n",
687 "> > 1.Text\n-text\n*text\n+text\n>\t1.Text\n",
688 ] {
689 for flavor in [
690 crate::config::MarkdownFlavor::Standard,
691 crate::config::MarkdownFlavor::MkDocs,
692 crate::config::MarkdownFlavor::MDX,
693 crate::config::MarkdownFlavor::Kramdown,
694 ] {
695 for rule in [
696 MD030ListMarkerSpace::default(),
697 MD030ListMarkerSpace::new(2, 2, 3, 3),
698 MD030ListMarkerSpace::new(1, 2, 1, 2),
699 MD030ListMarkerSpace::default().with_ol_align_column(4),
700 ] {
701 assert_clean_proof_parity(source, flavor, &rule);
702 }
703 }
704 }
705 }
706
707 proptest::proptest! {
708 #[test]
709 fn clean_proof_matches_full_ownership_walk(
710 fragments in proptest::collection::vec(0usize..29, 0..45),
711 flavor_index in 0usize..4,
712 spacing in (1usize..5, 1usize..5, 1usize..5, 1usize..5),
713 uniform in proptest::bool::ANY,
714 aligned in proptest::bool::ANY,
715 ) {
716 let pieces = ["- item\n", "- item\n", "1. item\n", "1.Text\n", "1. - x\n",
717 "1. - x\n", " nested\n", " code\n", "> - item\n", "> body\n", "\n",
718 "```\n", "~~~\n", "<!--\n", "-->\n", "!!! note\n", "<div markdown>\n",
719 "</div>\n", "é 🦀\n", "\t1.Text\n", "{::comment}\n", "{:/comment}\n",
720 "[^note]: - x\n", " $$\n", "> > 1.Text\n", "-text\n", "*text\n", "+text\n", ">\t1.Text\n"];
721 let source: String = fragments.into_iter().map(|i| pieces[i]).collect();
722 let flavor = [crate::config::MarkdownFlavor::Standard, crate::config::MarkdownFlavor::MkDocs,
723 crate::config::MarkdownFlavor::MDX, crate::config::MarkdownFlavor::Kramdown][flavor_index];
724 let mut rule = MD030ListMarkerSpace::new(spacing.0, if uniform {spacing.0} else {spacing.1},
725 spacing.2, if uniform {spacing.2} else {spacing.3});
726 if aligned {rule = rule.with_ol_align_column(4);}
727 assert_clean_proof_parity(&source, flavor, &rule);
728 }
729 }
730
731 #[test]
732 fn test_fix_keeps_a_parent_paragraph_out_of_the_shifted_child() {
733 let rule = MD030ListMarkerSpace::default();
736 let content = "* parent\n\n * child\n\n Parent paragraph.\n";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 assert_eq!(
739 rule.fix(&ctx).unwrap(),
740 "* parent\n\n * child\n\n Parent paragraph.\n"
741 );
742 }
743
744 #[test]
745 fn test_unfixable_list_leaves_other_lists_fixable() {
746 let rule = MD030ListMarkerSpace::default();
749 let content = "- fixable\n\nParagraph.\n\n 2) item\n\n code\n";
750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751 let warnings = rule.check(&ctx).unwrap();
752 assert_eq!(warnings.len(), 2, "{warnings:?}");
753 assert!(warnings[0].fix.is_some(), "{warnings:?}");
754 assert!(warnings[1].fix.is_none(), "{warnings:?}");
755 assert_eq!(
756 rule.fix(&ctx).unwrap(),
757 "- fixable\n\nParagraph.\n\n 2) item\n\n code\n"
758 );
759 }
760
761 #[test]
762 fn test_unfixable_list_leaves_a_later_list_fixable() {
763 let rule = MD030ListMarkerSpace::default();
764 let content = " 2) item\n\n code\n\nParagraph.\n\n- fixable\n";
765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
766 let warnings = rule.check(&ctx).unwrap();
767 assert_eq!(warnings.len(), 2, "{warnings:?}");
768 assert!(warnings[0].fix.is_none(), "{warnings:?}");
769 assert!(warnings[1].fix.is_some(), "{warnings:?}");
770 assert_eq!(
771 rule.fix(&ctx).unwrap(),
772 " 2) item\n\n code\n\nParagraph.\n\n- fixable\n"
773 );
774 }
775
776 #[test]
777 fn test_basic_functionality() {
778 let rule = MD030ListMarkerSpace::default();
779 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782 assert!(
783 result.is_empty(),
784 "Correctly spaced list markers should not generate warnings"
785 );
786 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
788 let result = rule.check(&ctx).unwrap();
789 assert_eq!(
791 result.len(),
792 2,
793 "Should flag lines with too many spaces after list marker"
794 );
795 for warning in result {
796 assert!(
797 warning.message.starts_with("Spaces after list markers (Expected:")
798 && warning.message.contains("Actual:"),
799 "Warning message should include expected and actual values, got: '{}'",
800 warning.message
801 );
802 }
803 }
804
805 #[test]
806 fn test_nested_emphasis_not_flagged_issue_278() {
807 let rule = MD030ListMarkerSpace::default();
809
810 let content = "*This text is **very** important*";
812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813 let result = rule.check(&ctx).unwrap();
814 assert!(
815 result.is_empty(),
816 "Nested emphasis should not trigger MD030, got: {result:?}"
817 );
818
819 let content2 = "*Hello World*";
821 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
822 let result2 = rule.check(&ctx2).unwrap();
823 assert!(
824 result2.is_empty(),
825 "Simple emphasis should not trigger MD030, got: {result2:?}"
826 );
827
828 let content3 = "**bold text**";
830 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
831 let result3 = rule.check(&ctx3).unwrap();
832 assert!(
833 result3.is_empty(),
834 "Bold text should not trigger MD030, got: {result3:?}"
835 );
836
837 let content4 = "***bold and italic***";
839 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
840 let result4 = rule.check(&ctx4).unwrap();
841 assert!(
842 result4.is_empty(),
843 "Bold+italic should not trigger MD030, got: {result4:?}"
844 );
845
846 let content5 = "* Item with space";
848 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
849 let result5 = rule.check(&ctx5).unwrap();
850 assert!(
851 result5.is_empty(),
852 "Properly spaced list item should not trigger MD030, got: {result5:?}"
853 );
854 }
855
856 #[test]
857 fn test_empty_marker_line_not_flagged_issue_288() {
858 let rule = MD030ListMarkerSpace::default();
861
862 let content = "-\n ```python\n print(\"code\")\n ```\n";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx).unwrap();
866 assert!(
867 result.is_empty(),
868 "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
869 );
870
871 let content = "1.\n ```python\n print(\"code\")\n ```\n";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 let result = rule.check(&ctx).unwrap();
875 assert!(
876 result.is_empty(),
877 "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
878 );
879
880 let content = "-\n This is a paragraph continuation\n of the list item.\n";
882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883 let result = rule.check(&ctx).unwrap();
884 assert!(
885 result.is_empty(),
886 "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
887 );
888
889 let content = "- Parent item\n -\n Nested content\n";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892 let result = rule.check(&ctx).unwrap();
893 assert!(
894 result.is_empty(),
895 "Nested empty marker line should not trigger MD030, got: {result:?}"
896 );
897
898 let content = "- Item with content\n-\n Code block\n- Another item\n";
900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901 let result = rule.check(&ctx).unwrap();
902 assert!(
903 result.is_empty(),
904 "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
905 );
906 }
907
908 #[test]
909 fn test_marker_with_content_still_flagged_issue_288() {
910 let rule = MD030ListMarkerSpace::default();
912
913 let content = "- Two spaces before content\n";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let result = rule.check(&ctx).unwrap();
917 assert_eq!(
918 result.len(),
919 1,
920 "Two spaces after unordered marker should still trigger MD030"
921 );
922
923 let content = "1. Two spaces\n";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let result = rule.check(&ctx).unwrap();
927 assert_eq!(
928 result.len(),
929 1,
930 "Two spaces after ordered marker should still trigger MD030"
931 );
932
933 let content = "- Normal item\n";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let result = rule.check(&ctx).unwrap();
937 assert!(
938 result.is_empty(),
939 "Normal list item should not trigger MD030, got: {result:?}"
940 );
941 }
942
943 #[test]
944 fn test_nested_items_with_4space_indent_are_detected() {
945 let rule = MD030ListMarkerSpace::new(3, 3, 1, 1);
950
951 let content = "- Top-level correct\n - Nested wrong spacing\n - Nested correct\n";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let result = rule.check(&ctx).unwrap();
956 assert_eq!(
957 result.len(),
958 1,
959 "Nested item with 1 space (ul_single=3) should be flagged; got: {result:?}"
960 );
961 assert_eq!(result[0].line, 2, "Violation should be on line 2");
962 assert!(
963 result[0].message.contains("Expected: 3") && result[0].message.contains("Actual: 1"),
964 "Message should state expected/actual spaces; got: {}",
965 result[0].message
966 );
967
968 let fixed = rule.fix(&ctx).unwrap();
970 assert_eq!(
971 fixed, "- Top-level correct\n - Nested wrong spacing\n - Nested correct\n",
972 "fix() should expand 1 space to ul_single=3 on the nested item"
973 );
974
975 let content_ok = "- Top-level\n - Nested correct\n";
977 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
978 let result_ok = rule.check(&ctx_ok).unwrap();
979 assert!(
980 result_ok.is_empty(),
981 "Nested item with correct spacing should not be flagged; got: {result_ok:?}"
982 );
983
984 let rule_ol = MD030ListMarkerSpace::new(1, 1, 2, 2);
986 let content_ol = "1. Top-level multi\n 1. Nested wrong\n";
987 let ctx_ol = LintContext::new(content_ol, crate::config::MarkdownFlavor::Standard, None);
988 let result_ol = rule_ol.check(&ctx_ol).unwrap();
989 assert_eq!(
990 result_ol.len(),
991 1,
992 "Nested ordered item with 1 space (ol_single=2) should be flagged; got: {result_ol:?}"
993 );
994 let fixed_ol = rule_ol.fix(&ctx_ol).unwrap();
995 assert_eq!(
996 fixed_ol, "1. Top-level multi\n 1. Nested wrong\n",
997 "fix() should expand 1 space to ol_single=2 on the nested ordered item"
998 );
999
1000 let content_deep = "- Level 1\n - Level 2\n - Level 3 wrong\n - Level 3 correct\n";
1003 let ctx_deep = LintContext::new(content_deep, crate::config::MarkdownFlavor::Standard, None);
1004 let result_deep = rule.check(&ctx_deep).unwrap();
1005 assert_eq!(
1006 result_deep.len(),
1007 1,
1008 "Deeply nested (8-space) item with 1 space should be flagged; got: {result_deep:?}"
1009 );
1010 assert_eq!(result_deep[0].line, 3, "Violation should be on the deeply nested line");
1011
1012 assert_fix_resolves_all_violations(&rule, content);
1014 assert_fix_resolves_all_violations(&rule_ol, content_ol);
1015 assert_fix_resolves_all_violations(&rule, content_deep);
1016 }
1017
1018 #[test]
1019 fn test_loose_nested_item_fix_matches_check() {
1020 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
1023
1024 let content = "- parent\n\n - nested wrong\n";
1025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026
1027 let warnings = rule.check(&ctx).unwrap();
1029 assert_eq!(
1030 warnings.len(),
1031 1,
1032 "Loose nested item with 2 spaces should be detected; got: {warnings:?}"
1033 );
1034
1035 let fixed = rule.fix(&ctx).unwrap();
1037 assert_eq!(
1038 fixed, "- parent\n\n - nested wrong\n",
1039 "fix() should reduce 2 spaces to 1 for loose nested item"
1040 );
1041
1042 assert_fix_resolves_all_violations(&rule, content);
1044 }
1045
1046 #[test]
1047 fn test_ol_multi_reindents_nested_to_stay_attached() {
1048 let rule = MD030ListMarkerSpace::new(1, 1, 1, 3); let content = indoc! {"
1054 1. outer
1055 1. inner
1056 deep
1057 "};
1058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1059 assert_eq!(
1060 rule.fix(&ctx).unwrap(),
1061 indoc! {"
1062 1. outer
1063 1. inner
1064 deep
1065 "}
1066 );
1067 assert_fix_resolves_all_violations(&rule, content);
1068 }
1069
1070 #[test]
1071 fn test_ol_multi_moves_content_when_narrowing() {
1072 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1); let content = indoc! {"
1077 1. outer
1078 continuation
1079 "};
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 assert_eq!(
1082 rule.fix(&ctx).unwrap(),
1083 indoc! {"
1084 1. outer
1085 continuation
1086 "},
1087 "marker narrows to 1 space and the continuation moves with it"
1088 );
1089 }
1090
1091 #[test]
1092 fn test_ol_align_column_off_by_default() {
1093 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
1096 let content = indoc! {"
1097 1. one
1098 9. nine
1099 10. ten
1100 "};
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102 assert!(
1103 rule.check(&ctx).unwrap().is_empty(),
1104 "Default behaviour should not require column alignment"
1105 );
1106 }
1107
1108 #[test]
1109 fn test_ol_align_column_basic() {
1110 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1113 let content = indoc! {"
1114 1. one
1115 9. nine
1116 10. ten
1117 "};
1118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119
1120 let warnings = rule.check(&ctx).unwrap();
1121 assert_eq!(
1122 warnings.len(),
1123 2,
1124 "Single-digit markers should be flagged; got: {warnings:?}"
1125 );
1126 assert!(warnings.iter().all(|w| w.line == 1 || w.line == 2));
1127 assert!(
1128 warnings[0].message.contains("Expected: 2") && warnings[0].message.contains("Actual: 1"),
1129 "Message should report the aligned target; got: {}",
1130 warnings[0].message
1131 );
1132 assert_eq!(
1133 warnings[0].column, 3,
1134 "Span should start at the whitespace after the marker"
1135 );
1136
1137 assert_eq!(
1138 rule.fix(&ctx).unwrap(),
1139 indoc! {"
1140 1. one
1141 9. nine
1142 10. ten
1143 "}
1144 );
1145 assert_fix_resolves_all_violations(&rule, content);
1146 }
1147
1148 #[test]
1149 fn test_ol_align_column_wide_marker_overflows() {
1150 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1153 let content = indoc! {"
1154 1. a
1155 100. b
1156 "};
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158
1159 assert_eq!(
1160 rule.fix(&ctx).unwrap(),
1161 indoc! {"
1162 1. a
1163 100. b
1164 "},
1165 "narrow marker sits at column 4; wide marker overflows to column 5"
1166 );
1167 assert_fix_resolves_all_violations(&rule, content);
1168 }
1169
1170 #[test]
1171 fn test_ol_align_column_max_is_four_spaces() {
1172 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(6);
1176 let content = indoc! {"
1177 1. one
1178 2. two
1179 "};
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181 assert_eq!(
1182 rule.fix(&ctx).unwrap(),
1183 indoc! {"
1184 1. one
1185 2. two
1186 "},
1187 "column 6 pads `1.` to exactly 4 spaces, never more"
1188 );
1189 assert_fix_resolves_all_violations(&rule, content);
1190 }
1191
1192 #[test]
1193 fn test_ol_align_column_already_aligned_is_clean() {
1194 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1195 let content = indoc! {"
1196 1. one
1197 9. nine
1198 10. ten
1199 "};
1200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1201 assert!(
1202 rule.check(&ctx).unwrap().is_empty(),
1203 "Already-aligned list should produce no warnings"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_ol_align_column_reindents_nested_list() {
1209 let rule = MD030ListMarkerSpace::new(3, 1, 1, 1).with_ol_align_column(4); let content = indoc! {"
1215 1. - x
1216 - y
1217 "};
1218 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1219 assert_eq!(
1220 rule.fix(&ctx).unwrap(),
1221 indoc! {"
1222 1. - x
1223 - y
1224 "}
1225 );
1226 assert_fix_resolves_all_violations(&rule, content);
1227 }
1228
1229 #[test]
1230 fn test_ol_align_column_inline_non_marker_left_alone() {
1231 let rule = MD030ListMarkerSpace::new(3, 1, 1, 1).with_ol_align_column(4);
1235 for (input, expected) in [
1236 ("1. -text\n", "1. -text\n"),
1237 ("1. *emphasis* here\n", "1. *emphasis* here\n"),
1238 ] {
1239 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
1240 assert_eq!(rule.fix(&ctx).unwrap(), expected, "input: {input:?}");
1241 }
1242 }
1243
1244 #[test]
1245 fn test_ol_align_column_reindents_multi_level() {
1246 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1249 let content = indoc! {"
1250 1. text
1251 1. a
1252 z
1253 "};
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 assert_eq!(
1256 rule.fix(&ctx).unwrap(),
1257 indoc! {"
1258 1. text
1259 1. a
1260 z
1261 "}
1262 );
1263 assert_fix_resolves_all_violations(&rule, content);
1264 }
1265
1266 #[test]
1267 fn test_ol_align_column_reindents_multiline_nested_unordered() {
1268 let rule = MD030ListMarkerSpace::new(1, 3, 1, 1).with_ol_align_column(4); let content = indoc! {"
1274 1. - first
1275 more first
1276 - second
1277 more second
1278 "};
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 assert_eq!(
1281 rule.fix(&ctx).unwrap(),
1282 indoc! {"
1283 1. - first
1284 more first
1285 - second
1286 more second
1287 "}
1288 );
1289 assert_fix_resolves_all_violations(&rule, content);
1290 }
1291
1292 #[test]
1293 fn test_ol_align_column_reindents_multiline_nested_ordered() {
1294 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1298 let content = indoc! {"
1299 1. text
1300 more text
1301 1. inner
1302 more inner
1303 2. inner2
1304 more inner2
1305 "};
1306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307 assert_eq!(
1308 rule.fix(&ctx).unwrap(),
1309 indoc! {"
1310 1. text
1311 more text
1312 1. inner
1313 more inner
1314 2. inner2
1315 more inner2
1316 "}
1317 );
1318 assert_fix_resolves_all_violations(&rule, content);
1319 }
1320
1321 #[test]
1322 fn test_ol_align_column_nested_aligns_relative() {
1323 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1327 let content = indoc! {"
1328 1. p
1329 1. a
1330 2. b
1331 9. i
1332 10. j
1333 "};
1334 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335 assert_eq!(
1336 rule.fix(&ctx).unwrap(),
1337 indoc! {"
1338 1. p
1339 1. a
1340 2. b
1341 9. i
1342 10. j
1343 "}
1344 );
1345 assert_fix_resolves_all_violations(&rule, content);
1346 }
1347
1348 #[test]
1349 fn test_ol_align_column_blockquote_items_in_a_list() {
1350 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1353 let content = indoc! {"
1354 1. > a
1355 > b
1356 2. > c
1357 "};
1358 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1359 assert_eq!(
1360 rule.fix(&ctx).unwrap(),
1361 indoc! {"
1362 1. > a
1363 > b
1364 2. > c
1365 "}
1366 );
1367 assert_fix_resolves_all_violations(&rule, content);
1368 }
1369
1370 #[test]
1371 fn test_ol_align_column_detached_blockquote_left_alone() {
1372 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1377 let detached = indoc! {"
1378 1. > x
1379 > y
1380 "};
1381 let ctx = LintContext::new(detached, crate::config::MarkdownFlavor::Standard, None);
1382 assert_eq!(
1383 rule.fix(&ctx).unwrap(),
1384 detached,
1385 "a detached top-level blockquote must be left as is"
1386 );
1387 }
1388
1389 #[test]
1390 fn test_ol_align_column_preserves_blockquote_alignment() {
1391 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1396 let content = indoc! {"
1397 1. > 1. x
1398 > 2. y
1399
1400 2. > z
1401
1402 3. > 1. a
1403 > 2. b
1404 "};
1405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406
1407 assert!(
1410 rule.check(&ctx).unwrap().is_empty(),
1411 "items already at column 4 must not be flagged; got: {:?}",
1412 rule.check(&ctx).unwrap()
1413 );
1414 assert_eq!(
1415 rule.fix(&ctx).unwrap(),
1416 content,
1417 "fix must leave the aligned input untouched"
1418 );
1419 }
1420
1421 #[test]
1422 fn test_ol_align_column_reindents_mixed_content() {
1423 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1426 let content = indoc! {"
1427 1. > x
1428 - sub
1429 "};
1430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1431 assert_eq!(
1432 rule.fix(&ctx).unwrap(),
1433 indoc! {"
1434 1. > x
1435 - sub
1436 "}
1437 );
1438 assert_fix_resolves_all_violations(&rule, content);
1439 }
1440
1441 #[test]
1442 fn test_ol_align_column_in_blockquote() {
1443 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1446 let content = indoc! {"
1447 > 1. one
1448 > 9. nine
1449 > 10. ten
1450 "};
1451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1452 assert_eq!(
1453 rule.fix(&ctx).unwrap(),
1454 indoc! {"
1455 > 1. one
1456 > 9. nine
1457 > 10. ten
1458 "}
1459 );
1460 assert_fix_resolves_all_violations(&rule, content);
1461 }
1462
1463 #[test]
1464 fn test_ol_align_column_multiline_item_in_blockquote() {
1465 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1470 let content = indoc! {"
1471 > 1. text
1472 > more
1473 > 2. second
1474 "};
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 assert_eq!(
1477 rule.fix(&ctx).unwrap(),
1478 indoc! {"
1479 > 1. text
1480 > more
1481 > 2. second
1482 "}
1483 );
1484 assert_fix_resolves_all_violations(&rule, content);
1485 }
1486
1487 #[test]
1488 fn test_ol_align_column_nested_list_in_blockquote() {
1489 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1493 let content = indoc! {"
1494 > 1. text
1495 > 1. inner
1496 > more
1497 "};
1498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1499 assert_eq!(
1500 rule.fix(&ctx).unwrap(),
1501 indoc! {"
1502 > 1. text
1503 > 1. inner
1504 > more
1505 "}
1506 );
1507 assert_fix_resolves_all_violations(&rule, content);
1508 }
1509
1510 #[test]
1511 fn test_ol_align_column_does_not_affect_unordered_lists() {
1512 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1514 let content = indoc! {"
1515 - a
1516 - b
1517 "};
1518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519 assert!(
1520 rule.check(&ctx).unwrap().is_empty(),
1521 "Unordered lists should be unaffected by ol-align-column"
1522 );
1523 }
1524
1525 #[test]
1526 fn test_has_content_after_marker() {
1527 assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
1529 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
1530 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
1531 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
1532 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
1533 assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
1534 assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
1535 assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
1536 }
1537}