1use crate::rule::{LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::blockquote::{effective_indent_in_blockquote, parse_blockquote_prefix};
8use crate::utils::calculate_indentation_width_default;
9use crate::utils::range_utils::calculate_match_range;
10use toml;
11
12mod md030_config;
13use md030_config::MD030Config;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum ListType {
17 Unordered,
18 Ordered,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23enum Continuation {
24 Belongs,
26 Skip,
28 Ends,
30}
31
32struct AlignFrame {
37 marker_column: usize,
38 bq_level: usize,
39 min_indent: usize,
40 shift: isize,
41}
42
43#[derive(Clone, Default)]
44pub struct MD030ListMarkerSpace {
45 config: MD030Config,
46}
47
48impl MD030ListMarkerSpace {
49 pub fn new(ul_single: usize, ul_multi: usize, ol_single: usize, ol_multi: usize) -> Self {
50 Self {
51 config: MD030Config {
52 ul_single: crate::types::PositiveUsize::new(ul_single)
53 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
54 ul_multi: crate::types::PositiveUsize::new(ul_multi)
55 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
56 ol_single: crate::types::PositiveUsize::new(ol_single)
57 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
58 ol_multi: crate::types::PositiveUsize::new(ol_multi)
59 .unwrap_or(crate::types::PositiveUsize::from_const(1)),
60 ol_align_column: crate::types::OlAlignColumn::default(),
61 },
62 }
63 }
64
65 fn from_config_struct(config: MD030Config) -> Self {
66 Self { config }
67 }
68
69 #[cfg(test)]
73 fn with_ol_align_column(mut self, column: usize) -> Self {
74 self.config.ol_align_column =
75 crate::types::OlAlignColumn::new(column).expect("test ol-align-column out of range");
76 self
77 }
78
79 fn ol_align_column(&self) -> Option<usize> {
81 self.config.ol_align_column.enabled()
82 }
83
84 fn get_expected_spaces(&self, list_type: ListType, is_multi: bool) -> usize {
85 match (list_type, is_multi) {
86 (ListType::Unordered, false) => self.config.ul_single.get(),
87 (ListType::Unordered, true) => self.config.ul_multi.get(),
88 (ListType::Ordered, false) => self.config.ol_single.get(),
89 (ListType::Ordered, true) => self.config.ol_multi.get(),
90 }
91 }
92}
93
94impl Rule for MD030ListMarkerSpace {
95 fn name(&self) -> &'static str {
96 "MD030"
97 }
98
99 fn description(&self) -> &'static str {
100 "Spaces after list markers should be consistent"
101 }
102
103 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
104 let mut warnings = Vec::new();
105
106 if self.should_skip(ctx) {
108 return Ok(warnings);
109 }
110
111 let lines = ctx.raw_lines();
112
113 let mut processed_lines = std::collections::HashSet::new();
115
116 let may_widen = self.ol_align_column().is_some()
124 || self.config.ul_single.get() > 1
125 || self.config.ul_multi.get() > 1
126 || self.config.ol_single.get() > 1
127 || self.config.ol_multi.get() > 1;
128
129 let mut stack: Vec<AlignFrame> = Vec::new();
136
137 for (line_num, line_info) in ctx.lines.iter().enumerate() {
140 let line_num_1based = line_num + 1;
141 let line = lines[line_num];
142
143 let (owner_shift, owner_bq_level) = if may_widen {
146 while let Some(&AlignFrame {
147 marker_column,
148 bq_level,
149 min_indent,
150 ..
151 }) = stack.last()
152 {
153 if Self::classify_continuation(ctx, line_num_1based, lines, marker_column, bq_level, min_indent)
154 == Continuation::Ends
155 {
156 stack.pop();
157 } else {
158 break;
159 }
160 }
161 stack.last().map_or((0, 0), |f| (f.shift, f.bq_level))
162 } else {
163 (0, 0)
164 };
165
166 let is_list_item = line_info.list_item.is_some()
168 && !line_info.in_code_block
169 && !line_info.in_math_block
170 && !line_info.in_pymdown_block
171 && !line_info.in_mkdocs_html_markdown
172 && !line_info.in_footnote_definition;
173
174 if !is_list_item {
175 if owner_shift > 0
177 && !line.trim().is_empty()
178 && let Some(warning) = self.indent_shift_warning(ctx, line, line_num, owner_bq_level, owner_shift)
179 {
180 processed_lines.insert(line_num_1based);
181 warnings.push(warning);
182 }
183 continue;
184 }
185
186 processed_lines.insert(line_num_1based);
187 let Some(list_info) = &line_info.list_item else {
188 continue;
189 };
190
191 if may_widen
193 && let Some(warning) = self.indent_shift_warning(ctx, line, line_num, owner_bq_level, owner_shift)
194 {
195 warnings.push(warning);
196 }
197
198 let list_type = if list_info.is_ordered {
199 ListType::Ordered
200 } else {
201 ListType::Unordered
202 };
203 let marker_end = list_info.marker_column + list_info.marker.len();
204
205 if !Self::has_content_after_marker(line, marker_end) {
207 continue;
208 }
209
210 let actual_spaces = list_info.content_column.saturating_sub(marker_end);
211
212 let expected_spaces = if list_type == ListType::Ordered
213 && let Some(target_column) = self.ol_align_column()
214 {
215 let marker_len = list_info.marker.len();
220 target_column.saturating_sub(marker_len).clamp(1, 4)
221 } else {
222 let is_multi_line = self.is_multi_line_list_item(ctx, line_num_1based, lines);
225 self.get_expected_spaces(list_type, is_multi_line)
226 };
227
228 if actual_spaces != expected_spaces {
229 warnings.push(self.spacing_fix_warning(
230 ctx,
231 line,
232 line_num,
233 marker_end..marker_end + actual_spaces,
234 expected_spaces,
235 format!("Spaces after list markers (Expected: {expected_spaces}; Actual: {actual_spaces})"),
236 ));
237 }
238
239 if may_widen
242 && let Some((marker_column, bq_level, min_indent)) = Self::continuation_params(ctx, line_num_1based)
243 {
244 let item_shift = owner_shift + (expected_spaces as isize - actual_spaces as isize);
245 stack.push(AlignFrame {
246 marker_column,
247 bq_level,
248 min_indent,
249 shift: item_shift,
250 });
251 if list_info.is_ordered
252 && let Some(warning) = self.align_inline_bullet(
253 ctx,
254 line_num_1based,
255 lines,
256 list_info.content_column,
257 item_shift,
258 &mut stack,
259 )
260 {
261 warnings.push(warning);
262 }
263 }
264 }
265
266 for (line_idx, line) in lines.iter().enumerate() {
269 let line_num = line_idx + 1;
270
271 if processed_lines.contains(&line_num) {
273 continue;
274 }
275 if let Some(line_info) = ctx.lines.get(line_idx)
276 && (line_info.in_code_block
277 || line_info.in_front_matter
278 || line_info.in_html_comment
279 || line_info.in_mdx_comment
280 || line_info.in_math_block
281 || line_info.in_pymdown_block
282 || line_info.in_mkdocs_html_markdown
283 || line_info.in_footnote_definition)
284 {
285 continue;
286 }
287
288 if self.is_indented_code_block(line, line_idx, lines) {
290 continue;
291 }
292
293 if let Some(warning) = self.check_unrecognized_list_marker(ctx, line, line_num, lines) {
295 warnings.push(warning);
296 }
297 }
298
299 Ok(warnings)
300 }
301
302 fn category(&self) -> RuleCategory {
303 RuleCategory::List
304 }
305
306 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
307 if ctx.content.is_empty() {
308 return true;
309 }
310
311 let bytes = ctx.content.as_bytes();
313 !bytes.contains(&b'*')
314 && !bytes.contains(&b'-')
315 && !bytes.contains(&b'+')
316 && !bytes.iter().any(|&b| b.is_ascii_digit())
317 }
318
319 fn as_any(&self) -> &dyn std::any::Any {
320 self
321 }
322
323 crate::impl_rule_config_methods!(MD030Config);
324
325 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, crate::rule::LintError> {
326 if self.should_skip(ctx) {
327 return Ok(ctx.content.to_string());
328 }
329
330 let warnings = self.check(ctx)?;
334 if warnings.is_empty() {
335 return Ok(ctx.content.to_string());
336 }
337
338 let warnings =
339 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
340
341 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
342 .map_err(crate::rule::LintError::InvalidInput)
343 }
344}
345
346impl MD030ListMarkerSpace {
347 #[inline]
351 fn has_content_after_marker(line: &str, marker_end: usize) -> bool {
352 if marker_end >= line.len() {
353 return false;
354 }
355 !line[marker_end..].trim().is_empty()
356 }
357
358 fn spacing_fix_warning(
362 &self,
363 ctx: &crate::lint_context::LintContext,
364 line: &str,
365 line_idx: usize,
366 span: std::ops::Range<usize>,
367 want: usize,
368 message: String,
369 ) -> LintWarning {
370 let (start_line, start_col, end_line, end_col) =
371 calculate_match_range(line_idx + 1, line, span.start, span.len());
372 let base = ctx.line_offsets.get(line_idx).copied().unwrap_or(0);
373 LintWarning {
374 rule_name: Some(self.name().to_string()),
375 severity: Severity::Warning,
376 line: start_line,
377 column: start_col,
378 end_line,
379 end_column: end_col,
380 message,
381 fix: Some(crate::rule::Fix::new(
382 base + span.start..base + span.end,
383 " ".repeat(want),
384 )),
385 }
386 }
387
388 fn inline_unordered_spaces(line: &str, content_col: usize) -> Option<(usize, usize)> {
393 if !matches!(line.as_bytes().get(content_col), Some(b'-' | b'*' | b'+')) {
394 return None;
395 }
396 let offset = content_col + 1;
397 let rest = line.get(offset..)?;
398 let spaces = rest.len() - rest.trim_start_matches(' ').len();
399 if spaces == 0 || rest[spaces..].is_empty() {
400 return None;
401 }
402 Some((offset, spaces))
403 }
404
405 fn continuation_params(ctx: &crate::lint_context::LintContext, line_num: usize) -> Option<(usize, usize, usize)> {
411 let info = ctx.line_info(line_num)?;
412 let list = info.list_item.as_ref()?;
413 let (bq_level, min_indent) = match &info.blockquote {
414 Some(bq) if bq.nesting_level > 0 => (bq.nesting_level, list.content_column.saturating_sub(bq.prefix.len())),
415 _ => (0, list.content_column),
416 };
417 Some((list.marker_column, bq_level, min_indent))
418 }
419
420 fn classify_continuation(
424 ctx: &crate::lint_context::LintContext,
425 next_line_num: usize,
426 lines: &[&str],
427 marker_column: usize,
428 bq_level: usize,
429 min_indent: usize,
430 ) -> Continuation {
431 let Some(info) = ctx.line_info(next_line_num) else {
432 return Continuation::Skip;
433 };
434 if let Some(next_list) = &info.list_item {
437 return if next_list.marker_column <= marker_column {
438 Continuation::Ends
439 } else {
440 Continuation::Belongs
441 };
442 }
443 let content = lines.get(next_line_num - 1).copied().unwrap_or("");
444 if content.trim().is_empty() {
445 return Continuation::Skip; }
447 let raw_indent = content.len() - content.trim_start().len();
448 if effective_indent_in_blockquote(content, bq_level, raw_indent) < min_indent {
449 Continuation::Ends
450 } else {
451 Continuation::Belongs
452 }
453 }
454
455 fn is_multi_line_list_item(&self, ctx: &crate::lint_context::LintContext, line_num: usize, lines: &[&str]) -> bool {
458 let Some((marker_column, bq_level, min_indent)) = Self::continuation_params(ctx, line_num) else {
459 return false;
460 };
461 Self::has_continuation(ctx, line_num, lines, marker_column, bq_level, min_indent)
462 }
463
464 fn has_continuation(
468 ctx: &crate::lint_context::LintContext,
469 line_num: usize,
470 lines: &[&str],
471 marker_column: usize,
472 bq_level: usize,
473 min_indent: usize,
474 ) -> bool {
475 for next in (line_num + 1)..=lines.len() {
476 match Self::classify_continuation(ctx, next, lines, marker_column, bq_level, min_indent) {
477 Continuation::Belongs => return true,
478 Continuation::Ends => break,
479 Continuation::Skip => {}
480 }
481 }
482 false
483 }
484
485 fn write_offset(owner_bq_level: usize, line: &str) -> usize {
489 match owner_bq_level {
490 0 => 0,
491 _ => parse_blockquote_prefix(line).map_or(0, |p| p.prefix.len()),
492 }
493 }
494
495 fn indent_shift_warning(
501 &self,
502 ctx: &crate::lint_context::LintContext,
503 line: &str,
504 line_idx: usize,
505 owner_bq_level: usize,
506 shift: isize,
507 ) -> Option<LintWarning> {
508 if shift <= 0 {
509 return None;
510 }
511 let offset = Self::write_offset(owner_bq_level, line);
512 let after = &line[offset..];
513 let indent = after.len() - after.trim_start().len();
514 let new_indent = (indent as isize + shift).max(0) as usize;
515 if new_indent == indent {
516 return None;
517 }
518 Some(self.spacing_fix_warning(
519 ctx,
520 line,
521 line_idx,
522 offset..offset + indent,
523 new_indent,
524 format!(
525 "Nested content should align with the list marker (Expected indent: {new_indent}; Actual: {indent})"
526 ),
527 ))
528 }
529
530 fn align_inline_bullet(
537 &self,
538 ctx: &crate::lint_context::LintContext,
539 line_num: usize,
540 lines: &[&str],
541 content_column: usize,
542 item_shift: isize,
543 stack: &mut Vec<AlignFrame>,
544 ) -> Option<LintWarning> {
545 let line = lines[line_num - 1];
546 let (offset, spaces) = Self::inline_unordered_spaces(line, content_column)?;
547 let bullet_content_col = offset + spaces;
548 let multi = Self::has_continuation(ctx, line_num, lines, content_column, 0, bullet_content_col);
551 let want = if multi {
552 self.config.ul_multi.get()
553 } else {
554 self.config.ul_single.get()
555 };
556 if spaces == want {
557 return None;
558 }
559 let bullet_delta = want as isize - spaces as isize;
560 stack.push(AlignFrame {
561 marker_column: content_column,
562 bq_level: 0,
563 min_indent: bullet_content_col,
564 shift: item_shift + bullet_delta,
565 });
566 Some(self.spacing_fix_warning(
567 ctx,
568 line,
569 line_num - 1,
570 offset..offset + spaces,
571 want,
572 format!("Spaces after list markers (Expected: {want}; Actual: {spaces})"),
573 ))
574 }
575
576 fn check_unrecognized_list_marker(
579 &self,
580 ctx: &crate::lint_context::LintContext,
581 line: &str,
582 line_num: usize,
583 lines: &[&str],
584 ) -> Option<LintWarning> {
585 let (bq_prefix_len, content) = match parse_blockquote_prefix(line) {
588 Some(parsed) => (parsed.prefix.len(), parsed.content),
589 None => (0, line),
590 };
591
592 let trimmed = content.trim_start();
593 let indent_len = content.len() - trimmed.len();
594
595 if let Some(dot_pos) = trimmed.find('.') {
602 let before_dot = &trimmed[..dot_pos];
603 if before_dot.chars().all(|c| c.is_ascii_digit()) && !before_dot.is_empty() {
604 let after_dot = &trimmed[dot_pos + 1..];
605 if !after_dot.is_empty() && !after_dot.starts_with(' ') && !after_dot.starts_with('\t') {
607 let first_char = after_dot.chars().next().unwrap_or(' ');
608
609 let is_clear_intent = first_char.is_ascii_uppercase() || first_char == '[' || first_char == '(';
614
615 if is_clear_intent {
616 let is_multi_line = self.is_multi_line_for_unrecognized(line_num, lines);
617 let expected_spaces = self.get_expected_spaces(ListType::Ordered, is_multi_line);
618
619 let marker = format!("{before_dot}.");
620 let marker_pos = indent_len;
621 let marker_end = marker_pos + marker.len();
622 let offset_in_line = bq_prefix_len + marker_end;
624
625 let (start_line, start_col, end_line, end_col) =
626 calculate_match_range(line_num, line, offset_in_line, 0);
627
628 let correct_spaces = " ".repeat(expected_spaces);
629 let line_start_byte = ctx.line_offsets.get(line_num - 1).copied().unwrap_or(0);
630 let fix_position = line_start_byte + offset_in_line;
631
632 return Some(LintWarning {
633 rule_name: Some("MD030".to_string()),
634 severity: Severity::Warning,
635 line: start_line,
636 column: start_col,
637 end_line,
638 end_column: end_col,
639 message: format!("Spaces after list markers (Expected: {expected_spaces}; Actual: 0)"),
640 fix: Some(crate::rule::Fix::new(fix_position..fix_position, correct_spaces)),
641 });
642 }
643 }
644 }
645 }
646
647 None
648 }
649
650 fn is_multi_line_for_unrecognized(&self, line_num: usize, lines: &[&str]) -> bool {
652 if line_num < lines.len() {
655 let next_line = lines[line_num]; let next_trimmed = next_line.trim();
657 if !next_trimmed.is_empty() && next_line.starts_with(' ') {
659 return true;
660 }
661 }
662 false
663 }
664
665 fn is_indented_code_block(&self, line: &str, line_idx: usize, lines: &[&str]) -> bool {
667 if calculate_indentation_width_default(line) < 4 {
669 return false;
670 }
671
672 if line_idx == 0 {
674 return false;
675 }
676
677 if self.has_blank_line_before_indented_block(line_idx, lines) {
679 return true;
680 }
681
682 false
683 }
684
685 fn has_blank_line_before_indented_block(&self, line_idx: usize, lines: &[&str]) -> bool {
687 let mut current_idx = line_idx;
689
690 while current_idx > 0 {
692 let current_line = lines[current_idx];
693 let prev_line = lines[current_idx - 1];
694
695 if calculate_indentation_width_default(current_line) < 4 {
697 break;
698 }
699
700 if calculate_indentation_width_default(prev_line) < 4 {
702 return prev_line.trim().is_empty();
703 }
704
705 current_idx -= 1;
706 }
707
708 false
709 }
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use crate::lint_context::LintContext;
716 use indoc::indoc;
717
718 fn assert_fix_resolves_all_violations(rule: &MD030ListMarkerSpace, content: &str) {
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let before = rule.check(&ctx).unwrap();
723 assert!(
724 !before.is_empty(),
725 "Expected violations but check() found none in:\n{content}"
726 );
727
728 let fixed = rule.fix(&ctx).unwrap();
729 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
730 let after = rule.check(&ctx_fixed).unwrap();
731 assert!(
732 after.is_empty(),
733 "fix() left {} violation(s) unresolved:\n{:?}\nOriginal:\n{content}\nFixed:\n{fixed}",
734 after.len(),
735 after
736 );
737 }
738
739 #[test]
740 fn test_basic_functionality() {
741 let rule = MD030ListMarkerSpace::default();
742 let content = "* Item 1\n* Item 2\n * Nested item\n1. Ordered item";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let result = rule.check(&ctx).unwrap();
745 assert!(
746 result.is_empty(),
747 "Correctly spaced list markers should not generate warnings"
748 );
749 let content = "* Item 1 (too many spaces)\n* Item 2\n1. Ordered item (too many spaces)";
750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751 let result = rule.check(&ctx).unwrap();
752 assert_eq!(
754 result.len(),
755 2,
756 "Should flag lines with too many spaces after list marker"
757 );
758 for warning in result {
759 assert!(
760 warning.message.starts_with("Spaces after list markers (Expected:")
761 && warning.message.contains("Actual:"),
762 "Warning message should include expected and actual values, got: '{}'",
763 warning.message
764 );
765 }
766 }
767
768 #[test]
769 fn test_nested_emphasis_not_flagged_issue_278() {
770 let rule = MD030ListMarkerSpace::default();
772
773 let content = "*This text is **very** important*";
775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776 let result = rule.check(&ctx).unwrap();
777 assert!(
778 result.is_empty(),
779 "Nested emphasis should not trigger MD030, got: {result:?}"
780 );
781
782 let content2 = "*Hello World*";
784 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
785 let result2 = rule.check(&ctx2).unwrap();
786 assert!(
787 result2.is_empty(),
788 "Simple emphasis should not trigger MD030, got: {result2:?}"
789 );
790
791 let content3 = "**bold text**";
793 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard, None);
794 let result3 = rule.check(&ctx3).unwrap();
795 assert!(
796 result3.is_empty(),
797 "Bold text should not trigger MD030, got: {result3:?}"
798 );
799
800 let content4 = "***bold and italic***";
802 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard, None);
803 let result4 = rule.check(&ctx4).unwrap();
804 assert!(
805 result4.is_empty(),
806 "Bold+italic should not trigger MD030, got: {result4:?}"
807 );
808
809 let content5 = "* Item with space";
811 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard, None);
812 let result5 = rule.check(&ctx5).unwrap();
813 assert!(
814 result5.is_empty(),
815 "Properly spaced list item should not trigger MD030, got: {result5:?}"
816 );
817 }
818
819 #[test]
820 fn test_empty_marker_line_not_flagged_issue_288() {
821 let rule = MD030ListMarkerSpace::default();
824
825 let content = "-\n ```python\n print(\"code\")\n ```\n";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829 assert!(
830 result.is_empty(),
831 "Empty unordered marker line with code continuation should not trigger MD030, got: {result:?}"
832 );
833
834 let content = "1.\n ```python\n print(\"code\")\n ```\n";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert!(
839 result.is_empty(),
840 "Empty ordered marker line with code continuation should not trigger MD030, got: {result:?}"
841 );
842
843 let content = "-\n This is a paragraph continuation\n of the list item.\n";
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847 assert!(
848 result.is_empty(),
849 "Empty marker line with paragraph continuation should not trigger MD030, got: {result:?}"
850 );
851
852 let content = "- Parent item\n -\n Nested content\n";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let result = rule.check(&ctx).unwrap();
856 assert!(
857 result.is_empty(),
858 "Nested empty marker line should not trigger MD030, got: {result:?}"
859 );
860
861 let content = "- Item with content\n-\n Code block\n- Another item\n";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865 assert!(
866 result.is_empty(),
867 "Mixed empty/non-empty marker lines should not trigger MD030 for empty ones, got: {result:?}"
868 );
869 }
870
871 #[test]
872 fn test_marker_with_content_still_flagged_issue_288() {
873 let rule = MD030ListMarkerSpace::default();
875
876 let content = "- Two spaces before content\n";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let result = rule.check(&ctx).unwrap();
880 assert_eq!(
881 result.len(),
882 1,
883 "Two spaces after unordered marker should still trigger MD030"
884 );
885
886 let content = "1. Two spaces\n";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889 let result = rule.check(&ctx).unwrap();
890 assert_eq!(
891 result.len(),
892 1,
893 "Two spaces after ordered marker should still trigger MD030"
894 );
895
896 let content = "- Normal item\n";
898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899 let result = rule.check(&ctx).unwrap();
900 assert!(
901 result.is_empty(),
902 "Normal list item should not trigger MD030, got: {result:?}"
903 );
904 }
905
906 #[test]
907 fn test_nested_items_with_4space_indent_are_detected() {
908 let rule = MD030ListMarkerSpace::new(3, 3, 1, 1);
913
914 let content = "- Top-level correct\n - Nested wrong spacing\n - Nested correct\n";
917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918 let result = rule.check(&ctx).unwrap();
919 assert_eq!(
920 result.len(),
921 1,
922 "Nested item with 1 space (ul_single=3) should be flagged; got: {result:?}"
923 );
924 assert_eq!(result[0].line, 2, "Violation should be on line 2");
925 assert!(
926 result[0].message.contains("Expected: 3") && result[0].message.contains("Actual: 1"),
927 "Message should state expected/actual spaces; got: {}",
928 result[0].message
929 );
930
931 let fixed = rule.fix(&ctx).unwrap();
933 assert_eq!(
934 fixed, "- Top-level correct\n - Nested wrong spacing\n - Nested correct\n",
935 "fix() should expand 1 space to ul_single=3 on the nested item"
936 );
937
938 let content_ok = "- Top-level\n - Nested correct\n";
940 let ctx_ok = LintContext::new(content_ok, crate::config::MarkdownFlavor::Standard, None);
941 let result_ok = rule.check(&ctx_ok).unwrap();
942 assert!(
943 result_ok.is_empty(),
944 "Nested item with correct spacing should not be flagged; got: {result_ok:?}"
945 );
946
947 let rule_ol = MD030ListMarkerSpace::new(1, 1, 2, 2);
949 let content_ol = "1. Top-level multi\n 1. Nested wrong\n";
950 let ctx_ol = LintContext::new(content_ol, crate::config::MarkdownFlavor::Standard, None);
951 let result_ol = rule_ol.check(&ctx_ol).unwrap();
952 assert_eq!(
953 result_ol.len(),
954 1,
955 "Nested ordered item with 1 space (ol_single=2) should be flagged; got: {result_ol:?}"
956 );
957 let fixed_ol = rule_ol.fix(&ctx_ol).unwrap();
958 assert_eq!(
959 fixed_ol, "1. Top-level multi\n 1. Nested wrong\n",
960 "fix() should expand 1 space to ol_single=2 on the nested ordered item"
961 );
962
963 let content_deep = "- Level 1\n - Level 2\n - Level 3 wrong\n - Level 3 correct\n";
966 let ctx_deep = LintContext::new(content_deep, crate::config::MarkdownFlavor::Standard, None);
967 let result_deep = rule.check(&ctx_deep).unwrap();
968 assert_eq!(
969 result_deep.len(),
970 1,
971 "Deeply nested (8-space) item with 1 space should be flagged; got: {result_deep:?}"
972 );
973 assert_eq!(result_deep[0].line, 3, "Violation should be on the deeply nested line");
974
975 assert_fix_resolves_all_violations(&rule, content);
977 assert_fix_resolves_all_violations(&rule_ol, content_ol);
978 assert_fix_resolves_all_violations(&rule, content_deep);
979 }
980
981 #[test]
982 fn test_loose_nested_item_fix_matches_check() {
983 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
986
987 let content = "- parent\n\n - nested wrong\n";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989
990 let warnings = rule.check(&ctx).unwrap();
992 assert_eq!(
993 warnings.len(),
994 1,
995 "Loose nested item with 2 spaces should be detected; got: {warnings:?}"
996 );
997
998 let fixed = rule.fix(&ctx).unwrap();
1000 assert_eq!(
1001 fixed, "- parent\n\n - nested wrong\n",
1002 "fix() should reduce 2 spaces to 1 for loose nested item"
1003 );
1004
1005 assert_fix_resolves_all_violations(&rule, content);
1007 }
1008
1009 #[test]
1010 fn test_ol_multi_reindents_nested_to_stay_attached() {
1011 let rule = MD030ListMarkerSpace::new(1, 1, 1, 3); let content = indoc! {"
1017 1. outer
1018 1. inner
1019 deep
1020 "};
1021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1022 assert_eq!(
1023 rule.fix(&ctx).unwrap(),
1024 indoc! {"
1025 1. outer
1026 1. inner
1027 deep
1028 "}
1029 );
1030 assert_fix_resolves_all_violations(&rule, content);
1031 }
1032
1033 #[test]
1034 fn test_ol_multi_does_not_reindent_when_narrowing() {
1035 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1); let content = indoc! {"
1040 1. outer
1041 continuation
1042 "};
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044 assert_eq!(
1045 rule.fix(&ctx).unwrap(),
1046 indoc! {"
1047 1. outer
1048 continuation
1049 "},
1050 "marker narrows to 1 space; the over-indented continuation is left for MD077"
1051 );
1052 }
1053
1054 #[test]
1055 fn test_ol_align_column_off_by_default() {
1056 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1);
1059 let content = indoc! {"
1060 1. one
1061 9. nine
1062 10. ten
1063 "};
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 assert!(
1066 rule.check(&ctx).unwrap().is_empty(),
1067 "Default behaviour should not require column alignment"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_ol_align_column_basic() {
1073 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1076 let content = indoc! {"
1077 1. one
1078 9. nine
1079 10. ten
1080 "};
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082
1083 let warnings = rule.check(&ctx).unwrap();
1084 assert_eq!(
1085 warnings.len(),
1086 2,
1087 "Single-digit markers should be flagged; got: {warnings:?}"
1088 );
1089 assert!(warnings.iter().all(|w| w.line == 1 || w.line == 2));
1090 assert!(
1091 warnings[0].message.contains("Expected: 2") && warnings[0].message.contains("Actual: 1"),
1092 "Message should report the aligned target; got: {}",
1093 warnings[0].message
1094 );
1095 assert_eq!(
1096 warnings[0].column, 3,
1097 "Span should start at the whitespace after the marker"
1098 );
1099
1100 assert_eq!(
1101 rule.fix(&ctx).unwrap(),
1102 indoc! {"
1103 1. one
1104 9. nine
1105 10. ten
1106 "}
1107 );
1108 assert_fix_resolves_all_violations(&rule, content);
1109 }
1110
1111 #[test]
1112 fn test_ol_align_column_wide_marker_overflows() {
1113 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1116 let content = indoc! {"
1117 1. a
1118 100. b
1119 "};
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121
1122 assert_eq!(
1123 rule.fix(&ctx).unwrap(),
1124 indoc! {"
1125 1. a
1126 100. b
1127 "},
1128 "narrow marker sits at column 4; wide marker overflows to column 5"
1129 );
1130 assert_fix_resolves_all_violations(&rule, content);
1131 }
1132
1133 #[test]
1134 fn test_ol_align_column_max_is_four_spaces() {
1135 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(6);
1139 let content = indoc! {"
1140 1. one
1141 2. two
1142 "};
1143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1144 assert_eq!(
1145 rule.fix(&ctx).unwrap(),
1146 indoc! {"
1147 1. one
1148 2. two
1149 "},
1150 "column 6 pads `1.` to exactly 4 spaces, never more"
1151 );
1152 assert_fix_resolves_all_violations(&rule, content);
1153 }
1154
1155 #[test]
1156 fn test_ol_align_column_already_aligned_is_clean() {
1157 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1158 let content = indoc! {"
1159 1. one
1160 9. nine
1161 10. ten
1162 "};
1163 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1164 assert!(
1165 rule.check(&ctx).unwrap().is_empty(),
1166 "Already-aligned list should produce no warnings"
1167 );
1168 }
1169
1170 #[test]
1171 fn test_ol_align_column_reindents_nested_list() {
1172 let rule = MD030ListMarkerSpace::new(3, 1, 1, 1).with_ol_align_column(4); let content = indoc! {"
1178 1. - x
1179 - y
1180 "};
1181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1182 assert_eq!(
1183 rule.fix(&ctx).unwrap(),
1184 indoc! {"
1185 1. - x
1186 - y
1187 "}
1188 );
1189 assert_fix_resolves_all_violations(&rule, content);
1190 }
1191
1192 #[test]
1193 fn test_ol_align_column_inline_non_marker_left_alone() {
1194 let rule = MD030ListMarkerSpace::new(3, 1, 1, 1).with_ol_align_column(4);
1198 for (input, expected) in [
1199 ("1. -text\n", "1. -text\n"),
1200 ("1. *emphasis* here\n", "1. *emphasis* here\n"),
1201 ] {
1202 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
1203 assert_eq!(rule.fix(&ctx).unwrap(), expected, "input: {input:?}");
1204 }
1205 }
1206
1207 #[test]
1208 fn test_ol_align_column_reindents_multi_level() {
1209 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1212 let content = indoc! {"
1213 1. text
1214 1. a
1215 z
1216 "};
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218 assert_eq!(
1219 rule.fix(&ctx).unwrap(),
1220 indoc! {"
1221 1. text
1222 1. a
1223 z
1224 "}
1225 );
1226 assert_fix_resolves_all_violations(&rule, content);
1227 }
1228
1229 #[test]
1230 fn test_ol_align_column_reindents_multiline_nested_unordered() {
1231 let rule = MD030ListMarkerSpace::new(1, 3, 1, 1).with_ol_align_column(4); let content = indoc! {"
1237 1. - first
1238 more first
1239 - second
1240 more second
1241 "};
1242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1243 assert_eq!(
1244 rule.fix(&ctx).unwrap(),
1245 indoc! {"
1246 1. - first
1247 more first
1248 - second
1249 more second
1250 "}
1251 );
1252 assert_fix_resolves_all_violations(&rule, content);
1253 }
1254
1255 #[test]
1256 fn test_ol_align_column_reindents_multiline_nested_ordered() {
1257 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1261 let content = indoc! {"
1262 1. text
1263 more text
1264 1. inner
1265 more inner
1266 2. inner2
1267 more inner2
1268 "};
1269 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1270 assert_eq!(
1271 rule.fix(&ctx).unwrap(),
1272 indoc! {"
1273 1. text
1274 more text
1275 1. inner
1276 more inner
1277 2. inner2
1278 more inner2
1279 "}
1280 );
1281 assert_fix_resolves_all_violations(&rule, content);
1282 }
1283
1284 #[test]
1285 fn test_ol_align_column_nested_aligns_relative() {
1286 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1290 let content = indoc! {"
1291 1. p
1292 1. a
1293 2. b
1294 9. i
1295 10. j
1296 "};
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298 assert_eq!(
1299 rule.fix(&ctx).unwrap(),
1300 indoc! {"
1301 1. p
1302 1. a
1303 2. b
1304 9. i
1305 10. j
1306 "}
1307 );
1308 assert_fix_resolves_all_violations(&rule, content);
1309 }
1310
1311 #[test]
1312 fn test_ol_align_column_blockquote_items_in_a_list() {
1313 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1316 let content = indoc! {"
1317 1. > a
1318 > b
1319 2. > c
1320 "};
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322 assert_eq!(
1323 rule.fix(&ctx).unwrap(),
1324 indoc! {"
1325 1. > a
1326 > b
1327 2. > c
1328 "}
1329 );
1330 assert_fix_resolves_all_violations(&rule, content);
1331 }
1332
1333 #[test]
1334 fn test_ol_align_column_detached_blockquote_left_alone() {
1335 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1340 let detached = indoc! {"
1341 1. > x
1342 > y
1343 "};
1344 let ctx = LintContext::new(detached, crate::config::MarkdownFlavor::Standard, None);
1345 assert_eq!(
1346 rule.fix(&ctx).unwrap(),
1347 detached,
1348 "a detached top-level blockquote must be left as is"
1349 );
1350 }
1351
1352 #[test]
1353 fn test_ol_align_column_preserves_blockquote_alignment() {
1354 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1359 let content = indoc! {"
1360 1. > 1. x
1361 > 2. y
1362
1363 2. > z
1364
1365 3. > 1. a
1366 > 2. b
1367 "};
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369
1370 assert!(
1373 rule.check(&ctx).unwrap().is_empty(),
1374 "items already at column 4 must not be flagged; got: {:?}",
1375 rule.check(&ctx).unwrap()
1376 );
1377 assert_eq!(
1378 rule.fix(&ctx).unwrap(),
1379 content,
1380 "fix must leave the aligned input untouched"
1381 );
1382 }
1383
1384 #[test]
1385 fn test_ol_align_column_reindents_mixed_content() {
1386 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1389 let content = indoc! {"
1390 1. > x
1391 - sub
1392 "};
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394 assert_eq!(
1395 rule.fix(&ctx).unwrap(),
1396 indoc! {"
1397 1. > x
1398 - sub
1399 "}
1400 );
1401 assert_fix_resolves_all_violations(&rule, content);
1402 }
1403
1404 #[test]
1405 fn test_ol_align_column_in_blockquote() {
1406 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1409 let content = indoc! {"
1410 > 1. one
1411 > 9. nine
1412 > 10. ten
1413 "};
1414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1415 assert_eq!(
1416 rule.fix(&ctx).unwrap(),
1417 indoc! {"
1418 > 1. one
1419 > 9. nine
1420 > 10. ten
1421 "}
1422 );
1423 assert_fix_resolves_all_violations(&rule, content);
1424 }
1425
1426 #[test]
1427 fn test_ol_align_column_multiline_item_in_blockquote() {
1428 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1433 let content = indoc! {"
1434 > 1. text
1435 > more
1436 > 2. second
1437 "};
1438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1439 assert_eq!(
1440 rule.fix(&ctx).unwrap(),
1441 indoc! {"
1442 > 1. text
1443 > more
1444 > 2. second
1445 "}
1446 );
1447 assert_fix_resolves_all_violations(&rule, content);
1448 }
1449
1450 #[test]
1451 fn test_ol_align_column_nested_list_in_blockquote() {
1452 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1456 let content = indoc! {"
1457 > 1. text
1458 > 1. inner
1459 > more
1460 "};
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 assert_eq!(
1463 rule.fix(&ctx).unwrap(),
1464 indoc! {"
1465 > 1. text
1466 > 1. inner
1467 > more
1468 "}
1469 );
1470 assert_fix_resolves_all_violations(&rule, content);
1471 }
1472
1473 #[test]
1474 fn test_ol_align_column_does_not_affect_unordered_lists() {
1475 let rule = MD030ListMarkerSpace::new(1, 1, 1, 1).with_ol_align_column(4);
1477 let content = indoc! {"
1478 - a
1479 - b
1480 "};
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482 assert!(
1483 rule.check(&ctx).unwrap().is_empty(),
1484 "Unordered lists should be unaffected by ol-align-column"
1485 );
1486 }
1487
1488 #[test]
1489 fn test_has_content_after_marker() {
1490 assert!(!MD030ListMarkerSpace::has_content_after_marker("-", 1));
1492 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
1493 assert!(!MD030ListMarkerSpace::has_content_after_marker("- ", 1));
1494 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
1495 assert!(MD030ListMarkerSpace::has_content_after_marker("- item", 1));
1496 assert!(MD030ListMarkerSpace::has_content_after_marker("1. item", 2));
1497 assert!(!MD030ListMarkerSpace::has_content_after_marker("1.", 2));
1498 assert!(!MD030ListMarkerSpace::has_content_after_marker("1. ", 2));
1499 }
1500}