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