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