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};
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 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 let mut stack: Vec<OwnerFrame> = Vec::new();
90 let mut list_warnings = 0;
95 let mut unfixable = false;
96
97 for (line_num, line_info) in ctx.lines.iter().enumerate() {
100 let line_num_1based = line_num + 1;
101 let line = lines[line_num];
102
103 let before = stack.clone();
106 let lazy = close_ended_items(ctx, line_num_1based, lines, &mut stack);
107
108 let is_list_item = line_info.list_item.is_some()
110 && !line_info.in_code_block
111 && !line_info.in_math_block
112 && !line_info.in_pymdown_block
113 && !line_info.in_mkdocs_html_markdown
114 && !line_info.in_footnote_definition;
115
116 if !is_list_item {
117 if !lazy && !line.trim().is_empty() {
120 let mut owners = stack.clone();
121 match keep_ended_items_closed(ctx, line_num, &before, stack.len(), Nesting::Relative) {
122 EndedItems::StayClosed => {}
123 EndedItems::ShiftOut(frame) => owners.push(frame),
124 EndedItems::Unfixable => unfixable = true,
125 }
126 move_owned_line(ctx, line, line_num, &owners, &mut warnings);
127 }
128 continue;
129 }
130
131 processed_lines.insert(line_num_1based);
132 let Some(list_info) = &line_info.list_item else {
133 continue;
134 };
135
136 if stack.is_empty() {
137 if std::mem::take(&mut unfixable) {
138 Self::decline_fixes(&mut warnings[list_warnings..]);
139 }
140 list_warnings = warnings.len();
141 }
142
143 move_owned_line(ctx, line, line_num, &stack, &mut warnings);
145
146 let marker_end = list_info.marker_column + list_info.marker.len();
147
148 if !Self::has_content_after_marker(line, marker_end) {
150 continue;
151 }
152
153 let actual_spaces = list_info.content_column.saturating_sub(marker_end);
154
155 let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, lines);
158 let expected_spaces =
159 self.config
160 .expected_spaces(list_info.is_ordered, is_multi_line, list_info.marker.len());
161
162 let warning = (actual_spaces != expected_spaces).then(|| {
163 warnings.push(self.spacing_fix_warning(
164 ctx,
165 line,
166 line_num,
167 marker_end..marker_end + actual_spaces,
168 expected_spaces,
169 format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})"),
170 ));
171 warnings.len() - 1
172 });
173
174 let own = expected_spaces as isize - actual_spaces as isize;
177 if let Some(frame) = OwnerFrame::for_item(ctx, line_num_1based, own, warning) {
178 stack.push(frame);
179 if list_info.is_ordered {
180 self.align_inline_bullet(
181 ctx,
182 line_num_1based,
183 lines,
184 list_info.content_column,
185 &mut stack,
186 &mut warnings,
187 );
188 }
189 }
190 }
191
192 if unfixable {
193 Self::decline_fixes(&mut warnings[list_warnings..]);
194 }
195 drop_structure_changing_fixes(ctx, &mut warnings, Allowed::Nothing);
198
199 for (line_idx, line) in lines.iter().enumerate() {
202 let line_num = line_idx + 1;
203
204 if processed_lines.contains(&line_num) {
206 continue;
207 }
208 if let Some(line_info) = ctx.lines.get(line_idx)
209 && (line_info.in_code_block
210 || line_info.in_front_matter
211 || line_info.in_html_comment
212 || line_info.in_mdx_comment
213 || line_info.in_math_block
214 || line_info.in_pymdown_block
215 || line_info.in_mkdocs_html_markdown
216 || line_info.in_footnote_definition)
217 {
218 continue;
219 }
220
221 if self.is_indented_code_block(line, line_idx, lines) {
223 continue;
224 }
225
226 if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, lines) {
228 warnings.push(warning);
229 }
230 }
231
232 Ok(warnings)
233 }
234
235 fn category(&self) -> RuleCategory {
236 RuleCategory::List
237 }
238
239 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
240 if ctx.content.is_empty() {
241 return true;
242 }
243
244 let bytes = ctx.content.as_bytes();
246 !bytes.contains(&b'*')
247 && !bytes.contains(&b'-')
248 && !bytes.contains(&b'+')
249 && !bytes.iter().any(|&b| b.is_ascii_digit())
250 }
251
252 fn as_any(&self) -> &dyn std::any::Any {
253 self
254 }
255
256 crate::impl_rule_config_methods!(MD030Config);
257
258 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
259 if self.should_skip(ctx) {
260 return Ok(ctx.content.to_string());
261 }
262
263 let warnings = self.check(ctx)?;
267 if warnings.is_empty() {
268 return Ok(ctx.content.to_string());
269 }
270
271 let warnings =
272 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
273
274 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
275 .map_err(crate::rule::LintError::InvalidInput)
276 }
277}
278
279impl MD030ListMarkerSpace {
280 #[inline]
284 fn decline_fixes(warnings: &mut [LintWarning]) {
288 for warning in warnings {
289 warning.fix = None;
290 }
291 }
292
293 fn has_content_after_marker(line: &str, marker_end: usize) -> bool {
294 if marker_end >= line.len() {
295 return false;
296 }
297 !line[marker_end..].trim().is_empty()
298 }
299
300 fn spacing_fix_warning(
304 &self,
305 ctx: &crate::lint_context::LintContext,
306 line: &str,
307 line_idx: usize,
308 span: std::ops::Range<usize>,
309 want: usize,
310 message: String,
311 ) -> LintWarning {
312 let (start_line, start_col, end_line, end_col) =
313 calculate_match_range(line_idx + 1, line, span.start, span.len());
314 let base = ctx.line_offsets.get(line_idx).copied().unwrap_or(0);
315 LintWarning {
316 rule_name: Some(self.name().to_string()),
317 severity: Severity::Warning,
318 line: start_line,
319 column: start_col,
320 end_line,
321 end_column: end_col,
322 message,
323 fix: Some(crate::rule::Fix::new(
324 base + span.start..base + span.end,
325 " ".repeat(want),
326 )),
327 }
328 }
329
330 fn inline_unordered_spaces(line: &str, content_col: usize) -> Option<(usize, usize)> {
335 if !matches!(line.as_bytes().get(content_col), Some(b'-' | b'*' | b'+')) {
336 return None;
337 }
338 let offset = content_col + 1;
339 let rest = line.get(offset..)?;
340 let spaces = rest.len() - rest.trim_start_matches(' ').len();
341 if spaces == 0 || rest[spaces..].is_empty() {
342 return None;
343 }
344 Some((offset, spaces))
345 }
346
347 fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
359 let Some((marker_column, bq_level, min_indent)) = continuation_params(ctx, line_num) else {
360 return false;
361 };
362 Self::has_continuation(ctx, line_num, lines, marker_column, bq_level, min_indent)
363 }
364
365 fn has_continuation(
369 ctx: &crate::lint_context::LintContext,
370 line_num: usize,
371 lines: &[&str],
372 marker_column: usize,
373 bq_level: usize,
374 min_indent: usize,
375 ) -> bool {
376 for next in (line_num + 1)..=lines.len() {
377 match classify_continuation(ctx, next, lines, marker_column, bq_level, min_indent) {
378 Continuation::Belongs => return true,
379 Continuation::Ends => break,
380 Continuation::Skip => {}
381 }
382 }
383 false
384 }
385
386 fn align_inline_bullet(
391 &self,
392 ctx: &crate::lint_context::LintContext,
393 line_num: usize,
394 lines: &[&str],
395 content_column: usize,
396 stack: &mut Vec<OwnerFrame>,
397 warnings: &mut Vec<LintWarning>,
398 ) {
399 let line = lines[line_num - 1];
400 let Some((offset, spaces)) = Self::inline_unordered_spaces(line, content_column) else {
401 return;
402 };
403 let bullet_content_col = offset + spaces;
404 let multi = Self::has_continuation(ctx, line_num, lines, content_column, 0, bullet_content_col);
408 let want = self.config.expected_spaces(false, multi, 1);
409 if spaces == want {
410 return;
411 }
412 warnings.push(self.spacing_fix_warning(
413 ctx,
414 line,
415 line_num - 1,
416 offset..offset + spaces,
417 want,
418 format!("Spaces after list markers (Expected: {want}; Actual: {spaces})"),
419 ));
420 stack.push(OwnerFrame {
421 marker_column: content_column,
422 bq_level: 0,
423 min_indent: bullet_content_col,
424 own: want as isize - spaces as isize,
425 warning: Some(warnings.len() - 1),
426 });
427 }
428
429 fn check_unrecognized_list_marker(
432 &self,
433 ctx: &crate::lint_context::LintContext,
434 line: &str,
435 line_num: usize,
436 lines: &[&str],
437 ) -> Option<LintWarning> {
438 let (bq_prefix_len, content) = match parse_blockquote_prefix(line) {
441 Some(parsed) => (parsed.prefix.len(), parsed.content),
442 None => (0, line),
443 };
444
445 let trimmed = content.trim_start();
446 let indent_len = content.len() - trimmed.len();
447
448 if let Some(dot_pos) = trimmed.find('.') {
455 let before_dot = &trimmed[..dot_pos];
456 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
457 let after_dot = &trimmed[dot_pos + 1..];
458 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
460 let first_char = after_dot.chars().next().unwrap_or(' ');
461
462 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
467
468 if is_clear_intent {
469 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
470 let expected_spaces = self.config.expected_spaces(true, is_multi_line, before_dot.len() + 1);
472
473 let marker = format!("{before_dot}.");
474 let marker_pos = indent_len;
475 let marker_end = marker_pos + marker.len();
476 let offset_in_line = bq_prefix_len + marker_end;
478
479 let (start_line, start_col, end_line, end_col) =
480 calculate_match_range(line_num, line, offset_in_line, 0);
481
482 let correct_spaces = " ".repeat(expected_spaces);
483 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
484 let fix_position = line_start_byte + offset_in_line;
485
486 return Some(LintWarning {
487 rule_name: Some("MD030".to_string()),
488 severity: Severity::Warning,
489 line: start_line,
490 column: start_col,
491 end_line,
492 end_column: end_col,
493 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
494 fix: Some(crate::rule::Fix::new(fix_position..fix_position, correct_spaces)),
495 });
496 }
497 }
498 }
499 }
500
501 None
502 }
503
504 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
506 if line_num < lines.len() {
509 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
511 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
513 return true;
514 }
515 }
516 false
517 }
518
519 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
521 if calculate_indentation_width_default(line) < 4 {
523 return false;
524 }
525
526 if line_idx == 0 {
528 return false;
529 }
530
531 if self.has_blank_line_before_indented_block(line_idx, lines) {
533 return true;
534 }
535
536 false
537 }
538
539 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
541 let mut current_idx = line_idx;
543
544 while current_idx > 0 {
546 let current_line = lines[current_idx];
547 let prev_line = lines[current_idx - 1];
548
549 if calculate_indentation_width_default(current_line) < 4 {
551 break;
552 }
553
554 if calculate_indentation_width_default(prev_line) < 4 {
556 return prev_line.trim().is_empty();
557 }
558
559 current_idx -= 1;
560 }
561
562 false
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::lint_context::LintContext;
570 use indoc::indoc;
571
572 fn assert_fix_resolves_all_violations(rule: &MD030ListMarkerSpace, content: &str) {
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576 let before = rule.check(&ctx).unwrap();
577 assert!(
578 !before.is_empty(),
579 "Expected violations but check() found none in:\n{content}"
580 );
581
582 let fixed = rule.fix(&ctx).unwrap();
583 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
584 let after = rule.check(&ctx_fixed).unwrap();
585 assert!(
586 after.is_empty(),
587 "fix() left {} violation(s) unresolved:\n{:?}\nOriginal:\n{content}\nFixed:\n{fixed}",
588 after.len(),
589 after
590 );
591 }
592
593 #[test]
594 fn test_fix_keeps_a_parent_paragraph_out_of_the_shifted_child() {
595 let rule = MD030ListMarkerSpace::default();
598 let content = "* parent\n\n * child\n\n Parent paragraph.\n";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 assert_eq!(
601 rule.fix(&ctx).unwrap(),
602 "* parent\n\n * child\n\n Parent paragraph.\n"
603 );
604 }
605
606 #[test]
607 fn test_unfixable_list_leaves_other_lists_fixable() {
608 let rule = MD030ListMarkerSpace::default();
611 let content = "- fixable\n\nParagraph.\n\n 2) item\n\n code\n";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let warnings = rule.check(&ctx).unwrap();
614 assert_eq!(warnings.len(), 2, "{warnings:?}");
615 assert!(warnings[0].fix.is_some(), "{warnings:?}");
616 assert!(warnings[1].fix.is_none(), "{warnings:?}");
617 assert_eq!(
618 rule.fix(&ctx).unwrap(),
619 "- fixable\n\nParagraph.\n\n 2) item\n\n code\n"
620 );
621 }
622
623 #[test]
624 fn test_unfixable_list_leaves_a_later_list_fixable() {
625 let rule = MD030ListMarkerSpace::default();
626 let content = " 2) item\n\n code\n\nParagraph.\n\n- fixable\n";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let warnings = rule.check(&ctx).unwrap();
629 assert_eq!(warnings.len(), 2, "{warnings:?}");
630 assert!(warnings[0].fix.is_none(), "{warnings:?}");
631 assert!(warnings[1].fix.is_some(), "{warnings:?}");
632 assert_eq!(
633 rule.fix(&ctx).unwrap(),
634 " 2) item\n\n code\n\nParagraph.\n\n- fixable\n"
635 );
636 }
637
638 #[test]
639 fn test_basic_functionality() {
640 let rule = MD030ListMarkerSpace::default();
641 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644 assert!(
645 result.is_empty(),
646 "Correctly spaced list markers should not generate warnings"
647 );
648 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let result = rule.check(&ctx).unwrap();
651 assert_eq!(
653 result.len(),
654 2,
655 "Should flag lines with too many spaces after list marker"
656 );
657 for warning in result {
658 assert!(
659 warning.message.starts_with("Spaces after list markers (Expected:")
660 && warning.message.contains("Actual:"),
661 "Warning message should include expected and actual values, got: '{}'",
662 warning.message
663 );
664 }
665 }
666
667 #[test]
668 fn test_nested_emphasis_not_flagged_issue_278() {
669 let rule = MD030ListMarkerSpace::default();
671
672 let content = "*This text is **very** important*";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676 assert!(
677 result.is_empty(),
678 "Nested emphasis should not trigger MD030, got: {result:?}"
679 );
680
681 let content2 = "*Hello World*";
683 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
684 let result2 = rule.check(&ctx2).unwrap();
685 assert!(
686 result2.is_empty(),
687 "Simple emphasis should not trigger MD030, got: {result2:?}"
688 );
689
690 let content3 = "**bold text**";
692 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
693 let result3 = rule.check(&ctx3).unwrap();
694 assert!(
695 result3.is_empty(),
696 "Bold text should not trigger MD030, got: {result3:?}"
697 );
698
699 let content4 = "***bold and italic***";
701 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
702 let result4 = rule.check(&ctx4).unwrap();
703 assert!(
704 result4.is_empty(),
705 "Bold+italic should not trigger MD030, got: {result4:?}"
706 );
707
708 let content5 = "* Item with space";
710 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
711 let result5 = rule.check(&ctx5).unwrap();
712 assert!(
713 result5.is_empty(),
714 "Properly spaced list item should not trigger MD030, got: {result5:?}"
715 );
716 }
717
718 #[test]
719 fn test_empty_marker_line_not_flagged_issue_288() {
720 let rule = MD030ListMarkerSpace::default();
723
724 let content = "-\n ```python\n print(\"code\")\n ```\n";
726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
727 let result = rule.check(&ctx).unwrap();
728 assert!(
729 result.is_empty(),
730 "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
731 );
732
733 let content = "1.\n ```python\n print(\"code\")\n ```\n";
735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
736 let result = rule.check(&ctx).unwrap();
737 assert!(
738 result.is_empty(),
739 "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
740 );
741
742 let content = "-\n This is a paragraph continuation\n of the list item.\n";
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745 let result = rule.check(&ctx).unwrap();
746 assert!(
747 result.is_empty(),
748 "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
749 );
750
751 let content = "- Parent item\n -\n Nested content\n";
753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
754 let result = rule.check(&ctx).unwrap();
755 assert!(
756 result.is_empty(),
757 "Nested empty marker line should not trigger MD030, got: {result:?}"
758 );
759
760 let content = "- Item with content\n-\n Code block\n- Another item\n";
762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let result = rule.check(&ctx).unwrap();
764 assert!(
765 result.is_empty(),
766 "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
767 );
768 }
769
770 #[test]
771 fn test_marker_with_content_still_flagged_issue_288() {
772 let rule = MD030ListMarkerSpace::default();
774
775 let content = "- Two spaces before content\n";
777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
778 let result = rule.check(&ctx).unwrap();
779 assert_eq!(
780 result.len(),
781 1,
782 "Two spaces after unordered marker should still trigger MD030"
783 );
784
785 let content = "1. Two spaces\n";
787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
788 let result = rule.check(&ctx).unwrap();
789 assert_eq!(
790 result.len(),
791 1,
792 "Two spaces after ordered marker should still trigger MD030"
793 );
794
795 let content = "- Normal item\n";
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799 assert!(
800 result.is_empty(),
801 "Normal list item should not trigger MD030, got: {result:?}"
802 );
803 }
804
805 #[test]
806 fn test_nested_items_with_4space_indent_are_detected() {
807 let rule = MD030ListMarkerSpace::new(3, 3, 1, 1);
812
813 let content = "- Top-level correct\n - Nested wrong spacing\n - Nested correct\n";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.check(&ctx).unwrap();
818 assert_eq!(
819 result.len(),
820 1,
821 "Nested item with 1 space (ul_single=3) should be flagged; got: {result:?}"
822 );
823 assert_eq!(result[0].line, 2, "Violation should be on line 2");
824 assert!(
825 result[0].message.contains("Expected: 3") && result[0].message.contains("Actual: 1"),
826 "Message should state expected/actual spaces; got: {}",
827 result[0].message
828 );
829
830 let fixed = rule.fix(&ctx).unwrap();
832 assert_eq!(
833 fixed, "- Top-level correct\n - Nested wrong spacing\n - Nested correct\n",
834 "fix() should expand 1 space to ul_single=3 on the nested item"
835 );
836
837 let content_ok = "- Top-level\n - Nested correct\n";
839 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
840 let result_ok = rule.check(&ctx_ok).unwrap();
841 assert!(
842 result_ok.is_empty(),
843 "Nested item with correct spacing should not be flagged; got: {result_ok:?}"
844 );
845
846 let rule_ol = MD030ListMarkerSpace::new(1, 1, 2, 2);
848 let content_ol = "1. Top-level multi\n 1. Nested wrong\n";
849 let ctx_ol = LintContext::new(content_ol, crate::config::MarkdownFlavor::Standard, None);
850 let result_ol = rule_ol.check(&ctx_ol).unwrap();
851 assert_eq!(
852 result_ol.len(),
853 1,
854 "Nested ordered item with 1 space (ol_single=2) should be flagged; got: {result_ol:?}"
855 );
856 let fixed_ol = rule_ol.fix(&ctx_ol).unwrap();
857 assert_eq!(
858 fixed_ol, "1. Top-level multi\n 1. Nested wrong\n",
859 "fix() should expand 1 space to ol_single=2 on the nested ordered item"
860 );
861
862 let content_deep = "- Level 1\n - Level 2\n - Level 3 wrong\n - Level 3 correct\n";
865 let ctx_deep = LintContext::new(content_deep, crate::config::MarkdownFlavor::Standard, None);
866 let result_deep = rule.check(&ctx_deep).unwrap();
867 assert_eq!(
868 result_deep.len(),
869 1,
870 "Deeply nested (8-space) item with 1 space should be flagged; got: {result_deep:?}"
871 );
872 assert_eq!(result_deep[0].line, 3, "Violation should be on the deeply nested line");
873
874 assert_fix_resolves_all_violations(&rule, content);
876 assert_fix_resolves_all_violations(&rule_ol, content_ol);
877 assert_fix_resolves_all_violations(&rule, content_deep);
878 }
879
880 #[test]
881 fn test_loose_nested_item_fix_matches_check() {
882 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
885
886 let content = "- parent\n\n - nested wrong\n";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888
889 let warnings = rule.check(&ctx).unwrap();
891 assert_eq!(
892 warnings.len(),
893 1,
894 "Loose nested item with 2 spaces should be detected; got: {warnings:?}"
895 );
896
897 let fixed = rule.fix(&ctx).unwrap();
899 assert_eq!(
900 fixed, "- parent\n\n - nested wrong\n",
901 "fix() should reduce 2 spaces to 1 for loose nested item"
902 );
903
904 assert_fix_resolves_all_violations(&rule, content);
906 }
907
908 #[test]
909 fn test_ol_multi_reindents_nested_to_stay_attached() {
910 let rule = MD030ListMarkerSpace::new(1, 1, 1, 3); let content = indoc! {"
916 1. outer
917 1. inner
918 deep
919 "};
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921 assert_eq!(
922 rule.fix(&ctx).unwrap(),
923 indoc! {"
924 1. outer
925 1. inner
926 deep
927 "}
928 );
929 assert_fix_resolves_all_violations(&rule, content);
930 }
931
932 #[test]
933 fn test_ol_multi_moves_content_when_narrowing() {
934 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1); let content = indoc! {"
939 1. outer
940 continuation
941 "};
942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 assert_eq!(
944 rule.fix(&ctx).unwrap(),
945 indoc! {"
946 1. outer
947 continuation
948 "},
949 "marker narrows to 1 space and the continuation moves with it"
950 );
951 }
952
953 #[test]
954 fn test_ol_align_column_off_by_default() {
955 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
958 let content = indoc! {"
959 1. one
960 9. nine
961 10. ten
962 "};
963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964 assert!(
965 rule.check(&ctx).unwrap().is_empty(),
966 "Default behaviour should not require column alignment"
967 );
968 }
969
970 #[test]
971 fn test_ol_align_column_basic() {
972 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
975 let content = indoc! {"
976 1. one
977 9. nine
978 10. ten
979 "};
980 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981
982 let warnings = rule.check(&ctx).unwrap();
983 assert_eq!(
984 warnings.len(),
985 2,
986 "Single-digit markers should be flagged; got: {warnings:?}"
987 );
988 assert!(warnings.iter().all(|w| w.line == 1 || w.line == 2));
989 assert!(
990 warnings[0].message.contains("Expected: 2") && warnings[0].message.contains("Actual: 1"),
991 "Message should report the aligned target; got: {}",
992 warnings[0].message
993 );
994 assert_eq!(
995 warnings[0].column, 3,
996 "Span should start at the whitespace after the marker"
997 );
998
999 assert_eq!(
1000 rule.fix(&ctx).unwrap(),
1001 indoc! {"
1002 1. one
1003 9. nine
1004 10. ten
1005 "}
1006 );
1007 assert_fix_resolves_all_violations(&rule, content);
1008 }
1009
1010 #[test]
1011 fn test_ol_align_column_wide_marker_overflows() {
1012 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1015 let content = indoc! {"
1016 1. a
1017 100. b
1018 "};
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020
1021 assert_eq!(
1022 rule.fix(&ctx).unwrap(),
1023 indoc! {"
1024 1. a
1025 100. b
1026 "},
1027 "narrow marker sits at column 4; wide marker overflows to column 5"
1028 );
1029 assert_fix_resolves_all_violations(&rule, content);
1030 }
1031
1032 #[test]
1033 fn test_ol_align_column_max_is_four_spaces() {
1034 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(6);
1038 let content = indoc! {"
1039 1. one
1040 2. two
1041 "};
1042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1043 assert_eq!(
1044 rule.fix(&ctx).unwrap(),
1045 indoc! {"
1046 1. one
1047 2. two
1048 "},
1049 "column 6 pads `1.` to exactly 4 spaces, never more"
1050 );
1051 assert_fix_resolves_all_violations(&rule, content);
1052 }
1053
1054 #[test]
1055 fn test_ol_align_column_already_aligned_is_clean() {
1056 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1057 let content = indoc! {"
1058 1. one
1059 9. nine
1060 10. ten
1061 "};
1062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1063 assert!(
1064 rule.check(&ctx).unwrap().is_empty(),
1065 "Already-aligned list should produce no warnings"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_ol_align_column_reindents_nested_list() {
1071 let rule = MD030ListMarkerSpace::new(3, 1, 1, 1).with_ol_align_column(4); let content = indoc! {"
1077 1. - x
1078 - y
1079 "};
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 assert_eq!(
1082 rule.fix(&ctx).unwrap(),
1083 indoc! {"
1084 1. - x
1085 - y
1086 "}
1087 );
1088 assert_fix_resolves_all_violations(&rule, content);
1089 }
1090
1091 #[test]
1092 fn test_ol_align_column_inline_non_marker_left_alone() {
1093 let rule = MD030ListMarkerSpace::new(3, 1, 1, 1).with_ol_align_column(4);
1097 for (input, expected) in [
1098 ("1. -text\n", "1. -text\n"),
1099 ("1. *emphasis* here\n", "1. *emphasis* here\n"),
1100 ] {
1101 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
1102 assert_eq!(rule.fix(&ctx).unwrap(), expected, "input: {input:?}");
1103 }
1104 }
1105
1106 #[test]
1107 fn test_ol_align_column_reindents_multi_level() {
1108 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1111 let content = indoc! {"
1112 1. text
1113 1. a
1114 z
1115 "};
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 assert_eq!(
1118 rule.fix(&ctx).unwrap(),
1119 indoc! {"
1120 1. text
1121 1. a
1122 z
1123 "}
1124 );
1125 assert_fix_resolves_all_violations(&rule, content);
1126 }
1127
1128 #[test]
1129 fn test_ol_align_column_reindents_multiline_nested_unordered() {
1130 let rule = MD030ListMarkerSpace::new(1, 3, 1, 1).with_ol_align_column(4); let content = indoc! {"
1136 1. - first
1137 more first
1138 - second
1139 more second
1140 "};
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142 assert_eq!(
1143 rule.fix(&ctx).unwrap(),
1144 indoc! {"
1145 1. - first
1146 more first
1147 - second
1148 more second
1149 "}
1150 );
1151 assert_fix_resolves_all_violations(&rule, content);
1152 }
1153
1154 #[test]
1155 fn test_ol_align_column_reindents_multiline_nested_ordered() {
1156 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1160 let content = indoc! {"
1161 1. text
1162 more text
1163 1. inner
1164 more inner
1165 2. inner2
1166 more inner2
1167 "};
1168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169 assert_eq!(
1170 rule.fix(&ctx).unwrap(),
1171 indoc! {"
1172 1. text
1173 more text
1174 1. inner
1175 more inner
1176 2. inner2
1177 more inner2
1178 "}
1179 );
1180 assert_fix_resolves_all_violations(&rule, content);
1181 }
1182
1183 #[test]
1184 fn test_ol_align_column_nested_aligns_relative() {
1185 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1189 let content = indoc! {"
1190 1. p
1191 1. a
1192 2. b
1193 9. i
1194 10. j
1195 "};
1196 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1197 assert_eq!(
1198 rule.fix(&ctx).unwrap(),
1199 indoc! {"
1200 1. p
1201 1. a
1202 2. b
1203 9. i
1204 10. j
1205 "}
1206 );
1207 assert_fix_resolves_all_violations(&rule, content);
1208 }
1209
1210 #[test]
1211 fn test_ol_align_column_blockquote_items_in_a_list() {
1212 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1215 let content = indoc! {"
1216 1. > a
1217 > b
1218 2. > c
1219 "};
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 assert_eq!(
1222 rule.fix(&ctx).unwrap(),
1223 indoc! {"
1224 1. > a
1225 > b
1226 2. > c
1227 "}
1228 );
1229 assert_fix_resolves_all_violations(&rule, content);
1230 }
1231
1232 #[test]
1233 fn test_ol_align_column_detached_blockquote_left_alone() {
1234 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1239 let detached = indoc! {"
1240 1. > x
1241 > y
1242 "};
1243 let ctx = LintContext::new(detached, crate::config::MarkdownFlavor::Standard, None);
1244 assert_eq!(
1245 rule.fix(&ctx).unwrap(),
1246 detached,
1247 "a detached top-level blockquote must be left as is"
1248 );
1249 }
1250
1251 #[test]
1252 fn test_ol_align_column_preserves_blockquote_alignment() {
1253 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1258 let content = indoc! {"
1259 1. > 1. x
1260 > 2. y
1261
1262 2. > z
1263
1264 3. > 1. a
1265 > 2. b
1266 "};
1267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268
1269 assert!(
1272 rule.check(&ctx).unwrap().is_empty(),
1273 "items already at column 4 must not be flagged; got: {:?}",
1274 rule.check(&ctx).unwrap()
1275 );
1276 assert_eq!(
1277 rule.fix(&ctx).unwrap(),
1278 content,
1279 "fix must leave the aligned input untouched"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_ol_align_column_reindents_mixed_content() {
1285 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1288 let content = indoc! {"
1289 1. > x
1290 - sub
1291 "};
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 assert_eq!(
1294 rule.fix(&ctx).unwrap(),
1295 indoc! {"
1296 1. > x
1297 - sub
1298 "}
1299 );
1300 assert_fix_resolves_all_violations(&rule, content);
1301 }
1302
1303 #[test]
1304 fn test_ol_align_column_in_blockquote() {
1305 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1308 let content = indoc! {"
1309 > 1. one
1310 > 9. nine
1311 > 10. ten
1312 "};
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314 assert_eq!(
1315 rule.fix(&ctx).unwrap(),
1316 indoc! {"
1317 > 1. one
1318 > 9. nine
1319 > 10. ten
1320 "}
1321 );
1322 assert_fix_resolves_all_violations(&rule, content);
1323 }
1324
1325 #[test]
1326 fn test_ol_align_column_multiline_item_in_blockquote() {
1327 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1332 let content = indoc! {"
1333 > 1. text
1334 > more
1335 > 2. second
1336 "};
1337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1338 assert_eq!(
1339 rule.fix(&ctx).unwrap(),
1340 indoc! {"
1341 > 1. text
1342 > more
1343 > 2. second
1344 "}
1345 );
1346 assert_fix_resolves_all_violations(&rule, content);
1347 }
1348
1349 #[test]
1350 fn test_ol_align_column_nested_list_in_blockquote() {
1351 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1355 let content = indoc! {"
1356 > 1. text
1357 > 1. inner
1358 > more
1359 "};
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361 assert_eq!(
1362 rule.fix(&ctx).unwrap(),
1363 indoc! {"
1364 > 1. text
1365 > 1. inner
1366 > more
1367 "}
1368 );
1369 assert_fix_resolves_all_violations(&rule, content);
1370 }
1371
1372 #[test]
1373 fn test_ol_align_column_does_not_affect_unordered_lists() {
1374 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1376 let content = indoc! {"
1377 - a
1378 - b
1379 "};
1380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1381 assert!(
1382 rule.check(&ctx).unwrap().is_empty(),
1383 "Unordered lists should be unaffected by ol-align-column"
1384 );
1385 }
1386
1387 #[test]
1388 fn test_has_content_after_marker() {
1389 assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
1391 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
1392 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
1393 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
1394 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
1395 assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
1396 assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
1397 assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
1398 }
1399}