1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::list_fix_guard::{Allowed, drop_structure_changing_fixes, protect_literal_code};
6use crate::utils::list_indent_shift::{Nesting, move_owned_lines};
7use std::collections::HashMap;
8
9pub mod md007_config;
10use md007_config::MD007Config;
11
12#[derive(Debug, Clone, Default)]
13pub struct MD007ULIndent {
14 config: MD007Config,
15}
16
17impl MD007ULIndent {
18 pub fn new(indent: usize) -> Self {
19 Self {
20 config: MD007Config {
21 indent: crate::types::IndentSize::from_const(indent as u8),
22 start_indented: false,
23 start_indent: crate::types::IndentSize::from_const(2),
24 style: md007_config::IndentStyle::TextAligned,
25 style_explicit: false, indent_explicit: false, },
28 }
29 }
30
31 pub fn from_config_struct(config: MD007Config) -> Self {
32 Self { config }
33 }
34
35 fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
37 let mut visual_col = 0;
38
39 for (current_pos, ch) in content.chars().enumerate() {
40 if current_pos >= char_pos {
41 break;
42 }
43 if ch == '\t' {
44 visual_col = (visual_col / 4 + 1) * 4;
46 } else {
47 visual_col += 1;
48 }
49 }
50 visual_col
51 }
52
53 fn indent_relative_to_depth(
77 ctx: &crate::lint_context::LintContext,
78 line_info: &crate::lint_context::LineInfo,
79 depth: usize,
80 ) -> usize {
81 if depth == 0 {
82 return line_info.visual_indent;
83 }
84 let line_content = line_info.content(ctx.content);
89 let mut remaining = line_content;
90 let mut content_start = 0;
91 let mut stripped_levels = 0;
92 while stripped_levels < depth {
93 let trimmed = remaining.trim_start();
94 if !trimmed.starts_with('>') {
95 break;
96 }
97 content_start += remaining.len() - trimmed.len();
98 content_start += 1;
99 let after_gt = &trimmed[1..];
100 if let Some(stripped) = after_gt.strip_prefix(' ') {
101 content_start += 1;
102 remaining = stripped;
103 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
104 content_start += 1;
105 remaining = stripped;
106 } else {
107 remaining = after_gt;
108 }
109 stripped_levels += 1;
110 }
111 let content_after_prefix = &line_content[content_start..];
112 let ws_chars = content_after_prefix
113 .chars()
114 .take_while(|c| *c == ' ' || *c == '\t')
115 .count();
116 Self::char_pos_to_visual_column(content_after_prefix, ws_chars)
117 }
118
119 fn terminate_closed_items(
120 ctx: &crate::lint_context::LintContext,
121 line_info: &crate::lint_context::LineInfo,
122 list_stack: &mut Vec<(usize, usize, bool, usize, usize, bool, usize)>,
123 line_bq_depth: usize,
124 ) {
125 while let Some(&(_, _, _, content_col, item_bq_depth, _, _)) = list_stack.last() {
126 let closed = match item_bq_depth.cmp(&line_bq_depth) {
127 std::cmp::Ordering::Greater => true,
129 std::cmp::Ordering::Equal | std::cmp::Ordering::Less => {
138 content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
139 }
140 };
141 if closed {
142 list_stack.pop();
143 } else {
144 break;
145 }
146 }
147 }
148
149 fn calculate_expected_indent(
158 &self,
159 nesting_level: usize,
160 parent_info: Option<(bool, usize)>, ) -> usize {
162 if nesting_level == 0 {
163 return 0;
164 }
165
166 if self.config.style_explicit {
168 return match self.config.style {
169 md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
170 md007_config::IndentStyle::TextAligned => {
171 parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
172 }
173 };
174 }
175
176 if self.config.indent_explicit {
179 match parent_info {
180 Some((true, parent_content_col)) => {
181 return parent_content_col;
184 }
185 _ => {
186 return nesting_level * self.config.indent.get() as usize;
188 }
189 }
190 }
191
192 match parent_info {
194 Some((true, parent_content_col)) => {
195 parent_content_col
198 }
199 Some((false, parent_content_col)) => {
200 let parent_level = nesting_level.saturating_sub(1);
204 let expected_parent_marker = parent_level * self.config.indent.get() as usize;
205 let parent_marker_col = parent_content_col.saturating_sub(2);
207
208 if parent_marker_col == expected_parent_marker {
209 nesting_level * self.config.indent.get() as usize
211 } else {
212 parent_content_col
214 }
215 }
216 None => {
217 nesting_level * self.config.indent.get() as usize
219 }
220 }
221 }
222}
223
224impl Rule for MD007ULIndent {
225 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
226 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(7))
227 }
228
229 fn name(&self) -> &'static str {
230 "MD007"
231 }
232
233 fn description(&self) -> &'static str {
234 "Unordered list indentation"
235 }
236
237 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
238 let mut warnings = Vec::new();
239 let mut list_stack: Vec<(usize, usize, bool, usize, usize, bool, usize)> = Vec::new(); let mut moves = HashMap::new();
243
244 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
245 let parsed_list_item = ctx.list_item_on_line(line_idx + 1);
246 let is_skipped_region = |info: &crate::lint_context::LineInfo| {
248 info.in_code_block || info.in_front_matter || info.in_mkdocstrings || info.in_footnote_definition
249 };
250 let opens_fence_on_marker_line = parsed_list_item
263 .and_then(|item| line_info.content(ctx.content).get(item.content_column()..))
264 .is_some_and(|after_marker| {
265 let after_marker = after_marker.trim_start();
266 after_marker.starts_with("```") || after_marker.starts_with("~~~")
267 });
268 let fence_opening_marker_line = opens_fence_on_marker_line
269 && line_info.in_code_block
270 && !line_info.in_front_matter
271 && !line_info.in_mkdocstrings
272 && !line_info.in_footnote_definition;
273 if is_skipped_region(line_info) && !fence_opening_marker_line {
274 let region_start = line_idx == 0 || !is_skipped_region(&ctx.lines[line_idx - 1]);
281 if region_start && !line_info.is_blank {
282 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
283 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
284 }
285 continue;
286 }
287
288 if let Some(list_item) = parsed_list_item {
290 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
294 let line_content = line_info.content(ctx.content);
296 let mut remaining = line_content;
297 let mut content_start = 0;
298
299 loop {
300 let trimmed = remaining.trim_start();
301 if !trimmed.starts_with('>') {
302 break;
303 }
304 content_start += remaining.len() - trimmed.len();
306 content_start += 1;
308 let after_gt = &trimmed[1..];
309 if let Some(stripped) = after_gt.strip_prefix(' ') {
311 content_start += 1;
312 remaining = stripped;
313 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
314 content_start += 1;
315 remaining = stripped;
316 } else {
317 remaining = after_gt;
318 }
319 }
320
321 let content_after_prefix = &line_content[content_start..];
323 let adjusted_col = if list_item.marker_column() >= content_start {
325 list_item.marker_column() - content_start
326 } else {
327 list_item.marker_column()
329 };
330 (content_after_prefix.to_string(), adjusted_col)
331 } else {
332 (line_info.content(ctx.content).to_string(), list_item.marker_column())
333 };
334
335 let visual_marker_column =
337 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
338
339 let visual_content_column = if line_info.blockquote.is_some() {
341 let adjusted_content_col =
343 if list_item.content_column() >= (line_info.byte_len - content_for_calculation.len()) {
344 list_item.content_column() - (line_info.byte_len - content_for_calculation.len())
345 } else {
346 list_item.content_column()
347 };
348 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
349 } else {
350 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column())
351 };
352
353 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
357 0
358 } else {
359 visual_marker_column
360 };
361
362 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
364
365 while let Some(&(indent, _, _, _, item_bq_depth, _, _)) = list_stack.last() {
368 if item_bq_depth == bq_depth && indent >= visual_marker_for_nesting {
369 list_stack.pop();
370 } else if item_bq_depth > bq_depth {
371 list_stack.pop();
373 } else {
374 break;
375 }
376 }
377
378 while let Some(&(_, _, _, content_col, item_bq_depth, _, _)) = list_stack.last() {
391 if item_bq_depth < bq_depth
392 && content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
393 {
394 list_stack.pop();
395 } else {
396 break;
397 }
398 }
399
400 if list_item.is_ordered() {
402 list_stack.push((
405 visual_marker_column,
406 line_idx,
407 true,
408 visual_content_column,
409 bq_depth,
410 false,
411 visual_content_column,
412 ));
413 continue;
414 }
415
416 let threshold_ok = list_stack
440 .iter()
441 .any(|item| item.4 == bq_depth && item.2 && item.3 <= visual_marker_column);
442 if ctx.flavor != crate::config::MarkdownFlavor::MkDocs
453 && threshold_ok
454 && self.config.style_explicit
455 && self.config.style == md007_config::IndentStyle::Fixed
456 {
457 while let Some(&(_, _, _, _, item_bq_depth, _, source_content_col)) = list_stack.last() {
458 if item_bq_depth == bq_depth && source_content_col > visual_marker_column {
459 list_stack.pop();
460 } else {
461 break;
462 }
463 }
464 }
465 let chain_ok = list_stack
466 .iter()
467 .rev()
468 .find(|item| item.4 == bq_depth)
469 .is_some_and(|item| item.2 || item.5);
470 let ordered_chain = ctx.flavor != crate::config::MarkdownFlavor::MkDocs && threshold_ok && chain_ok;
471 let clamp_to_parent = ordered_chain
477 && self.config.style_explicit
478 && self.config.style == md007_config::IndentStyle::Fixed;
479 if ordered_chain && !clamp_to_parent {
480 list_stack.push((
481 visual_marker_column,
482 line_idx,
483 false,
484 visual_content_column,
485 bq_depth,
486 true,
487 visual_content_column,
488 ));
489 continue;
490 }
491
492 let nesting_level = list_stack.iter().filter(|item| item.4 == bq_depth).count();
494
495 let parent_info = list_stack
497 .iter()
498 .rev()
499 .find(|item| item.4 == bq_depth)
500 .map(|&(_, _, is_ordered, content_col, _, _, _)| (is_ordered, content_col));
501
502 let mut expected_indent = if self.config.start_indented && nesting_level == 0 {
508 self.config.start_indent.get() as usize
509 } else {
510 self.calculate_expected_indent(nesting_level, parent_info)
511 };
512
513 if clamp_to_parent && let Some((_, parent_content_col)) = parent_info {
518 expected_indent = expected_indent.max(parent_content_col);
519 }
520
521 let also_acceptable = if !clamp_to_parent
527 && self.config.indent_explicit
528 && parent_info.is_some_and(|(is_ordered, _)| is_ordered)
529 {
530 Some(nesting_level * self.config.indent.get() as usize)
531 } else {
532 None
533 };
534
535 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
539 && let Some(&(parent_marker_col, _, true, _, _, _, _)) =
540 list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2)
541 {
542 expected_indent = expected_indent.max(parent_marker_col + 4);
543 }
544
545 let accepted_indent = if also_acceptable.is_some_and(|alt| visual_marker_column == alt) {
551 visual_marker_column
552 } else {
553 expected_indent
554 };
555 let marker_width = visual_content_column.saturating_sub(visual_marker_column);
565 let expected_content_visual_col = accepted_indent + marker_width;
566 list_stack.push((
571 visual_marker_column,
572 line_idx,
573 false,
574 expected_content_visual_col,
575 bq_depth,
576 clamp_to_parent,
577 visual_content_column,
578 ));
579
580 if !self.config.start_indented && nesting_level == 0 && visual_marker_column == 0 {
586 continue;
587 }
588
589 if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
590 if let Some(alt) = also_acceptable {
592 expected_indent = alt;
593 }
594 let fix = {
596 let correct_indent = " ".repeat(expected_indent);
597
598 let replacement = if line_info.blockquote.is_some() {
601 let mut blockquote_count = 0;
603 for ch in line_info.content(ctx.content).chars() {
604 if ch == '>' {
605 blockquote_count += 1;
606 } else if ch != ' ' && ch != '\t' {
607 break;
608 }
609 }
610 let blockquote_prefix = if blockquote_count > 1 {
612 (0..blockquote_count)
613 .map(|_| "> ")
614 .collect::<String>()
615 .trim_end()
616 .to_string()
617 } else {
618 ">".to_string()
619 };
620 format!("{blockquote_prefix} {correct_indent}")
623 } else {
624 correct_indent
625 };
626
627 let start_byte = line_info.byte_offset;
630 let mut end_byte = line_info.byte_offset;
631
632 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
634 if i >= list_item.marker_column() {
635 break;
636 }
637 end_byte += ch.len_utf8();
638 }
639
640 Some(crate::rule::Fix::new(start_byte..end_byte, replacement))
641 };
642
643 warnings.push(LintWarning {
644 rule_name: Some(self.name().to_string()),
645 message: format!(
646 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
647 ),
648 line: line_idx + 1, column: 1, end_line: line_idx + 1,
651 end_column: visual_marker_column + 1, severity: Severity::Warning,
653 fix,
654 });
655 moves.insert(
656 line_idx,
657 (
658 warnings.len() - 1,
659 expected_indent as isize - visual_marker_column as isize,
660 ),
661 );
662 }
663 } else if !line_info.is_blank {
664 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
691 let prev_line = line_idx.checked_sub(1).map(|i| &ctx.lines[i]);
692 let prev_blank = prev_line.is_none_or(|p| p.is_blank);
693 let prev_bq_depth = prev_line
694 .and_then(|p| p.blockquote.as_ref())
695 .map_or(0, |bq| bq.nesting_level);
696 let same_container = prev_bq_depth == bq_depth;
697 let text = line_info
698 .blockquote
699 .as_ref()
700 .map_or_else(|| line_info.content(ctx.content), |bq| bq.content.as_str());
701 let trimmed = text.trim_start();
702 let starts_like_list_marker = match trimmed.as_bytes().first() {
703 Some(b'-' | b'*' | b'+') => {
704 matches!(trimmed.as_bytes().get(1), Some(b' ' | b'\t'))
705 }
706 Some(c) if c.is_ascii_digit() => {
707 let after_digits = trimmed.trim_start_matches(|ch: char| ch.is_ascii_digit());
711 let num_digits = trimmed.len() - after_digits.len();
712 let mut rest = after_digits.chars();
713 (1..=9).contains(&num_digits)
714 && matches!(rest.next(), Some('.' | ')'))
715 && matches!(rest.next(), Some(' ' | '\t') | None)
716 }
717 _ => false,
718 };
719 let prev_is_open_paragraph = prev_line.is_some_and(|p| {
726 !p.is_blank
727 && !p.in_code_block
728 && p.heading.is_none()
729 && !p.is_horizontal_rule
730 && !p.in_html_block
731 && !p.in_html_comment
732 && !p.is_div_marker
733 });
734 let is_lazy_paragraph_continuation = !prev_blank
735 && prev_is_open_paragraph
736 && same_container
737 && !starts_like_list_marker
738 && line_info.heading.is_none()
739 && !line_info.is_horizontal_rule
740 && !line_info.in_code_block
741 && !line_info.in_html_block
742 && !line_info.in_html_comment
743 && !line_info.is_div_marker;
744 if is_lazy_paragraph_continuation {
745 continue;
747 }
748 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
749 }
750 }
751
752 move_owned_lines(ctx, &moves, Nesting::Absolute, &mut warnings);
756 protect_literal_code(ctx, &mut warnings);
757 drop_structure_changing_fixes(ctx, &mut warnings, Allowed::Nothing);
758 Ok(warnings)
759 }
760
761 fn fixes_from_diagnostics(&self) -> bool {
762 true
763 }
764
765 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
766 let warnings = self.check(ctx)?;
768 let warnings =
769 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
770
771 if warnings.is_empty() {
773 return Ok(ctx.content.to_string());
774 }
775
776 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
777 }
778
779 fn category(&self) -> RuleCategory {
781 RuleCategory::List
782 }
783
784 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
786 if ctx.content.is_empty() || !ctx.likely_has_lists() {
788 return true;
789 }
790 !ctx.has_unordered_list_items()
792 }
793
794 fn as_any(&self) -> &dyn std::any::Any {
795 self
796 }
797
798 crate::impl_rule_config_sections!(MD007Config);
799
800 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
801 where
802 Self: Sized,
803 {
804 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
805
806 if let Some(rule_cfg) = config.rules.get("MD007") {
808 rule_config.style_explicit = rule_cfg.values.contains_key("style");
809 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
810
811 if rule_config.indent_explicit
815 && rule_config.style_explicit
816 && rule_config.style == md007_config::IndentStyle::TextAligned
817 {
818 eprintln!(
819 "{} MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
820 Text-aligned style ignores indent and aligns nested items with parent text. \
821 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
822 crate::utils::warning_label("config warning"),
823 rule_config.indent.get()
824 );
825 }
826 }
827
828 if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
831 if rule_config.indent_explicit && rule_config.indent.get() < 4 {
832 eprintln!(
833 "{} MD007: MkDocs flavor requires indent >= 4 \
834 (Python-Markdown enforces 4-space indentation). \
835 Overriding indent={} to indent=4.",
836 crate::utils::warning_label("config warning"),
837 rule_config.indent.get()
838 );
839 }
840 if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
841 eprintln!(
842 "{} MD007: MkDocs flavor requires style=\"fixed\" \
843 (Python-Markdown uses fixed 4-space indentation). \
844 Overriding style=\"text-aligned\" to style=\"fixed\".",
845 crate::utils::warning_label("config warning")
846 );
847 }
848 if rule_config.indent.get() < 4 {
849 rule_config.indent = crate::types::IndentSize::from_const(4);
850 }
851 rule_config.style = md007_config::IndentStyle::Fixed;
852 }
853
854 Box::new(Self::from_config_struct(rule_config))
855 }
856}
857
858#[cfg(test)]
859mod tests {
860 use super::*;
861 use crate::lint_context::LintContext;
862 use crate::rule::Rule;
863 use indoc::indoc;
864
865 fn fix_md007(content: &str) -> String {
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 MD007ULIndent::default().fix(&ctx).unwrap()
868 }
869
870 #[test]
871 fn test_fix_keeps_a_parent_paragraph_out_of_the_shifted_child() {
872 let content = "* parent\n\n * child\n\n Parent paragraph.\n";
875 assert_eq!(fix_md007(content), "* parent\n\n * child\n\n Parent paragraph.\n");
876
877 let quoted = "> * parent\n>\n> * child\n>\n> Parent paragraph.\n";
878 assert_eq!(
879 fix_md007(quoted),
880 "> * parent\n>\n> * child\n>\n> Parent paragraph.\n"
881 );
882 }
883
884 #[test]
885 fn test_fix_declines_when_the_child_would_capture_a_parent_code_block() {
886 let content = "* parent\n\n * child\n\n ```\n code\n ```\n";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let warnings = MD007ULIndent::default().check(&ctx).unwrap();
891 assert_eq!(warnings.len(), 1);
892 assert!(warnings[0].fix.is_none());
893 assert_eq!(fix_md007(content), content);
894 }
895
896 #[test]
897 fn test_unfixable_list_leaves_other_lists_fixable() {
898 let content = "* a\n * b\n\nParagraph.\n\n* parent\n\n * child\n\n ```\n code\n ```\n";
900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901 let warnings = MD007ULIndent::default().check(&ctx).unwrap();
902 assert_eq!(warnings.len(), 2, "{warnings:?}");
903 assert!(warnings[0].fix.is_some(), "{warnings:?}");
904 assert!(warnings[1].fix.is_none(), "{warnings:?}");
905 assert_eq!(
906 fix_md007(content),
907 "* a\n * b\n\nParagraph.\n\n* parent\n\n * child\n\n ```\n code\n ```\n"
908 );
909 }
910
911 #[test]
912 fn test_unfixable_list_leaves_a_later_list_fixable() {
913 let content = "* parent\n\n * child\n\n ```\n code\n ```\n\nParagraph.\n\n* a\n * b\n";
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let warnings = MD007ULIndent::default().check(&ctx).unwrap();
916 assert_eq!(warnings.len(), 2, "{warnings:?}");
917 assert!(warnings[0].fix.is_none(), "{warnings:?}");
918 assert!(warnings[1].fix.is_some(), "{warnings:?}");
919 assert_eq!(
920 fix_md007(content),
921 "* parent\n\n * child\n\n ```\n code\n ```\n\nParagraph.\n\n* a\n * b\n"
922 );
923 }
924
925 #[test]
926 fn test_valid_list_indent() {
927 let rule = MD007ULIndent::default();
928 let content = "* Item 1\n * Item 2\n * Item 3";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931 assert!(
932 result.is_empty(),
933 "Expected no warnings for valid indentation, but got {} warnings",
934 result.len()
935 );
936 }
937
938 #[test]
939 fn test_invalid_list_indent() {
940 let rule = MD007ULIndent::default();
941 let content = "* Item 1\n * Item 2\n * Item 3";
942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 let result = rule.check(&ctx).unwrap();
944 assert_eq!(result.len(), 2);
945 assert_eq!(result[0].line, 2);
946 assert_eq!(result[0].column, 1);
947 assert_eq!(result[1].line, 3);
948 assert_eq!(result[1].column, 1);
949 }
950
951 #[test]
952 fn test_mixed_indentation() {
953 let rule = MD007ULIndent::default();
954 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let result = rule.check(&ctx).unwrap();
957 assert_eq!(result.len(), 1);
958 assert_eq!(result[0].line, 3);
959 assert_eq!(result[0].column, 1);
960 }
961
962 #[test]
963 fn test_fix_indentation() {
964 let rule = MD007ULIndent::default();
965 let content = "* Item 1\n * Item 2\n * Item 3";
966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
967 let result = rule.fix(&ctx).unwrap();
968 let expected = "* Item 1\n * Item 2\n * Item 3";
972 assert_eq!(result, expected);
973 }
974
975 #[test]
976 fn test_md007_in_yaml_code_block() {
977 let rule = MD007ULIndent::default();
978 let content = r#"```yaml
979repos:
980- repo: https://github.com/rvben/rumdl
981 rev: v0.5.0
982 hooks:
983 - id: rumdl-check
984```"#;
985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
986 let result = rule.check(&ctx).unwrap();
987 assert!(
988 result.is_empty(),
989 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
990 );
991 }
992
993 #[test]
994 fn test_blockquoted_list_indent() {
995 let rule = MD007ULIndent::default();
996 let content = "> * Item 1\n> * Item 2\n> * Item 3";
997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998 let result = rule.check(&ctx).unwrap();
999 assert!(
1000 result.is_empty(),
1001 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
1002 );
1003 }
1004
1005 #[test]
1006 fn test_blockquoted_list_invalid_indent() {
1007 let rule = MD007ULIndent::default();
1008 let content = "> * Item 1\n> * Item 2\n> * Item 3";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010 let result = rule.check(&ctx).unwrap();
1011 assert_eq!(
1012 result.len(),
1013 2,
1014 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
1015 );
1016 assert_eq!(result[0].line, 2);
1017 assert_eq!(result[1].line, 3);
1018 }
1019
1020 #[test]
1021 fn test_nested_blockquote_list_indent() {
1022 let rule = MD007ULIndent::default();
1023 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
1024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025 let result = rule.check(&ctx).unwrap();
1026 assert!(
1027 result.is_empty(),
1028 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
1029 );
1030 }
1031
1032 #[test]
1033 fn test_blockquote_list_with_code_block() {
1034 let rule = MD007ULIndent::default();
1035 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
1036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037 let result = rule.check(&ctx).unwrap();
1038 assert!(
1039 result.is_empty(),
1040 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
1041 );
1042 }
1043
1044 #[test]
1045 fn test_properly_indented_lists() {
1046 let rule = MD007ULIndent::default();
1047
1048 let test_cases = vec![
1050 "* Item 1\n* Item 2",
1051 "* Item 1\n * Item 1.1\n * Item 1.1.1",
1052 "- Item 1\n - Item 1.1",
1053 "+ Item 1\n + Item 1.1",
1054 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
1055 ];
1056
1057 for content in test_cases {
1058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1059 let result = rule.check(&ctx).unwrap();
1060 assert!(
1061 result.is_empty(),
1062 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
1063 content,
1064 result.len()
1065 );
1066 }
1067 }
1068
1069 #[test]
1070 fn test_under_indented_lists() {
1071 let rule = MD007ULIndent::default();
1072
1073 let test_cases = vec![
1074 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
1077
1078 for (content, expected_warnings, line) in test_cases {
1079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1080 let result = rule.check(&ctx).unwrap();
1081 assert_eq!(
1082 result.len(),
1083 expected_warnings,
1084 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
1085 );
1086 if expected_warnings > 0 {
1087 assert_eq!(result[0].line, line);
1088 }
1089 }
1090 }
1091
1092 #[test]
1093 fn test_over_indented_lists() {
1094 let rule = MD007ULIndent::default();
1095
1096 let test_cases = vec![
1097 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
1101
1102 for (content, expected_warnings, line) in test_cases {
1103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104 let result = rule.check(&ctx).unwrap();
1105 assert_eq!(
1106 result.len(),
1107 expected_warnings,
1108 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
1109 );
1110 if expected_warnings > 0 {
1111 assert_eq!(result[0].line, line);
1112 }
1113 }
1114 }
1115
1116 #[test]
1117 fn test_custom_indent_2_spaces() {
1118 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121 let result = rule.check(&ctx).unwrap();
1122 assert!(result.is_empty());
1123 }
1124
1125 #[test]
1126 fn test_custom_indent_3_spaces() {
1127 let rule = MD007ULIndent::new(3);
1130
1131 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1133 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1134 let result = rule.check(&ctx).unwrap();
1135 assert!(
1136 result.is_empty(),
1137 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
1138 );
1139
1140 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1142 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1143 let result = rule.check(&ctx).unwrap();
1144 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
1145 }
1146
1147 #[test]
1148 fn test_custom_indent_4_spaces() {
1149 let rule = MD007ULIndent::new(4);
1152
1153 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1155 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1156 let result = rule.check(&ctx).unwrap();
1157 assert!(
1158 result.is_empty(),
1159 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
1160 );
1161
1162 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1164 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1165 let result = rule.check(&ctx).unwrap();
1166 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
1167 }
1168
1169 #[test]
1170 fn test_tab_indentation() {
1171 let rule = MD007ULIndent::default();
1172
1173 let content = "* Item 1\n * Item 2";
1179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180 let result = rule.check(&ctx).unwrap();
1181 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
1182
1183 let fixed = rule.fix(&ctx).unwrap();
1185 assert_eq!(fixed, "* Item 1\n * Item 2");
1186
1187 let content_multi = "* Item 1\n * Item 2\n * Item 3";
1189 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
1190 let fixed = rule.fix(&ctx).unwrap();
1191 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1194
1195 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
1197 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
1198 let fixed = rule.fix(&ctx).unwrap();
1199 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1202 }
1203
1204 #[test]
1205 fn test_mixed_ordered_unordered_lists() {
1206 let rule = MD007ULIndent::default();
1207
1208 let content = r#"1. Ordered item
1211 * Unordered sub-item (correct - 3 spaces under ordered)
1212 2. Ordered sub-item
1213* Unordered item
1214 1. Ordered sub-item
1215 * Unordered sub-item"#;
1216
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218 let result = rule.check(&ctx).unwrap();
1219 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
1220
1221 let fixed = rule.fix(&ctx).unwrap();
1223 assert_eq!(fixed, content);
1224 }
1225
1226 #[test]
1227 fn test_list_markers_variety() {
1228 let rule = MD007ULIndent::default();
1229
1230 let content = r#"* Asterisk
1232 * Nested asterisk
1233- Hyphen
1234 - Nested hyphen
1235+ Plus
1236 + Nested plus"#;
1237
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239 let result = rule.check(&ctx).unwrap();
1240 assert!(
1241 result.is_empty(),
1242 "All unordered list markers should work with proper indentation"
1243 );
1244
1245 let wrong_content = r#"* Asterisk
1247 * Wrong asterisk
1248- Hyphen
1249 - Wrong hyphen
1250+ Plus
1251 + Wrong plus"#;
1252
1253 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1254 let result = rule.check(&ctx).unwrap();
1255 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
1256 }
1257
1258 #[test]
1259 fn test_empty_list_items() {
1260 let rule = MD007ULIndent::default();
1261 let content = "* Item 1\n* \n * Item 2";
1262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1263 let result = rule.check(&ctx).unwrap();
1264 assert!(
1265 result.is_empty(),
1266 "Empty list items should not affect indentation checks"
1267 );
1268 }
1269
1270 #[test]
1271 fn test_list_with_code_blocks() {
1272 let rule = MD007ULIndent::default();
1273 let content = r#"* Item 1
1274 ```
1275 code
1276 ```
1277 * Item 2
1278 * Item 3"#;
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 let result = rule.check(&ctx).unwrap();
1281 assert!(result.is_empty());
1282 }
1283
1284 #[test]
1285 fn test_list_in_front_matter() {
1286 let rule = MD007ULIndent::default();
1287 let content = r#"---
1288tags:
1289 - tag1
1290 - tag2
1291---
1292* Item 1
1293 * Item 2"#;
1294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295 let result = rule.check(&ctx).unwrap();
1296 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
1297 }
1298
1299 #[test]
1300 fn test_fix_preserves_content() {
1301 let rule = MD007ULIndent::default();
1302 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304 let fixed = rule.fix(&ctx).unwrap();
1305 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1308 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
1309 }
1310
1311 #[test]
1312 fn test_start_indented_config() {
1313 let config = MD007Config {
1314 start_indented: true,
1315 start_indent: crate::types::IndentSize::from_const(4),
1316 indent: crate::types::IndentSize::from_const(2),
1317 style: md007_config::IndentStyle::TextAligned,
1318 style_explicit: true, indent_explicit: false,
1320 };
1321 let rule = MD007ULIndent::from_config_struct(config);
1322
1323 let content = " * Item 1\n * Item 2\n * Item 3";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329 let result = rule.check(&ctx).unwrap();
1330 assert!(result.is_empty(), "Expected no warnings with start_indented config");
1331
1332 let wrong_content = " * Item 1\n * Item 2";
1334 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1335 let result = rule.check(&ctx).unwrap();
1336 assert_eq!(result.len(), 2);
1337 assert_eq!(result[0].line, 1);
1338 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
1339 assert_eq!(result[1].line, 2);
1340 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
1341
1342 let fixed = rule.fix(&ctx).unwrap();
1345 assert_eq!(fixed, wrong_content);
1346 }
1347
1348 #[test]
1349 fn test_start_indented_false_flags_indented_first_level() {
1350 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1358 let result = rule.check(&ctx).unwrap();
1359 assert!(
1360 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1361 "a top-level item indented 3 spaces must be flagged with Expected 0, got: {result:?}"
1362 );
1363
1364 let content = "* Item 1\n * Item 2\n * Item 3";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369 let result = rule.check(&ctx).unwrap();
1370 assert!(
1371 result.is_empty(),
1372 "a correctly nested 0/2/4-space list should produce no warnings, got: {result:?}"
1373 );
1374 }
1375
1376 #[test]
1377 fn test_deeply_nested_lists() {
1378 let rule = MD007ULIndent::default();
1379 let content = r#"* L1
1380 * L2
1381 * L3
1382 * L4
1383 * L5
1384 * L6"#;
1385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386 let result = rule.check(&ctx).unwrap();
1387 assert!(result.is_empty());
1388
1389 let wrong_content = r#"* L1
1391 * L2
1392 * L3
1393 * L4
1394 * L5
1395 * L6"#;
1396 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1397 let result = rule.check(&ctx).unwrap();
1398 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
1399 }
1400
1401 #[test]
1402 fn test_excessive_indentation_detected() {
1403 let rule = MD007ULIndent::default();
1404
1405 let content = "- Item 1\n - Item 2 with 5 spaces";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let result = rule.check(&ctx).unwrap();
1409 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
1410 assert_eq!(result[0].line, 2);
1411 assert!(result[0].message.contains("Expected 2 spaces"));
1412 assert!(result[0].message.contains("found 5"));
1413
1414 let content = "- Item 1\n - Item 2 with 3 spaces";
1416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417 let result = rule.check(&ctx).unwrap();
1418 assert_eq!(
1419 result.len(),
1420 1,
1421 "Should detect slightly excessive indentation (3 instead of 2)"
1422 );
1423 assert_eq!(result[0].line, 2);
1424 assert!(result[0].message.contains("Expected 2 spaces"));
1425 assert!(result[0].message.contains("found 3"));
1426
1427 let content = "- Item 1\n - Item 2 with 1 space";
1429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1430 let result = rule.check(&ctx).unwrap();
1431 assert_eq!(
1432 result.len(),
1433 1,
1434 "Should detect 1-space indent (insufficient for nesting, expected 0)"
1435 );
1436 assert_eq!(result[0].line, 2);
1437 assert!(result[0].message.contains("Expected 0 spaces"));
1438 assert!(result[0].message.contains("found 1"));
1439 }
1440
1441 #[test]
1442 fn test_excessive_indentation_with_4_space_config() {
1443 let rule = MD007ULIndent::new(4);
1446
1447 let content = "- Formatter:\n - The stable style changed";
1449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1450 let result = rule.check(&ctx).unwrap();
1451 assert!(
1452 !result.is_empty(),
1453 "Should detect 5 spaces when expecting 4 (fixed style)"
1454 );
1455
1456 let correct_content = "- Formatter:\n - The stable style changed";
1458 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1459 let result = rule.check(&ctx).unwrap();
1460 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
1461 }
1462
1463 #[test]
1464 fn test_bullets_nested_under_numbered_items() {
1465 let rule = MD007ULIndent::default();
1466 let content = "\
14671. **Active Directory/LDAP**
1468 - User authentication and directory services
1469 - LDAP for user information and validation
1470
14712. **Oracle Unified Directory (OUD)**
1472 - Extended user directory services";
1473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474 let result = rule.check(&ctx).unwrap();
1475 assert!(
1477 result.is_empty(),
1478 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1479 );
1480 }
1481
1482 #[test]
1483 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1484 let rule = MD007ULIndent::default();
1485 let content = "\
14861. **Active Directory/LDAP**
1487 - Wrong: only 2 spaces";
1488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1489 let result = rule.check(&ctx).unwrap();
1490 assert_eq!(
1492 result.len(),
1493 1,
1494 "Expected warning for incorrect indentation under numbered items"
1495 );
1496 assert!(
1497 result
1498 .iter()
1499 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1500 );
1501 }
1502
1503 #[test]
1504 fn test_regular_bullet_nesting_still_works() {
1505 let rule = MD007ULIndent::default();
1506 let content = "\
1507* Top level
1508 * Nested bullet (2 spaces is correct)
1509 * Deeply nested (4 spaces)";
1510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511 let result = rule.check(&ctx).unwrap();
1512 assert!(
1514 result.is_empty(),
1515 "Expected no warnings for standard bullet nesting, got: {result:?}"
1516 );
1517 }
1518
1519 #[test]
1520 fn test_blockquote_with_tab_after_marker() {
1521 let rule = MD007ULIndent::default();
1522 let content = ">\t* List item\n>\t * Nested\n";
1523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524 let result = rule.check(&ctx).unwrap();
1525 assert!(
1526 result.is_empty(),
1527 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1528 );
1529 }
1530
1531 #[test]
1532 fn test_blockquote_with_space_then_tab_after_marker() {
1533 let rule = MD007ULIndent::default();
1534 let content = "> \t* List item\n";
1535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1536 let result = rule.check(&ctx).unwrap();
1537 assert!(
1542 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1543 "an indented blockquoted top-level item must be flagged with Expected 0, got: {result:?}"
1544 );
1545 }
1546
1547 #[test]
1548 fn test_blockquote_with_multiple_tabs() {
1549 let rule = MD007ULIndent::default();
1550 let content = ">\t\t* List item\n";
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552 let result = rule.check(&ctx).unwrap();
1553 assert!(
1555 result.is_empty(),
1556 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_nested_blockquote_with_tab() {
1562 let rule = MD007ULIndent::default();
1563 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1565 let result = rule.check(&ctx).unwrap();
1566 assert!(
1567 result.is_empty(),
1568 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1569 );
1570 }
1571
1572 #[test]
1575 fn test_smart_style_pure_unordered_uses_fixed() {
1576 let rule = MD007ULIndent::new(4);
1578
1579 let content = "* Level 0\n * Level 1\n * Level 2";
1581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1582 let result = rule.check(&ctx).unwrap();
1583 assert!(
1584 result.is_empty(),
1585 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1586 );
1587 }
1588
1589 #[test]
1590 fn test_smart_style_mixed_lists_uses_text_aligned() {
1591 let rule = MD007ULIndent::new(4);
1593
1594 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597 let result = rule.check(&ctx).unwrap();
1598 assert!(
1599 result.is_empty(),
1600 "Mixed lists should use text-aligned style, got: {result:?}"
1601 );
1602 }
1603
1604 #[test]
1605 fn test_smart_style_explicit_fixed_overrides() {
1606 let config = MD007Config {
1608 indent: crate::types::IndentSize::from_const(4),
1609 start_indented: false,
1610 start_indent: crate::types::IndentSize::from_const(2),
1611 style: md007_config::IndentStyle::Fixed,
1612 style_explicit: true, indent_explicit: false,
1614 };
1615 let rule = MD007ULIndent::from_config_struct(config);
1616
1617 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620 let result = rule.check(&ctx).unwrap();
1621 assert!(
1623 result.is_empty(),
1624 "Explicit fixed style should be respected, got: {result:?}"
1625 );
1626 }
1627
1628 #[test]
1629 fn test_smart_style_explicit_text_aligned_overrides() {
1630 let config = MD007Config {
1632 indent: crate::types::IndentSize::from_const(4),
1633 start_indented: false,
1634 start_indent: crate::types::IndentSize::from_const(2),
1635 style: md007_config::IndentStyle::TextAligned,
1636 style_explicit: true, indent_explicit: false,
1638 };
1639 let rule = MD007ULIndent::from_config_struct(config);
1640
1641 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1644 let result = rule.check(&ctx).unwrap();
1645 assert!(
1646 result.is_empty(),
1647 "Explicit text-aligned should be respected, got: {result:?}"
1648 );
1649
1650 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1652 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1653 let result = rule.check(&ctx).unwrap();
1654 assert!(
1655 !result.is_empty(),
1656 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1657 );
1658 }
1659
1660 #[test]
1661 fn test_smart_style_default_indent_no_autoswitch() {
1662 let rule = MD007ULIndent::new(2);
1664
1665 let content = "* Level 0\n * Level 1\n * Level 2";
1666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1667 let result = rule.check(&ctx).unwrap();
1668 assert!(
1669 result.is_empty(),
1670 "Default indent should work regardless of style, got: {result:?}"
1671 );
1672 }
1673
1674 #[test]
1675 fn test_has_mixed_list_nesting_detection() {
1676 let content = "* Item 1\n * Item 2\n * Item 3";
1680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681 assert!(
1682 !ctx.has_mixed_list_nesting(),
1683 "Pure unordered should not be detected as mixed"
1684 );
1685
1686 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1689 assert!(
1690 !ctx.has_mixed_list_nesting(),
1691 "Pure ordered should not be detected as mixed"
1692 );
1693
1694 let content = "1. Ordered\n * Unordered child";
1696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1697 assert!(
1698 ctx.has_mixed_list_nesting(),
1699 "Unordered under ordered should be detected as mixed"
1700 );
1701
1702 let content = "* Unordered\n 1. Ordered child";
1704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1705 assert!(
1706 ctx.has_mixed_list_nesting(),
1707 "Ordered under unordered should be detected as mixed"
1708 );
1709
1710 let content = "* Unordered\n\n1. Ordered (separate list)";
1712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1713 assert!(
1714 !ctx.has_mixed_list_nesting(),
1715 "Separate lists should not be detected as mixed"
1716 );
1717
1718 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1721 assert!(
1722 ctx.has_mixed_list_nesting(),
1723 "Mixed lists in blockquotes should be detected"
1724 );
1725 }
1726
1727 #[test]
1728 fn test_issue_210_exact_reproduction() {
1729 let config = MD007Config {
1731 indent: crate::types::IndentSize::from_const(4),
1732 start_indented: false,
1733 start_indent: crate::types::IndentSize::from_const(2),
1734 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1738 let rule = MD007ULIndent::from_config_struct(config);
1739
1740 let content = "# Title\n\n* some\n * list\n * items\n";
1741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1742 let result = rule.check(&ctx).unwrap();
1743
1744 assert!(
1745 result.is_empty(),
1746 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_issue_209_still_fixed() {
1752 let config = MD007Config {
1755 indent: crate::types::IndentSize::from_const(3),
1756 start_indented: false,
1757 start_indent: crate::types::IndentSize::from_const(2),
1758 style: md007_config::IndentStyle::TextAligned,
1759 style_explicit: true, indent_explicit: false,
1761 };
1762 let rule = MD007ULIndent::from_config_struct(config);
1763
1764 let content = r#"# Header 1
1766
1767- **Second item**:
1768 - **This is a nested list**:
1769 1. **First point**
1770 - First subpoint
1771"#;
1772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773 let result = rule.check(&ctx).unwrap();
1774
1775 assert!(
1776 result.is_empty(),
1777 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1778 );
1779 }
1780
1781 #[test]
1784 fn test_multi_level_mixed_detection_grandparent() {
1785 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1790 assert!(
1791 ctx.has_mixed_list_nesting(),
1792 "Should detect mixed nesting when grandparent differs in type"
1793 );
1794
1795 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1798 assert!(
1799 ctx.has_mixed_list_nesting(),
1800 "Should detect mixed nesting for ordered descendants under unordered"
1801 );
1802 }
1803
1804 #[test]
1805 fn test_html_comments_skipped_in_detection() {
1806 let content = r#"* Unordered list
1808<!-- This is a comment
1809 1. This ordered list is inside a comment
1810 * This nested bullet is also inside
1811-->
1812 * Another unordered item"#;
1813 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1814 assert!(
1815 !ctx.has_mixed_list_nesting(),
1816 "Lists in HTML comments should be ignored in mixed detection"
1817 );
1818 }
1819
1820 #[test]
1821 fn test_blank_lines_separate_lists() {
1822 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1825 assert!(
1826 !ctx.has_mixed_list_nesting(),
1827 "Blank line at root should separate lists"
1828 );
1829
1830 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1833 assert!(
1834 ctx.has_mixed_list_nesting(),
1835 "Indented list after blank is still nested"
1836 );
1837 }
1838
1839 #[test]
1840 fn test_column_1_normalization() {
1841 let content = "* First item\n * Second item with 1 space (sibling)";
1844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1845 let rule = MD007ULIndent::default();
1846 let result = rule.check(&ctx).unwrap();
1847 assert!(
1849 result.iter().any(|w| w.line == 2),
1850 "1-space indent should be flagged as incorrect"
1851 );
1852 }
1853
1854 #[test]
1855 fn test_code_blocks_skipped_in_detection() {
1856 let content = r#"* Unordered list
1858```
18591. This ordered list is inside a code block
1860 * This nested bullet is also inside
1861```
1862 * Another unordered item"#;
1863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1864 assert!(
1865 !ctx.has_mixed_list_nesting(),
1866 "Lists in code blocks should be ignored in mixed detection"
1867 );
1868 }
1869
1870 #[test]
1871 fn test_front_matter_skipped_in_detection() {
1872 let content = r#"---
1874items:
1875 - yaml list item
1876 - another item
1877---
1878* Unordered list after front matter"#;
1879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880 assert!(
1881 !ctx.has_mixed_list_nesting(),
1882 "Lists in front matter should be ignored in mixed detection"
1883 );
1884 }
1885
1886 #[test]
1887 fn test_alternating_types_at_same_level() {
1888 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892 assert!(
1893 !ctx.has_mixed_list_nesting(),
1894 "Alternating types at same level should not be detected as mixed"
1895 );
1896 }
1897
1898 #[test]
1899 fn test_five_level_deep_mixed_nesting() {
1900 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1903 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1904 }
1905
1906 #[test]
1907 fn test_very_deep_pure_unordered_nesting() {
1908 let mut content = String::from("* L1");
1910 for level in 2..=12 {
1911 let indent = " ".repeat(level - 1);
1912 content.push_str(&format!("\n{indent}* L{level}"));
1913 }
1914
1915 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1916
1917 assert!(
1919 !ctx.has_mixed_list_nesting(),
1920 "Pure unordered deep nesting should not be detected as mixed"
1921 );
1922
1923 let rule = MD007ULIndent::new(4);
1925 let result = rule.check(&ctx).unwrap();
1926 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1929 }
1930
1931 #[test]
1932 fn test_interleaved_content_between_list_items() {
1933 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 assert!(
1937 ctx.has_mixed_list_nesting(),
1938 "Should detect mixed nesting even with interleaved paragraphs"
1939 );
1940 }
1941
1942 #[test]
1943 fn test_esm_blocks_skipped_in_detection() {
1944 let content = "* Unordered list\n * Nested unordered";
1947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1948 assert!(
1949 !ctx.has_mixed_list_nesting(),
1950 "Pure unordered should not be detected as mixed"
1951 );
1952 }
1953
1954 #[test]
1955 fn test_multiple_list_blocks_pure_then_mixed() {
1956 let content = r#"* Pure unordered
1959 * Nested unordered
1960
19611. Mixed section
1962 * Bullet under ordered"#;
1963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1964 assert!(
1965 ctx.has_mixed_list_nesting(),
1966 "Should detect mixed nesting in any part of document"
1967 );
1968 }
1969
1970 #[test]
1971 fn test_multiple_separate_pure_lists() {
1972 let content = r#"* First list
1975 * Nested
1976
1977* Second list
1978 * Also nested
1979
1980* Third list
1981 * Deeply
1982 * Nested"#;
1983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1984 assert!(
1985 !ctx.has_mixed_list_nesting(),
1986 "Multiple separate pure unordered lists should not be mixed"
1987 );
1988 }
1989
1990 #[test]
1991 fn test_code_block_between_list_items() {
1992 let content = r#"1. Ordered
1994 ```
1995 code
1996 ```
1997 * Still a mixed child"#;
1998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1999 assert!(
2000 ctx.has_mixed_list_nesting(),
2001 "Code block between items should not prevent mixed detection"
2002 );
2003 }
2004
2005 #[test]
2006 fn test_blockquoted_mixed_detection() {
2007 let content = "> 1. Ordered in blockquote\n> * Mixed child";
2009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2010 assert!(
2013 ctx.has_mixed_list_nesting(),
2014 "Should detect mixed nesting in blockquotes"
2015 );
2016 }
2017
2018 #[test]
2021 fn test_indent_explicit_uses_fixed_style() {
2022 let config = MD007Config {
2025 indent: crate::types::IndentSize::from_const(4),
2026 start_indented: false,
2027 start_indent: crate::types::IndentSize::from_const(2),
2028 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
2032 let rule = MD007ULIndent::from_config_struct(config);
2033
2034 let content = "* Level 0\n * Level 1\n * Level 2";
2037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2038 let result = rule.check(&ctx).unwrap();
2039 assert!(
2040 result.is_empty(),
2041 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
2042 );
2043
2044 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
2046 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2047 let result = rule.check(&ctx).unwrap();
2048 assert!(
2049 !result.is_empty(),
2050 "Should flag text-aligned spacing when indent_explicit=true"
2051 );
2052 }
2053
2054 #[test]
2055 fn test_explicit_style_overrides_indent_explicit() {
2056 let config = MD007Config {
2059 indent: crate::types::IndentSize::from_const(4),
2060 start_indented: false,
2061 start_indent: crate::types::IndentSize::from_const(2),
2062 style: md007_config::IndentStyle::TextAligned,
2063 style_explicit: true, indent_explicit: true, };
2066 let rule = MD007ULIndent::from_config_struct(config);
2067
2068 let content = "* Level 0\n * Level 1\n * Level 2";
2070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2071 let result = rule.check(&ctx).unwrap();
2072 assert!(
2073 result.is_empty(),
2074 "Explicit text-aligned style should be respected, got: {result:?}"
2075 );
2076 }
2077
2078 #[test]
2079 fn test_no_indent_explicit_uses_smart_detection() {
2080 let config = MD007Config {
2082 indent: crate::types::IndentSize::from_const(4),
2083 start_indented: false,
2084 start_indent: crate::types::IndentSize::from_const(2),
2085 style: md007_config::IndentStyle::TextAligned,
2086 style_explicit: false,
2087 indent_explicit: false, };
2089 let rule = MD007ULIndent::from_config_struct(config);
2090
2091 let content = "* Level 0\n * Level 1";
2094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2095 let result = rule.check(&ctx).unwrap();
2096 assert!(
2098 result.is_empty(),
2099 "Smart detection should accept 4-space indent, got: {result:?}"
2100 );
2101 }
2102
2103 #[test]
2104 fn test_issue_273_exact_reproduction() {
2105 let config = MD007Config {
2108 indent: crate::types::IndentSize::from_const(4),
2109 start_indented: false,
2110 start_indent: crate::types::IndentSize::from_const(2),
2111 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
2113 indent_explicit: true, };
2115 let rule = MD007ULIndent::from_config_struct(config);
2116
2117 let content = r#"* Item 1
2118 * Item 2
2119 * Item 3"#;
2120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2121 let result = rule.check(&ctx).unwrap();
2122 assert!(
2123 result.is_empty(),
2124 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
2125 );
2126 }
2127
2128 #[test]
2129 fn test_indent_explicit_with_ordered_parent() {
2130 let config = MD007Config {
2134 indent: crate::types::IndentSize::from_const(4),
2135 start_indented: false,
2136 start_indent: crate::types::IndentSize::from_const(2),
2137 style: md007_config::IndentStyle::TextAligned,
2138 style_explicit: false,
2139 indent_explicit: true, };
2141 let rule = MD007ULIndent::from_config_struct(config);
2142
2143 let content = "1. Ordered\n * Bullet with 4-space indent";
2145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2146 let result = rule.check(&ctx).unwrap();
2147 assert!(
2148 result.is_empty(),
2149 "4-space indent under ordered should pass with indent=4: {result:?}"
2150 );
2151
2152 let content_3 = "1. Ordered\n * Bullet with 3-space indent";
2154 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2155 let result = rule.check(&ctx).unwrap();
2156 assert!(
2157 result.is_empty(),
2158 "3-space indent under ordered should pass (text-aligned): {result:?}"
2159 );
2160
2161 let wrong_content = "1. Ordered\n * Bullet with 2-space indent";
2163 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2164 let result = rule.check(&ctx).unwrap();
2165 assert!(
2166 !result.is_empty(),
2167 "2-space indent under ordered list should be flagged when indent=4: {result:?}"
2168 );
2169 }
2170
2171 #[test]
2172 fn test_indent_explicit_mixed_list_deep_nesting() {
2173 let config = MD007Config {
2178 indent: crate::types::IndentSize::from_const(4),
2179 start_indented: false,
2180 start_indent: crate::types::IndentSize::from_const(2),
2181 style: md007_config::IndentStyle::TextAligned,
2182 style_explicit: false,
2183 indent_explicit: true,
2184 };
2185 let rule = MD007ULIndent::from_config_struct(config);
2186
2187 let content_text_aligned = r#"* Level 0
2193 * Level 1 (4-space indent from bullet parent)
2194 1. Level 2 ordered
2195 * Level 3 bullet (text-aligned under ordered)"#;
2196 let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
2197 let result = rule.check(&ctx).unwrap();
2198 assert!(
2199 result.is_empty(),
2200 "Text-aligned nesting under ordered should pass: {result:?}"
2201 );
2202
2203 let content_fixed = r#"* Level 0
2204 * Level 1 (4-space indent from bullet parent)
2205 1. Level 2 ordered
2206 * Level 3 bullet (fixed indent under ordered)"#;
2207 let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
2208 let result = rule.check(&ctx).unwrap();
2209 assert!(
2210 result.is_empty(),
2211 "Fixed indent nesting under ordered should also pass: {result:?}"
2212 );
2213 }
2214
2215 #[test]
2216 fn test_ordered_list_double_digit_markers() {
2217 let config = MD007Config {
2220 indent: crate::types::IndentSize::from_const(4),
2221 start_indented: false,
2222 start_indent: crate::types::IndentSize::from_const(2),
2223 style: md007_config::IndentStyle::TextAligned,
2224 style_explicit: false,
2225 indent_explicit: true,
2226 };
2227 let rule = MD007ULIndent::from_config_struct(config);
2228
2229 let content = "10. Double digit\n * Bullet at col 4";
2231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2232 let result = rule.check(&ctx).unwrap();
2233 assert!(
2234 result.is_empty(),
2235 "Bullet under '10.' should align at column 4: {result:?}"
2236 );
2237
2238 let content_3 = "1. Single digit\n * Bullet at col 3";
2241 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2242 let result = rule.check(&ctx).unwrap();
2243 assert!(
2244 result.is_empty(),
2245 "Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
2246 );
2247
2248 let content_4 = "1. Single digit\n * Bullet at col 4";
2249 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2250 let result = rule.check(&ctx).unwrap();
2251 assert!(
2252 result.is_empty(),
2253 "Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
2254 );
2255 }
2256
2257 #[test]
2258 fn test_indent_explicit_pure_unordered_uses_fixed() {
2259 let config = MD007Config {
2262 indent: crate::types::IndentSize::from_const(4),
2263 start_indented: false,
2264 start_indent: crate::types::IndentSize::from_const(2),
2265 style: md007_config::IndentStyle::TextAligned,
2266 style_explicit: false,
2267 indent_explicit: true,
2268 };
2269 let rule = MD007ULIndent::from_config_struct(config);
2270
2271 let content = "* Level 0\n * Level 1\n * Level 2";
2273 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2274 let result = rule.check(&ctx).unwrap();
2275 assert!(
2276 result.is_empty(),
2277 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
2278 );
2279
2280 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
2282 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2283 let result = rule.check(&ctx).unwrap();
2284 assert!(
2285 !result.is_empty(),
2286 "2-space indent should be flagged when indent=4 is configured"
2287 );
2288 }
2289
2290 #[test]
2291 fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
2292 let rule = MD007ULIndent::default();
2296 let content = "1. text\n\n - nested item";
2297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2298 let result = rule.check(&ctx).unwrap();
2299 assert!(
2300 result.is_empty(),
2301 "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
2302 );
2303 }
2304
2305 #[test]
2306 fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
2307 let rule = MD007ULIndent::default();
2310 let content = "1. text\n\n - nested item";
2311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2312 let result = rule.check(&ctx).unwrap();
2313 assert!(
2314 result.is_empty(),
2315 "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
2316 );
2317 }
2318
2319 #[test]
2320 fn test_standard_flavor_ordered_list_under_ordered_is_exempt() {
2321 let rule = MD007ULIndent::default();
2326 let content = "1. text\n\n - nested item";
2327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2328 let result = rule.check(&ctx).unwrap();
2329 assert!(
2330 result.is_empty(),
2331 "unordered sublist of an ordered list must be exempt in Standard flavor, got: {result:?}"
2332 );
2333 }
2334
2335 #[test]
2336 fn test_mkdocs_multi_digit_ordered_list() {
2337 let rule = MD007ULIndent::default();
2340 let content = "10. text\n\n - nested item";
2341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2342 let result = rule.check(&ctx).unwrap();
2343 assert!(
2344 result.is_empty(),
2345 "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
2346 );
2347 }
2348
2349 #[test]
2350 fn test_mkdocs_triple_digit_ordered_list() {
2351 let rule = MD007ULIndent::default();
2354 let content = "100. text\n\n - nested item";
2355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2356 let result = rule.check(&ctx).unwrap();
2357 assert!(
2358 result.is_empty(),
2359 "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
2360 );
2361 }
2362
2363 #[test]
2364 fn test_mkdocs_insufficient_indent_under_ordered() {
2365 let rule = MD007ULIndent::default();
2368 let content = "1. text\n\n - nested item";
2369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2370 let result = rule.check(&ctx).unwrap();
2371 assert_eq!(
2372 result.len(),
2373 1,
2374 "2-space indent under ordered list should warn in MkDocs flavor"
2375 );
2376 assert!(
2377 result[0].message.contains("Expected 4"),
2378 "Warning should expect 4 spaces (MkDocs minimum), got: {}",
2379 result[0].message
2380 );
2381 }
2382
2383 #[test]
2384 fn test_mkdocs_deeper_nesting_under_ordered() {
2385 let rule = MD007ULIndent::default();
2390 let content = "1. text\n\n - sub\n - subsub";
2391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2392 let result = rule.check(&ctx).unwrap();
2393 assert!(
2394 result.is_empty(),
2395 "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
2396 );
2397 }
2398
2399 #[test]
2400 fn test_mkdocs_fix_adjusts_to_4_spaces() {
2401 let rule = MD007ULIndent::default();
2403 let content = "1. text\n\n - nested item";
2404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2405 let result = rule.check(&ctx).unwrap();
2406 assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
2407 let fixed = rule.fix(&ctx).unwrap();
2408 assert_eq!(
2409 fixed, "1. text\n\n - nested item",
2410 "Fix should adjust indent to 4 spaces in MkDocs"
2411 );
2412 }
2413
2414 #[test]
2415 fn test_mkdocs_start_indented_with_ordered_parent() {
2416 let config = MD007Config {
2419 start_indented: true,
2420 ..Default::default()
2421 };
2422 let rule = MD007ULIndent::from_config_struct(config);
2423 let content = "1. text\n\n - nested item";
2424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2425 let result = rule.check(&ctx).unwrap();
2426 assert!(
2427 result.is_empty(),
2428 "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
2429 );
2430 }
2431
2432 #[test]
2433 fn test_mkdocs_ordered_at_nonzero_indent() {
2434 let rule = MD007ULIndent::default();
2439 let content = "- outer\n 1. inner\n - deep";
2440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2441 let result = rule.check(&ctx).unwrap();
2442 assert!(
2443 result.is_empty(),
2444 "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
2445 );
2446 }
2447
2448 #[test]
2449 fn test_mkdocs_blockquoted_ordered_list() {
2450 let rule = MD007ULIndent::default();
2454 let content = "> 1. text\n>\n> - nested item";
2455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2456 let result = rule.check(&ctx).unwrap();
2457 assert!(
2458 result.is_empty(),
2459 "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
2460 );
2461 }
2462
2463 #[test]
2464 fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
2465 let rule = MD007ULIndent::default();
2468 let content = "- outer\n 1. inner\n - deep";
2469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2470 let result = rule.check(&ctx).unwrap();
2471 assert_eq!(
2472 result.len(),
2473 1,
2474 "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
2475 );
2476 }
2477
2478 #[test]
2479 fn test_issue_504_indent4_ordered_parent() {
2480 let config = MD007Config {
2484 indent: crate::types::IndentSize::from_const(4),
2485 start_indented: false,
2486 start_indent: crate::types::IndentSize::from_const(2),
2487 style: md007_config::IndentStyle::TextAligned,
2488 style_explicit: false,
2489 indent_explicit: true,
2490 };
2491 let rule = MD007ULIndent::from_config_struct(config);
2492
2493 let content = r#"# Things
2494
2495+ An unordered list
2496 + An item with 4 spaces, ok.
2497
24981. A numbered list
2499 + A sublist with 4 spaces, not ok
2500 + A sub item with 4 spaces, ok
2501 + Why is rumdl expecting 3 spaces for a 4 space indent?
25022. Item 2
25033. Item 3"#;
2504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2505 let result = rule.check(&ctx).unwrap();
2506 assert!(
2507 result.is_empty(),
2508 "Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
2509 );
2510 }
2511
2512 #[test]
2513 fn test_indent2_explicit_with_ordered_parent() {
2514 let config = MD007Config {
2517 indent: crate::types::IndentSize::from_const(2),
2518 start_indented: false,
2519 start_indent: crate::types::IndentSize::from_const(2),
2520 style: md007_config::IndentStyle::TextAligned,
2521 style_explicit: false,
2522 indent_explicit: true,
2523 };
2524 let rule = MD007ULIndent::from_config_struct(config);
2525
2526 let content = "1. Ordered\n * Bullet at 3 spaces";
2528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2529 let result = rule.check(&ctx).unwrap();
2530 assert!(
2531 result.is_empty(),
2532 "indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
2533 );
2534
2535 let content_2 = "1. Ordered\n * Bullet at 2 spaces";
2537 let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
2538 let result = rule.check(&ctx).unwrap();
2539 assert!(
2540 result.is_empty(),
2541 "indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
2542 );
2543 }
2544
2545 const ISSUE_638_INPUT: &str = "# Title\n\n1. Some text\n - Indented text\n - more indented\n";
2549
2550 #[test]
2551 fn test_issue_638_unordered_under_ordered_smart_default() {
2552 let rule = MD007ULIndent::new(2);
2553 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2554 let result = rule.check(&ctx).unwrap();
2555 assert!(
2556 result.is_empty(),
2557 "smart default: unordered items under an ordered list must not be flagged, got: {result:?}"
2558 );
2559 }
2560
2561 #[test]
2562 fn test_issue_638_unordered_under_ordered_indent_explicit() {
2563 let config = MD007Config {
2564 indent: crate::types::IndentSize::from_const(2),
2565 start_indented: false,
2566 start_indent: crate::types::IndentSize::from_const(2),
2567 style: md007_config::IndentStyle::TextAligned,
2568 style_explicit: false,
2569 indent_explicit: true,
2570 };
2571 let rule = MD007ULIndent::from_config_struct(config);
2572 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2573 let result = rule.check(&ctx).unwrap();
2574 assert!(
2575 result.is_empty(),
2576 "indent=2 explicit: unordered items under an ordered list must not be flagged, got: {result:?}"
2577 );
2578 }
2579
2580 #[test]
2581 fn test_issue_638_unordered_under_ordered_style_fixed() {
2582 let config = MD007Config {
2584 indent: crate::types::IndentSize::from_const(2),
2585 start_indented: false,
2586 start_indent: crate::types::IndentSize::from_const(2),
2587 style: md007_config::IndentStyle::Fixed,
2588 style_explicit: true,
2589 indent_explicit: true,
2590 };
2591 let rule = MD007ULIndent::from_config_struct(config);
2592 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2593 let result = rule.check(&ctx).unwrap();
2594 assert!(
2595 result.is_empty(),
2596 "style=fixed: unordered items under an ordered list must not be flagged, got: {result:?}"
2597 );
2598 }
2599
2600 fn fixed_style_rule(indent: u8) -> MD007ULIndent {
2607 MD007ULIndent::from_config_struct(MD007Config {
2608 indent: crate::types::IndentSize::from_const(indent),
2609 start_indented: false,
2610 start_indent: crate::types::IndentSize::from_const(2),
2611 style: md007_config::IndentStyle::Fixed,
2612 style_explicit: true,
2613 indent_explicit: true,
2614 })
2615 }
2616
2617 #[test]
2618 fn test_fixed_style_clamp_flags_over_indented_bullet_under_ordered() {
2619 let rule = fixed_style_rule(2);
2620 let content = "1. Some text\n - four spaces\n";
2621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2622 let result = rule.check(&ctx).unwrap();
2623 assert_eq!(
2624 result.len(),
2625 1,
2626 "a bullet at 4 under a content column of 3 is flagged: {result:?}"
2627 );
2628 assert!(
2629 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2630 "clamped expectation is the parent content column, got: {}",
2631 result[0].message
2632 );
2633 let fixed = rule.fix(&ctx).unwrap();
2634 assert_eq!(fixed, "1. Some text\n - four spaces\n");
2635 }
2636
2637 #[test]
2638 fn test_fixed_style_clamp_accepts_bullet_at_parent_content_column() {
2639 let rule = fixed_style_rule(2);
2640 let content = "1. Some text\n - three spaces\n";
2641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2642 let result = rule.check(&ctx).unwrap();
2643 assert!(result.is_empty(), "the clamped expectation itself passes: {result:?}");
2644 }
2645
2646 #[test]
2647 fn test_fixed_style_clamp_pulls_five_spaces_to_content_column() {
2648 let rule = fixed_style_rule(2);
2649 let content = "1. Some text\n - five spaces\n";
2650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2651 let result = rule.check(&ctx).unwrap();
2652 assert_eq!(result.len(), 1, "{result:?}");
2653 let fixed = rule.fix(&ctx).unwrap();
2654 assert_eq!(fixed, "1. Some text\n - five spaces\n");
2655 }
2656
2657 #[test]
2658 fn test_fixed_style_clamp_cascades_through_nested_bullets() {
2659 let rule = fixed_style_rule(2);
2663 let content = "1. Ordered\n - child\n - grandchild\n";
2664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2665 let result = rule.check(&ctx).unwrap();
2666 assert_eq!(result.len(), 1, "only the grandchild is off: {result:?}");
2667 assert!(
2668 result[0].message.contains("Expected 5") && result[0].message.contains("found 6"),
2669 "got: {}",
2670 result[0].message
2671 );
2672 let fixed = rule.fix(&ctx).unwrap();
2673 assert_eq!(fixed, "1. Ordered\n - child\n - grandchild\n");
2674 let refixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2675 assert!(rule.check(&refixed_ctx).unwrap().is_empty(), "fix is stable");
2676 }
2677
2678 #[test]
2679 fn test_fixed_style_clamp_respects_wider_fixed_indent() {
2680 let rule = fixed_style_rule(4);
2683 let content = "1. Some text\n - three spaces\n";
2684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2685 let result = rule.check(&ctx).unwrap();
2686 assert_eq!(result.len(), 1, "{result:?}");
2687 assert!(
2688 result[0].message.contains("Expected 4") && result[0].message.contains("found 3"),
2689 "got: {}",
2690 result[0].message
2691 );
2692 let fixed = rule.fix(&ctx).unwrap();
2693 assert_eq!(fixed, "1. Some text\n - three spaces\n");
2694 }
2695
2696 #[test]
2697 fn test_fixed_style_clamp_uses_measured_content_column_of_wide_marker() {
2698 let rule = fixed_style_rule(2);
2701 let content = "1. Some text\n - five spaces\n";
2702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2703 let result = rule.check(&ctx).unwrap();
2704 assert_eq!(result.len(), 1, "{result:?}");
2705 assert!(
2706 result[0].message.contains("Expected 4") && result[0].message.contains("found 5"),
2707 "got: {}",
2708 result[0].message
2709 );
2710 let fixed = rule.fix(&ctx).unwrap();
2711 assert_eq!(fixed, "1. Some text\n - five spaces\n");
2712
2713 let ok = "1. Some text\n - four spaces\n";
2714 let ok_ctx = LintContext::new(ok, crate::config::MarkdownFlavor::Standard, None);
2715 assert!(rule.check(&ok_ctx).unwrap().is_empty());
2716 }
2717
2718 #[test]
2719 fn test_fixed_style_clamp_in_blockquote() {
2720 let rule = fixed_style_rule(2);
2721 let content = "> 1. Some text\n> - four spaces\n";
2722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2723 let result = rule.check(&ctx).unwrap();
2724 assert_eq!(result.len(), 1, "{result:?}");
2725 assert!(
2726 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2727 "got: {}",
2728 result[0].message
2729 );
2730 let fixed = rule.fix(&ctx).unwrap();
2731 assert_eq!(fixed, "> 1. Some text\n> - four spaces\n");
2732 }
2733
2734 #[test]
2735 fn test_fixed_style_clamp_treats_near_sibling_as_sibling() {
2736 let rule = fixed_style_rule(2);
2742 let content = "1. x\n - a\n - b\n";
2743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2744 let result = rule.check(&ctx).unwrap();
2745 assert_eq!(result.len(), 1, "{result:?}");
2746 assert!(
2747 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2748 "near-sibling resolves against the ordered parent, got: {}",
2749 result[0].message
2750 );
2751 let fixed = rule.fix(&ctx).unwrap();
2752 assert_eq!(fixed, "1. x\n - a\n - b\n");
2753 }
2754
2755 #[test]
2756 fn test_fixed_style_clamp_child_after_near_sibling_resolves_against_it() {
2757 let rule = fixed_style_rule(2);
2761 let content = "1. x\n - a\n - b\n - c\n";
2762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2763 let result = rule.check(&ctx).unwrap();
2764 assert_eq!(result.len(), 2, "b and c are both off: {result:?}");
2765 assert!(
2766 result[0].message.contains("Expected 3") && result[0].message.contains("found 4"),
2767 "got: {}",
2768 result[0].message
2769 );
2770 assert!(
2771 result[1].message.contains("Expected 5") && result[1].message.contains("found 6"),
2772 "got: {}",
2773 result[1].message
2774 );
2775 let fixed = rule.fix(&ctx).unwrap();
2776 assert_eq!(fixed, "1. x\n - a\n - b\n - c\n");
2777 }
2778
2779 #[test]
2780 fn test_fixed_style_clamp_pops_near_sibling_of_over_indented_bullet() {
2781 let rule = fixed_style_rule(2);
2786 let content = "1. x\n - a\n - b\n";
2787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2788 let result = rule.check(&ctx).unwrap();
2789 assert_eq!(result.len(), 2, "a and b are both flagged: {result:?}");
2790 assert!(
2791 result[1].message.contains("Expected 3") && result[1].message.contains("found 5"),
2792 "b resolves against the ordered parent, got: {}",
2793 result[1].message
2794 );
2795 let fixed = rule.fix(&ctx).unwrap();
2796 assert_eq!(fixed, "1. x\n - a\n - b\n");
2797 }
2798
2799 #[test]
2800 fn test_fixed_style_clamp_keeps_child_of_over_indented_bullet() {
2801 let rule = fixed_style_rule(2);
2804 let content = "1. x\n - a\n - c\n";
2805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2806 let result = rule.check(&ctx).unwrap();
2807 assert_eq!(result.len(), 2, "a and c are both flagged: {result:?}");
2808 assert!(
2809 result[1].message.contains("Expected 5") && result[1].message.contains("found 7"),
2810 "c's floor is a's corrected content column, got: {}",
2811 result[1].message
2812 );
2813 let fixed = rule.fix(&ctx).unwrap();
2814 assert_eq!(fixed, "1. x\n - a\n - c\n");
2815 }
2816
2817 #[test]
2818 fn test_fixed_style_clamp_pops_ordered_near_sibling() {
2819 let rule = fixed_style_rule(2);
2825 let content = "1. root\n - a\n 1. sub\n - b\n";
2826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2827 let result = rule.check(&ctx).unwrap();
2828 assert_eq!(result.len(), 1, "only b is off: {result:?}");
2829 assert!(
2830 result[0].message.contains("Expected 5") && result[0].message.contains("found 6"),
2831 "b resolves against a, not the nested ordered sibling, got: {}",
2832 result[0].message
2833 );
2834 let fixed = rule.fix(&ctx).unwrap();
2835 assert_eq!(fixed, "1. root\n - a\n 1. sub\n - b\n");
2836 }
2837
2838 #[test]
2839 fn test_fixed_style_clamp_leaves_sibling_bullet_left_of_content_column() {
2840 let rule = fixed_style_rule(2);
2844 let content = "1. Some text\n - two spaces\n";
2845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2846 let result = rule.check(&ctx).unwrap();
2847 assert!(
2848 result.is_empty(),
2849 "sibling bullet at the fixed indent stays silent: {result:?}"
2850 );
2851 }
2852
2853 #[test]
2854 fn test_fixed_style_clamp_requires_explicit_style() {
2855 let config = MD007Config {
2858 indent: crate::types::IndentSize::from_const(2),
2859 start_indented: false,
2860 start_indent: crate::types::IndentSize::from_const(2),
2861 style: md007_config::IndentStyle::TextAligned,
2862 style_explicit: false,
2863 indent_explicit: true,
2864 };
2865 let rule = MD007ULIndent::from_config_struct(config);
2866 let content = "1. Some text\n - four spaces\n";
2867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2868 let result = rule.check(&ctx).unwrap();
2869 assert!(result.is_empty(), "no explicit style, exemption stays: {result:?}");
2870
2871 let smart = MD007ULIndent::new(2);
2872 assert!(
2873 smart.check(&ctx).unwrap().is_empty(),
2874 "smart default keeps the exemption too"
2875 );
2876 }
2877
2878 #[test]
2879 fn test_issue_638_deeper_unordered_chain_under_ordered() {
2880 let rule = MD007ULIndent::new(2);
2882 let content = "1. Ordered\n - child\n - grandchild\n - great-grandchild\n";
2883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2884 let result = rule.check(&ctx).unwrap();
2885 assert!(
2886 result.is_empty(),
2887 "all unordered descendants of an ordered list are exempt, got: {result:?}"
2888 );
2889 }
2890
2891 #[test]
2892 fn test_issue_638_pure_unordered_still_checked() {
2893 let rule = MD007ULIndent::new(2);
2895 let content = "- Top\n - three spaces (wrong, expected 2)\n";
2896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2897 let result = rule.check(&ctx).unwrap();
2898 assert_eq!(
2899 result.len(),
2900 1,
2901 "pure unordered nesting must still be checked, got: {result:?}"
2902 );
2903 }
2904
2905 #[test]
2906 fn test_issue_638_exemption_not_applied_after_list_terminated_by_paragraph() {
2907 let rule = MD007ULIndent::new(2);
2914 let content = "1. ordered\n\nparagraph\n\n - parent\n - child six\n";
2915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2916 let result = rule.check(&ctx).unwrap();
2917 assert_eq!(
2918 result.len(),
2919 2,
2920 "the new top-level list following a terminated ordered list is checked at both levels, got: {result:?}"
2921 );
2922 assert!(
2923 result.iter().any(|w| w.line == 5 && w.message.contains("Expected 0")),
2924 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2925 );
2926 assert!(
2927 result
2928 .iter()
2929 .any(|w| w.line == 6 && w.message.contains("Expected 2") && w.message.contains("found 6")),
2930 "the misindented child must be flagged with Expected 2, found 6, got: {result:?}"
2931 );
2932 }
2933
2934 #[test]
2935 fn test_issue_638_lazy_continuation_does_not_terminate_ordered_list() {
2936 let rule = MD007ULIndent::new(2);
2942 let content = "1. ordered\nlazy continuation\n - child\n - grandchild\n";
2943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2944 let result = rule.check(&ctx).unwrap();
2945 assert!(
2946 result.is_empty(),
2947 "lazy continuation must not terminate the ordered list; sublist stays exempt, got: {result:?}"
2948 );
2949 }
2950
2951 #[test]
2952 fn test_issue_638_heading_interrupts_ordered_list_without_blank() {
2953 let rule = MD007ULIndent::new(2);
2960 let content = "1. ordered\n# heading\n - child\n - grandchild\n";
2961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2962 let result = rule.check(&ctx).unwrap();
2963 assert_eq!(
2964 result.len(),
2965 2,
2966 "a heading terminates the ordered list, so the new top-level list and its child are both checked, got: {result:?}"
2967 );
2968 assert!(
2969 result.iter().any(|w| w.line == 3 && w.message.contains("Expected 0")),
2970 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2971 );
2972 assert!(
2973 result.iter().any(|w| w.line == 4 && w.message.contains("Expected 2")),
2974 "the misindented child must be flagged with Expected 2, got: {result:?}"
2975 );
2976 }
2977
2978 #[test]
2979 fn test_issue_638_lazy_continuation_inside_blockquote_keeps_exemption() {
2980 let rule = MD007ULIndent::new(2);
2985 let content = "> 1. ordered\n> continuation\n>\n> - child\n> - grandchild\n";
2986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2987 let result = rule.check(&ctx).unwrap();
2988 assert!(
2989 result.is_empty(),
2990 "a lazy continuation within the same blockquote must keep the sublist exempt, got: {result:?}"
2991 );
2992 }
2993
2994 #[test]
2995 fn test_issue_638_indented_fence_inside_blockquoted_ordered_item_keeps_exemption() {
2996 let rule = MD007ULIndent::new(2);
3001 let content = "> 1. ordered\n> ```\n> code\n> ```\n> - child\n> - grandchild\n";
3002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3003 let result = rule.check(&ctx).unwrap();
3004 assert!(
3005 result.is_empty(),
3006 "an indented fence inside a blockquoted ordered item must keep the sublist exempt, got: {result:?}"
3007 );
3008 }
3009
3010 #[test]
3011 fn test_issue_638_fenced_code_block_terminates_ordered_list() {
3012 let rule = MD007ULIndent::new(2);
3018 let content = "1. ordered\n```\ncode\n```\n\n - parent\n - child\n";
3019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3020 let result = rule.check(&ctx).unwrap();
3021 assert!(
3022 result.iter().any(|w| w.line == 7),
3023 "a top-level fenced code block terminates the ordered list; the child must be flagged, got: {result:?}"
3024 );
3025 }
3026
3027 #[test]
3028 fn test_issue_638_fenced_code_block_inside_item_keeps_exemption() {
3029 let rule = MD007ULIndent::new(2);
3034 let content = "1. ordered\n ```\n code\n ```\n - child\n - grandchild\n";
3035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3036 let result = rule.check(&ctx).unwrap();
3037 assert!(
3038 result.is_empty(),
3039 "a fenced code block nested inside the item must keep the sublist exempt, got: {result:?}"
3040 );
3041 }
3042
3043 #[test]
3044 fn test_issue_638_blockquote_terminates_ordered_list() {
3045 let rule = MD007ULIndent::new(2);
3052 let content = "1. ordered\n> quote\n\n - parent\n - child\n";
3053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3054 let result = rule.check(&ctx).unwrap();
3055 assert!(
3056 result.iter().any(|w| w.line == 5),
3057 "blockquote terminates the ordered list, so the child must still be flagged, got: {result:?}"
3058 );
3059 }
3060
3061 #[test]
3062 fn test_issue_638_blockquote_inside_item_keeps_exemption() {
3063 let rule = MD007ULIndent::new(2);
3068 let content = "1. ordered\n > quote inside item\n - child\n - grandchild\n";
3069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3070 let result = rule.check(&ctx).unwrap();
3071 assert!(
3072 result.is_empty(),
3073 "a blockquote nested inside the item must keep the sublist exempt, got: {result:?}"
3074 );
3075 }
3076
3077 #[test]
3078 fn test_issue_638_exemption_requires_genuine_nesting_under_ordered() {
3079 let rule = MD007ULIndent::new(2);
3088 let content = "100. ordered\n - parent\n - child\n";
3089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3090 let result = rule.check(&ctx).unwrap();
3091 assert!(
3092 result.iter().any(|w| w.line == 3),
3093 "the child of a non-nested bullet must still be checked, not exempted; got: {result:?}"
3094 );
3095 }
3096
3097 #[test]
3098 fn test_issue_638_paragraph_after_fenced_code_closes_ordered_list() {
3099 let rule = MD007ULIndent::new(2);
3108 let content = "1. ordered\n ```\n code\n ```\nnot lazy text\n - parent\n - child\n";
3109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3110 let result = rule.check(&ctx).unwrap();
3111 assert!(
3112 result.iter().any(|w| w.line == 7),
3113 "fenced code is not paragraph text, so the list closes and the nested child must still be checked, not exempted; got: {result:?}"
3114 );
3115 }
3116
3117 #[test]
3118 fn test_issue_638_overlong_ordered_marker_is_lazy_continuation() {
3119 let rule = MD007ULIndent::new(2);
3125 let content = "1. ordered\n1234567890. this is continuation text\n - child\n - grandchild\n";
3126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3127 let result = rule.check(&ctx).unwrap();
3128 assert!(
3129 result.is_empty(),
3130 "an overlong digit run is not a valid ordered marker, so the list stays open and the nested bullets are exempt; got: {result:?}"
3131 );
3132 }
3133
3134 #[test]
3135 fn test_indented_top_level_list_item_is_flagged() {
3136 let rule = MD007ULIndent::new(2);
3142 for indent in 2..=3 {
3143 let pad = " ".repeat(indent);
3144 let content = format!("{pad}- parent\n{pad} - child\n");
3145 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
3146 let result = rule.check(&ctx).unwrap();
3147 assert!(
3148 result.iter().any(|w| w.line == 1),
3149 "a top-level item indented {indent} spaces must be flagged (Expected 0); got: {result:?}"
3150 );
3151 }
3152 }
3153
3154 #[test]
3155 fn test_indented_code_block_bullet_is_not_a_list_item() {
3156 let rule = MD007ULIndent::new(2);
3159 let content = " - not a list, this is code\n";
3160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3161 let result = rule.check(&ctx).unwrap();
3162 assert!(
3163 result.is_empty(),
3164 "a 4-space-indented bullet is an indented code block, not a misindented list; got: {result:?}"
3165 );
3166 }
3167
3168 #[test]
3169 fn test_tab_indent_expands_to_four_column_tabstop() {
3170 let rule = MD007ULIndent::new(2);
3177 let content = "- a\n\t- b\n";
3178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3179 let result = rule.check(&ctx).unwrap();
3180 let warning = result
3181 .iter()
3182 .find(|w| w.line == 2)
3183 .expect("a tab-indented sublist at column 4 is over-indented for depth 1 and must be flagged");
3184 assert!(
3185 warning.message.contains("found 4"),
3186 "the tab must expand to the 4-column tab stop (found 4), not be counted as one character; got: {}",
3187 warning.message
3188 );
3189 }
3190
3191 #[test]
3192 fn test_tab_completing_two_space_indent_to_tabstop_is_accepted() {
3193 let rule = MD007ULIndent::new(2);
3199 let content = "- a\n - b\n \t- c\n";
3200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3201 let result = rule.check(&ctx).unwrap();
3202 assert!(
3203 result.is_empty(),
3204 "` \\t` expands to column 4, the correct depth-2 indent, so no MD007 warning is expected; got: {result:?}"
3205 );
3206 }
3207
3208 #[test]
3209 fn test_issue_638_html_comment_terminates_ordered_list() {
3210 let rule = MD007ULIndent::new(2);
3217 let content = "1. ordered\n<!-- comment -->\n\n - parent\n - child\n";
3218 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3219 let result = rule.check(&ctx).unwrap();
3220 assert!(
3221 result.iter().any(|w| w.line == 5),
3222 "an HTML comment terminates the ordered list, so the child must still be flagged, got: {result:?}"
3223 );
3224 }
3225
3226 #[test]
3227 fn test_issue_638_blockquoted_list_item_terminates_ordered_list() {
3228 let rule = MD007ULIndent::new(2);
3236 let content = "1. ordered\n> - quote list\n\n - parent\n - child\n";
3237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3238 let result = rule.check(&ctx).unwrap();
3239 assert!(
3240 result.iter().any(|w| w.line == 5),
3241 "a blockquoted list item terminates the ordered list, so the child must still be flagged, got: {result:?}"
3242 );
3243 }
3244
3245 #[test]
3246 fn test_issue_638_deeper_nested_quote_terminates_blockquoted_ordered_list() {
3247 let rule = MD007ULIndent::new(2);
3257 let content = "> 1. ordered\n> > quote\n>\n> - parent\n> - child\n";
3258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3259 let result = rule.check(&ctx).unwrap();
3260 assert!(
3261 result.iter().any(|w| w.line == 4),
3262 "deeper nested quote closes the ordered list, so the misindented parent must be flagged, got: {result:?}"
3263 );
3264 assert!(
3265 result.iter().any(|w| w.line == 5),
3266 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
3267 );
3268 }
3269
3270 #[test]
3271 fn test_issue_638_deeper_quote_list_item_terminates_blockquoted_ordered_list() {
3272 let rule = MD007ULIndent::new(2);
3280 let content = "> 1. ordered\n> > - quote list\n>\n> - parent\n> - child\n";
3281 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3282 let result = rule.check(&ctx).unwrap();
3283 assert!(
3284 result.iter().any(|w| w.line == 4),
3285 "a deeper-quote list item closes the ordered list, so the parent must be flagged, got: {result:?}"
3286 );
3287 assert!(
3288 result.iter().any(|w| w.line == 5),
3289 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
3290 );
3291 }
3292
3293 #[test]
3294 fn test_issue_638_deeper_quote_indented_into_item_keeps_exemption() {
3295 let rule = MD007ULIndent::new(2);
3300 let content = "> 1. ordered\n> > quote inside item\n> - child\n> - grandchild\n";
3301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3302 let result = rule.check(&ctx).unwrap();
3303 assert!(
3304 result.is_empty(),
3305 "a deeper quote indented into the item must keep the sublist exempt, got: {result:?}"
3306 );
3307 }
3308
3309 #[test]
3310 fn test_indent4_explicit_with_wide_ordered_parent() {
3311 let config = MD007Config {
3315 indent: crate::types::IndentSize::from_const(4),
3316 start_indented: false,
3317 start_indent: crate::types::IndentSize::from_const(2),
3318 style: md007_config::IndentStyle::TextAligned,
3319 style_explicit: false,
3320 indent_explicit: true,
3321 };
3322 let rule = MD007ULIndent::from_config_struct(config);
3323
3324 let content = "100. Wide ordered\n * Bullet at 5 spaces";
3326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3327 let result = rule.check(&ctx).unwrap();
3328 assert!(
3329 result.is_empty(),
3330 "indent=4 under '100.' should accept 5-space indent: {result:?}"
3331 );
3332
3333 let content_4 = "100. Wide ordered\n * Bullet at 4 spaces";
3335 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
3336 let result = rule.check(&ctx).unwrap();
3337 assert!(
3338 result.is_empty(),
3339 "indent=4 under '100.' should accept 4-space indent: {result:?}"
3340 );
3341 }
3342
3343 fn commonmark_max_list_depth(md: &str) -> usize {
3347 use pulldown_cmark::{Event, Parser, Tag, TagEnd};
3348 let (mut depth, mut max) = (0usize, 0usize);
3349 for event in Parser::new(md) {
3350 match event {
3351 Event::Start(Tag::List(_)) => {
3352 depth += 1;
3353 max = max.max(depth);
3354 }
3355 Event::End(TagEnd::List(_)) => depth = depth.saturating_sub(1),
3356 _ => {}
3357 }
3358 }
3359 max
3360 }
3361
3362 #[test]
3363 fn test_md007_widened_parent_marker_keeps_nested_child() {
3364 let rule = MD007ULIndent::default();
3370 let content = indoc! {"
3371 - Parent item
3372 - Nested item
3373 "};
3374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3375 let result = rule.check(&ctx).unwrap();
3376 assert!(
3377 result.is_empty(),
3378 "a child aligned to a widened parent's content column must not be flagged: {result:?}"
3379 );
3380 assert_eq!(commonmark_max_list_depth(content), 2, "precondition: source is nested");
3381 assert_eq!(
3382 rule.fix(&ctx).unwrap(),
3383 content,
3384 "fix must be a no-op for an already correctly nested child"
3385 );
3386 }
3387
3388 #[test]
3389 fn test_md007_widened_parent_aligns_child_to_content_column() {
3390 let rule = MD007ULIndent::default();
3393 let content = indoc! {"
3394 - Parent item
3395 - Nested item
3396 "};
3397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3398 let fixed = rule.fix(&ctx).unwrap();
3399 assert_eq!(
3400 fixed,
3401 indoc! {"
3402 - Parent item
3403 - Nested item
3404 "},
3405 "child must align to the parent's content column 4: {fixed:?}"
3406 );
3407 assert_eq!(
3408 commonmark_max_list_depth(&fixed),
3409 2,
3410 "fixed child must remain nested, not flattened to a sibling:\n{fixed}"
3411 );
3412 }
3413
3414 #[test]
3415 fn test_md007_widened_markers_nested_multiple_levels() {
3416 let rule = MD007ULIndent::default();
3419 let content = indoc! {"
3420 - Level 0
3421 - Level 1
3422 - Level 2
3423 "};
3424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3425 let result = rule.check(&ctx).unwrap();
3426 assert!(
3427 result.is_empty(),
3428 "deeply nested widened markers must not be flagged: {result:?}"
3429 );
3430 assert_eq!(
3431 commonmark_max_list_depth(content),
3432 3,
3433 "three nesting levels are preserved"
3434 );
3435 }
3436
3437 #[test]
3438 fn test_md007_default_marker_indent_still_enforced() {
3439 let rule = MD007ULIndent::default();
3443 let content = indoc! {"
3444 - Parent item
3445 - Nested item
3446 "};
3447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3448 let result = rule.check(&ctx).unwrap();
3449 assert_eq!(
3450 result.len(),
3451 1,
3452 "an over-indented child under a normal marker is still flagged: {result:?}"
3453 );
3454 assert_eq!(
3455 rule.fix(&ctx).unwrap(),
3456 indoc! {"
3457 - Parent item
3458 - Nested item
3459 "}
3460 );
3461 }
3462}