1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6
7pub mod md007_config;
8use md007_config::MD007Config;
9
10#[derive(Debug, Clone, Default)]
11pub struct MD007ULIndent {
12 config: MD007Config,
13}
14
15impl MD007ULIndent {
16 pub fn new(indent: usize) -> Self {
17 Self {
18 config: MD007Config {
19 indent: crate::types::IndentSize::from_const(indent as u8),
20 start_indented: false,
21 start_indent: crate::types::IndentSize::from_const(2),
22 style: md007_config::IndentStyle::TextAligned,
23 style_explicit: false, indent_explicit: false, },
26 }
27 }
28
29 pub fn from_config_struct(config: MD007Config) -> Self {
30 Self { config }
31 }
32
33 fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
35 let mut visual_col = 0;
36
37 for (current_pos, ch) in content.chars().enumerate() {
38 if current_pos >= char_pos {
39 break;
40 }
41 if ch == '\t' {
42 visual_col = (visual_col / 4 + 1) * 4;
44 } else {
45 visual_col += 1;
46 }
47 }
48 visual_col
49 }
50
51 fn indent_relative_to_depth(
75 ctx: &crate::lint_context::LintContext,
76 line_info: &crate::lint_context::LineInfo,
77 depth: usize,
78 ) -> usize {
79 if depth == 0 {
80 return line_info.visual_indent;
81 }
82 let line_content = line_info.content(ctx.content);
87 let mut remaining = line_content;
88 let mut content_start = 0;
89 let mut stripped_levels = 0;
90 while stripped_levels < depth {
91 let trimmed = remaining.trim_start();
92 if !trimmed.starts_with('>') {
93 break;
94 }
95 content_start += remaining.len() - trimmed.len();
96 content_start += 1;
97 let after_gt = &trimmed[1..];
98 if let Some(stripped) = after_gt.strip_prefix(' ') {
99 content_start += 1;
100 remaining = stripped;
101 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
102 content_start += 1;
103 remaining = stripped;
104 } else {
105 remaining = after_gt;
106 }
107 stripped_levels += 1;
108 }
109 let content_after_prefix = &line_content[content_start..];
110 let ws_chars = content_after_prefix
111 .chars()
112 .take_while(|c| *c == ' ' || *c == '\t')
113 .count();
114 Self::char_pos_to_visual_column(content_after_prefix, ws_chars)
115 }
116
117 fn terminate_closed_items(
118 ctx: &crate::lint_context::LintContext,
119 line_info: &crate::lint_context::LineInfo,
120 list_stack: &mut Vec<(usize, usize, bool, usize, usize, bool)>,
121 line_bq_depth: usize,
122 ) {
123 while let Some(&(_, _, _, content_col, item_bq_depth, _)) = list_stack.last() {
124 let closed = match item_bq_depth.cmp(&line_bq_depth) {
125 std::cmp::Ordering::Greater => true,
127 std::cmp::Ordering::Equal | std::cmp::Ordering::Less => {
136 content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
137 }
138 };
139 if closed {
140 list_stack.pop();
141 } else {
142 break;
143 }
144 }
145 }
146
147 fn calculate_expected_indent(
156 &self,
157 nesting_level: usize,
158 parent_info: Option<(bool, usize)>, ) -> usize {
160 if nesting_level == 0 {
161 return 0;
162 }
163
164 if self.config.style_explicit {
166 return match self.config.style {
167 md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
168 md007_config::IndentStyle::TextAligned => {
169 parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
170 }
171 };
172 }
173
174 if self.config.indent_explicit {
177 match parent_info {
178 Some((true, parent_content_col)) => {
179 return parent_content_col;
182 }
183 _ => {
184 return nesting_level * self.config.indent.get() as usize;
186 }
187 }
188 }
189
190 match parent_info {
192 Some((true, parent_content_col)) => {
193 parent_content_col
196 }
197 Some((false, parent_content_col)) => {
198 let parent_level = nesting_level.saturating_sub(1);
202 let expected_parent_marker = parent_level * self.config.indent.get() as usize;
203 let parent_marker_col = parent_content_col.saturating_sub(2);
205
206 if parent_marker_col == expected_parent_marker {
207 nesting_level * self.config.indent.get() as usize
209 } else {
210 parent_content_col
212 }
213 }
214 None => {
215 nesting_level * self.config.indent.get() as usize
217 }
218 }
219 }
220}
221
222impl Rule for MD007ULIndent {
223 fn name(&self) -> &'static str {
224 "MD007"
225 }
226
227 fn description(&self) -> &'static str {
228 "Unordered list indentation"
229 }
230
231 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
232 let mut warnings = Vec::new();
233 let mut list_stack: Vec<(usize, usize, bool, usize, usize, bool)> = Vec::new(); for (line_idx, line_info) in ctx.lines.iter().enumerate() {
236 let is_skipped_region = |info: &crate::lint_context::LineInfo| {
238 info.in_code_block || info.in_front_matter || info.in_mkdocstrings || info.in_footnote_definition
239 };
240 if is_skipped_region(line_info) {
241 let region_start = line_idx == 0 || !is_skipped_region(&ctx.lines[line_idx - 1]);
248 if region_start && !line_info.is_blank {
249 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
250 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
251 }
252 continue;
253 }
254
255 if let Some(list_item) = &line_info.list_item {
257 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
261 let line_content = line_info.content(ctx.content);
263 let mut remaining = line_content;
264 let mut content_start = 0;
265
266 loop {
267 let trimmed = remaining.trim_start();
268 if !trimmed.starts_with('>') {
269 break;
270 }
271 content_start += remaining.len() - trimmed.len();
273 content_start += 1;
275 let after_gt = &trimmed[1..];
276 if let Some(stripped) = after_gt.strip_prefix(' ') {
278 content_start += 1;
279 remaining = stripped;
280 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
281 content_start += 1;
282 remaining = stripped;
283 } else {
284 remaining = after_gt;
285 }
286 }
287
288 let content_after_prefix = &line_content[content_start..];
290 let adjusted_col = if list_item.marker_column >= content_start {
292 list_item.marker_column - content_start
293 } else {
294 list_item.marker_column
296 };
297 (content_after_prefix.to_string(), adjusted_col)
298 } else {
299 (line_info.content(ctx.content).to_string(), list_item.marker_column)
300 };
301
302 let visual_marker_column =
304 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
305
306 let visual_content_column = if line_info.blockquote.is_some() {
308 let adjusted_content_col =
310 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
311 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
312 } else {
313 list_item.content_column
314 };
315 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
316 } else {
317 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
318 };
319
320 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
324 0
325 } else {
326 visual_marker_column
327 };
328
329 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
331
332 while let Some(&(indent, _, _, _, item_bq_depth, _)) = list_stack.last() {
335 if item_bq_depth == bq_depth && indent >= visual_marker_for_nesting {
336 list_stack.pop();
337 } else if item_bq_depth > bq_depth {
338 list_stack.pop();
340 } else {
341 break;
342 }
343 }
344
345 while let Some(&(_, _, _, content_col, item_bq_depth, _)) = list_stack.last() {
358 if item_bq_depth < bq_depth
359 && content_col > Self::indent_relative_to_depth(ctx, line_info, item_bq_depth)
360 {
361 list_stack.pop();
362 } else {
363 break;
364 }
365 }
366
367 if list_item.is_ordered {
369 list_stack.push((
372 visual_marker_column,
373 line_idx,
374 true,
375 visual_content_column,
376 bq_depth,
377 false,
378 ));
379 continue;
380 }
381
382 let threshold_ok = list_stack
406 .iter()
407 .any(|item| item.4 == bq_depth && item.2 && item.3 <= visual_marker_column);
408 let chain_ok = list_stack
409 .iter()
410 .rev()
411 .find(|item| item.4 == bq_depth)
412 .is_some_and(|item| item.2 || item.5);
413 if ctx.flavor != crate::config::MarkdownFlavor::MkDocs && threshold_ok && chain_ok {
414 list_stack.push((
415 visual_marker_column,
416 line_idx,
417 false,
418 visual_content_column,
419 bq_depth,
420 true,
421 ));
422 continue;
423 }
424
425 let nesting_level = list_stack.iter().filter(|item| item.4 == bq_depth).count();
427
428 let parent_info = list_stack
430 .iter()
431 .rev()
432 .find(|item| item.4 == bq_depth)
433 .map(|&(_, _, is_ordered, content_col, _, _)| (is_ordered, content_col));
434
435 let mut expected_indent = if self.config.start_indented && nesting_level == 0 {
441 self.config.start_indent.get() as usize
442 } else {
443 self.calculate_expected_indent(nesting_level, parent_info)
444 };
445
446 let also_acceptable =
450 if self.config.indent_explicit && parent_info.is_some_and(|(is_ordered, _)| is_ordered) {
451 Some(nesting_level * self.config.indent.get() as usize)
452 } else {
453 None
454 };
455
456 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
460 && let Some(&(parent_marker_col, _, true, _, _, _)) =
461 list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2)
462 {
463 expected_indent = expected_indent.max(parent_marker_col + 4);
464 }
465
466 let accepted_indent = if also_acceptable.is_some_and(|alt| visual_marker_column == alt) {
472 visual_marker_column
473 } else {
474 expected_indent
475 };
476 let expected_content_visual_col = accepted_indent + 2;
477 list_stack.push((
478 visual_marker_column,
479 line_idx,
480 false,
481 expected_content_visual_col,
482 bq_depth,
483 false,
484 ));
485
486 if !self.config.start_indented && nesting_level == 0 && visual_marker_column == 0 {
492 continue;
493 }
494
495 if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
496 if let Some(alt) = also_acceptable {
498 expected_indent = alt;
499 }
500 let fix = {
502 let correct_indent = " ".repeat(expected_indent);
503
504 let replacement = if line_info.blockquote.is_some() {
507 let mut blockquote_count = 0;
509 for ch in line_info.content(ctx.content).chars() {
510 if ch == '>' {
511 blockquote_count += 1;
512 } else if ch != ' ' && ch != '\t' {
513 break;
514 }
515 }
516 let blockquote_prefix = if blockquote_count > 1 {
518 (0..blockquote_count)
519 .map(|_| "> ")
520 .collect::<String>()
521 .trim_end()
522 .to_string()
523 } else {
524 ">".to_string()
525 };
526 format!("{blockquote_prefix} {correct_indent}")
529 } else {
530 correct_indent
531 };
532
533 let start_byte = line_info.byte_offset;
536 let mut end_byte = line_info.byte_offset;
537
538 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
540 if i >= list_item.marker_column {
541 break;
542 }
543 end_byte += ch.len_utf8();
544 }
545
546 Some(crate::rule::Fix::new(start_byte..end_byte, replacement))
547 };
548
549 warnings.push(LintWarning {
550 rule_name: Some(self.name().to_string()),
551 message: format!(
552 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
553 ),
554 line: line_idx + 1, column: 1, end_line: line_idx + 1,
557 end_column: visual_marker_column + 1, severity: Severity::Warning,
559 fix,
560 });
561 }
562 } else if !line_info.is_blank {
563 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
590 let prev_line = line_idx.checked_sub(1).map(|i| &ctx.lines[i]);
591 let prev_blank = prev_line.is_none_or(|p| p.is_blank);
592 let prev_bq_depth = prev_line
593 .and_then(|p| p.blockquote.as_ref())
594 .map_or(0, |bq| bq.nesting_level);
595 let same_container = prev_bq_depth == bq_depth;
596 let text = line_info
597 .blockquote
598 .as_ref()
599 .map_or_else(|| line_info.content(ctx.content), |bq| bq.content.as_str());
600 let trimmed = text.trim_start();
601 let starts_like_list_marker = match trimmed.as_bytes().first() {
602 Some(b'-' | b'*' | b'+') => {
603 matches!(trimmed.as_bytes().get(1), Some(b' ' | b'\t'))
604 }
605 Some(c) if c.is_ascii_digit() => {
606 let after_digits = trimmed.trim_start_matches(|ch: char| ch.is_ascii_digit());
610 let num_digits = trimmed.len() - after_digits.len();
611 let mut rest = after_digits.chars();
612 (1..=9).contains(&num_digits)
613 && matches!(rest.next(), Some('.' | ')'))
614 && matches!(rest.next(), Some(' ' | '\t') | None)
615 }
616 _ => false,
617 };
618 let prev_is_open_paragraph = prev_line.is_some_and(|p| {
625 !p.is_blank
626 && !p.in_code_block
627 && p.heading.is_none()
628 && !p.is_horizontal_rule
629 && !p.in_html_block
630 && !p.in_html_comment
631 && !p.is_div_marker
632 });
633 let is_lazy_paragraph_continuation = !prev_blank
634 && prev_is_open_paragraph
635 && same_container
636 && !starts_like_list_marker
637 && line_info.heading.is_none()
638 && !line_info.is_horizontal_rule
639 && !line_info.in_code_block
640 && !line_info.in_html_block
641 && !line_info.in_html_comment
642 && !line_info.is_div_marker;
643 if is_lazy_paragraph_continuation {
644 continue;
646 }
647 Self::terminate_closed_items(ctx, line_info, &mut list_stack, bq_depth);
648 }
649 }
650 Ok(warnings)
651 }
652
653 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
655 let warnings = self.check(ctx)?;
657 let warnings =
658 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
659
660 if warnings.is_empty() {
662 return Ok(ctx.content.to_string());
663 }
664
665 let mut fixes: Vec<_> = warnings
667 .iter()
668 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
669 .collect();
670 fixes.sort_by(|a, b| b.0.cmp(&a.0));
671
672 let mut result = ctx.content.to_string();
674 for (start, end, replacement) in fixes {
675 if start < result.len() && end <= result.len() && start <= end {
676 result.replace_range(start..end, replacement);
677 }
678 }
679
680 Ok(result)
681 }
682
683 fn category(&self) -> RuleCategory {
685 RuleCategory::List
686 }
687
688 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
690 if ctx.content.is_empty() || !ctx.likely_has_lists() {
692 return true;
693 }
694 !ctx.lines
696 .iter()
697 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
698 }
699
700 fn as_any(&self) -> &dyn std::any::Any {
701 self
702 }
703
704 fn default_config_section(&self) -> Option<(String, toml::Value)> {
705 let default_config = MD007Config::default();
706 let json_value = serde_json::to_value(&default_config).ok()?;
707 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
708
709 if let toml::Value::Table(table) = toml_value {
710 if !table.is_empty() {
711 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
712 } else {
713 None
714 }
715 } else {
716 None
717 }
718 }
719
720 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
721 where
722 Self: Sized,
723 {
724 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
725
726 if let Some(rule_cfg) = config.rules.get("MD007") {
728 rule_config.style_explicit = rule_cfg.values.contains_key("style");
729 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
730
731 if rule_config.indent_explicit
735 && rule_config.style_explicit
736 && rule_config.style == md007_config::IndentStyle::TextAligned
737 {
738 eprintln!(
739 "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
740 Text-aligned style ignores indent and aligns nested items with parent text. \
741 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
742 rule_config.indent.get()
743 );
744 }
745 }
746
747 if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
750 if rule_config.indent_explicit && rule_config.indent.get() < 4 {
751 eprintln!(
752 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires indent >= 4 \
753 (Python-Markdown enforces 4-space indentation). \
754 Overriding indent={} to indent=4.",
755 rule_config.indent.get()
756 );
757 }
758 if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
759 eprintln!(
760 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires style=\"fixed\" \
761 (Python-Markdown uses fixed 4-space indentation). \
762 Overriding style=\"text-aligned\" to style=\"fixed\"."
763 );
764 }
765 if rule_config.indent.get() < 4 {
766 rule_config.indent = crate::types::IndentSize::from_const(4);
767 }
768 rule_config.style = md007_config::IndentStyle::Fixed;
769 }
770
771 Box::new(Self::from_config_struct(rule_config))
772 }
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778 use crate::lint_context::LintContext;
779 use crate::rule::Rule;
780
781 #[test]
782 fn test_valid_list_indent() {
783 let rule = MD007ULIndent::default();
784 let content = "* Item 1\n * Item 2\n * Item 3";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert!(
788 result.is_empty(),
789 "Expected no warnings for valid indentation, but got {} warnings",
790 result.len()
791 );
792 }
793
794 #[test]
795 fn test_invalid_list_indent() {
796 let rule = MD007ULIndent::default();
797 let content = "* Item 1\n * Item 2\n * Item 3";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let result = rule.check(&ctx).unwrap();
800 assert_eq!(result.len(), 2);
801 assert_eq!(result[0].line, 2);
802 assert_eq!(result[0].column, 1);
803 assert_eq!(result[1].line, 3);
804 assert_eq!(result[1].column, 1);
805 }
806
807 #[test]
808 fn test_mixed_indentation() {
809 let rule = MD007ULIndent::default();
810 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812 let result = rule.check(&ctx).unwrap();
813 assert_eq!(result.len(), 1);
814 assert_eq!(result[0].line, 3);
815 assert_eq!(result[0].column, 1);
816 }
817
818 #[test]
819 fn test_fix_indentation() {
820 let rule = MD007ULIndent::default();
821 let content = "* Item 1\n * Item 2\n * Item 3";
822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823 let result = rule.fix(&ctx).unwrap();
824 let expected = "* Item 1\n * Item 2\n * Item 3";
828 assert_eq!(result, expected);
829 }
830
831 #[test]
832 fn test_md007_in_yaml_code_block() {
833 let rule = MD007ULIndent::default();
834 let content = r#"```yaml
835repos:
836- repo: https://github.com/rvben/rumdl
837 rev: v0.5.0
838 hooks:
839 - id: rumdl-check
840```"#;
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843 assert!(
844 result.is_empty(),
845 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
846 );
847 }
848
849 #[test]
850 fn test_blockquoted_list_indent() {
851 let rule = MD007ULIndent::default();
852 let content = "> * Item 1\n> * Item 2\n> * Item 3";
853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854 let result = rule.check(&ctx).unwrap();
855 assert!(
856 result.is_empty(),
857 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
858 );
859 }
860
861 #[test]
862 fn test_blockquoted_list_invalid_indent() {
863 let rule = MD007ULIndent::default();
864 let content = "> * Item 1\n> * Item 2\n> * Item 3";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867 assert_eq!(
868 result.len(),
869 2,
870 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
871 );
872 assert_eq!(result[0].line, 2);
873 assert_eq!(result[1].line, 3);
874 }
875
876 #[test]
877 fn test_nested_blockquote_list_indent() {
878 let rule = MD007ULIndent::default();
879 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx).unwrap();
882 assert!(
883 result.is_empty(),
884 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
885 );
886 }
887
888 #[test]
889 fn test_blockquote_list_with_code_block() {
890 let rule = MD007ULIndent::default();
891 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let result = rule.check(&ctx).unwrap();
894 assert!(
895 result.is_empty(),
896 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
897 );
898 }
899
900 #[test]
901 fn test_properly_indented_lists() {
902 let rule = MD007ULIndent::default();
903
904 let test_cases = vec![
906 "* Item 1\n* Item 2",
907 "* Item 1\n * Item 1.1\n * Item 1.1.1",
908 "- Item 1\n - Item 1.1",
909 "+ Item 1\n + Item 1.1",
910 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
911 ];
912
913 for content in test_cases {
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let result = rule.check(&ctx).unwrap();
916 assert!(
917 result.is_empty(),
918 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
919 content,
920 result.len()
921 );
922 }
923 }
924
925 #[test]
926 fn test_under_indented_lists() {
927 let rule = MD007ULIndent::default();
928
929 let test_cases = vec![
930 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
933
934 for (content, expected_warnings, line) in test_cases {
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936 let result = rule.check(&ctx).unwrap();
937 assert_eq!(
938 result.len(),
939 expected_warnings,
940 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
941 );
942 if expected_warnings > 0 {
943 assert_eq!(result[0].line, line);
944 }
945 }
946 }
947
948 #[test]
949 fn test_over_indented_lists() {
950 let rule = MD007ULIndent::default();
951
952 let test_cases = vec![
953 ("* 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), ];
957
958 for (content, expected_warnings, line) in test_cases {
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.check(&ctx).unwrap();
961 assert_eq!(
962 result.len(),
963 expected_warnings,
964 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
965 );
966 if expected_warnings > 0 {
967 assert_eq!(result[0].line, line);
968 }
969 }
970 }
971
972 #[test]
973 fn test_custom_indent_2_spaces() {
974 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977 let result = rule.check(&ctx).unwrap();
978 assert!(result.is_empty());
979 }
980
981 #[test]
982 fn test_custom_indent_3_spaces() {
983 let rule = MD007ULIndent::new(3);
986
987 let correct_content = "* Item 1\n * Item 2\n * Item 3";
989 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
990 let result = rule.check(&ctx).unwrap();
991 assert!(
992 result.is_empty(),
993 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
994 );
995
996 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
998 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
1001 }
1002
1003 #[test]
1004 fn test_custom_indent_4_spaces() {
1005 let rule = MD007ULIndent::new(4);
1008
1009 let correct_content = "* Item 1\n * Item 2\n * Item 3";
1011 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1012 let result = rule.check(&ctx).unwrap();
1013 assert!(
1014 result.is_empty(),
1015 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
1016 );
1017
1018 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
1020 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1021 let result = rule.check(&ctx).unwrap();
1022 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
1023 }
1024
1025 #[test]
1026 fn test_tab_indentation() {
1027 let rule = MD007ULIndent::default();
1028
1029 let content = "* Item 1\n * Item 2";
1035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036 let result = rule.check(&ctx).unwrap();
1037 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
1038
1039 let fixed = rule.fix(&ctx).unwrap();
1041 assert_eq!(fixed, "* Item 1\n * Item 2");
1042
1043 let content_multi = "* Item 1\n * Item 2\n * Item 3";
1045 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
1046 let fixed = rule.fix(&ctx).unwrap();
1047 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1050
1051 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
1053 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
1054 let fixed = rule.fix(&ctx).unwrap();
1055 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
1058 }
1059
1060 #[test]
1061 fn test_mixed_ordered_unordered_lists() {
1062 let rule = MD007ULIndent::default();
1063
1064 let content = r#"1. Ordered item
1067 * Unordered sub-item (correct - 3 spaces under ordered)
1068 2. Ordered sub-item
1069* Unordered item
1070 1. Ordered sub-item
1071 * Unordered sub-item"#;
1072
1073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1074 let result = rule.check(&ctx).unwrap();
1075 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
1076
1077 let fixed = rule.fix(&ctx).unwrap();
1079 assert_eq!(fixed, content);
1080 }
1081
1082 #[test]
1083 fn test_list_markers_variety() {
1084 let rule = MD007ULIndent::default();
1085
1086 let content = r#"* Asterisk
1088 * Nested asterisk
1089- Hyphen
1090 - Nested hyphen
1091+ Plus
1092 + Nested plus"#;
1093
1094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1095 let result = rule.check(&ctx).unwrap();
1096 assert!(
1097 result.is_empty(),
1098 "All unordered list markers should work with proper indentation"
1099 );
1100
1101 let wrong_content = r#"* Asterisk
1103 * Wrong asterisk
1104- Hyphen
1105 - Wrong hyphen
1106+ Plus
1107 + Wrong plus"#;
1108
1109 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
1112 }
1113
1114 #[test]
1115 fn test_empty_list_items() {
1116 let rule = MD007ULIndent::default();
1117 let content = "* Item 1\n* \n * Item 2";
1118 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1119 let result = rule.check(&ctx).unwrap();
1120 assert!(
1121 result.is_empty(),
1122 "Empty list items should not affect indentation checks"
1123 );
1124 }
1125
1126 #[test]
1127 fn test_list_with_code_blocks() {
1128 let rule = MD007ULIndent::default();
1129 let content = r#"* Item 1
1130 ```
1131 code
1132 ```
1133 * Item 2
1134 * Item 3"#;
1135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1136 let result = rule.check(&ctx).unwrap();
1137 assert!(result.is_empty());
1138 }
1139
1140 #[test]
1141 fn test_list_in_front_matter() {
1142 let rule = MD007ULIndent::default();
1143 let content = r#"---
1144tags:
1145 - tag1
1146 - tag2
1147---
1148* Item 1
1149 * Item 2"#;
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151 let result = rule.check(&ctx).unwrap();
1152 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
1153 }
1154
1155 #[test]
1156 fn test_fix_preserves_content() {
1157 let rule = MD007ULIndent::default();
1158 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160 let fixed = rule.fix(&ctx).unwrap();
1161 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
1164 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
1165 }
1166
1167 #[test]
1168 fn test_start_indented_config() {
1169 let config = MD007Config {
1170 start_indented: true,
1171 start_indent: crate::types::IndentSize::from_const(4),
1172 indent: crate::types::IndentSize::from_const(2),
1173 style: md007_config::IndentStyle::TextAligned,
1174 style_explicit: true, indent_explicit: false,
1176 };
1177 let rule = MD007ULIndent::from_config_struct(config);
1178
1179 let content = " * Item 1\n * Item 2\n * Item 3";
1184 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1185 let result = rule.check(&ctx).unwrap();
1186 assert!(result.is_empty(), "Expected no warnings with start_indented config");
1187
1188 let wrong_content = " * Item 1\n * Item 2";
1190 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1191 let result = rule.check(&ctx).unwrap();
1192 assert_eq!(result.len(), 2);
1193 assert_eq!(result[0].line, 1);
1194 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
1195 assert_eq!(result[1].line, 2);
1196 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
1197
1198 let fixed = rule.fix(&ctx).unwrap();
1200 assert_eq!(fixed, " * Item 1\n * Item 2");
1201 }
1202
1203 #[test]
1204 fn test_start_indented_false_flags_indented_first_level() {
1205 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213 let result = rule.check(&ctx).unwrap();
1214 assert!(
1215 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1216 "a top-level item indented 3 spaces must be flagged with Expected 0, got: {result:?}"
1217 );
1218
1219 let content = "* Item 1\n * Item 2\n * Item 3";
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224 let result = rule.check(&ctx).unwrap();
1225 assert!(
1226 result.is_empty(),
1227 "a correctly nested 0/2/4-space list should produce no warnings, got: {result:?}"
1228 );
1229 }
1230
1231 #[test]
1232 fn test_deeply_nested_lists() {
1233 let rule = MD007ULIndent::default();
1234 let content = r#"* L1
1235 * L2
1236 * L3
1237 * L4
1238 * L5
1239 * L6"#;
1240 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1241 let result = rule.check(&ctx).unwrap();
1242 assert!(result.is_empty());
1243
1244 let wrong_content = r#"* L1
1246 * L2
1247 * L3
1248 * L4
1249 * L5
1250 * L6"#;
1251 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1252 let result = rule.check(&ctx).unwrap();
1253 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
1254 }
1255
1256 #[test]
1257 fn test_excessive_indentation_detected() {
1258 let rule = MD007ULIndent::default();
1259
1260 let content = "- Item 1\n - Item 2 with 5 spaces";
1262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1263 let result = rule.check(&ctx).unwrap();
1264 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
1265 assert_eq!(result[0].line, 2);
1266 assert!(result[0].message.contains("Expected 2 spaces"));
1267 assert!(result[0].message.contains("found 5"));
1268
1269 let content = "- Item 1\n - Item 2 with 3 spaces";
1271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272 let result = rule.check(&ctx).unwrap();
1273 assert_eq!(
1274 result.len(),
1275 1,
1276 "Should detect slightly excessive indentation (3 instead of 2)"
1277 );
1278 assert_eq!(result[0].line, 2);
1279 assert!(result[0].message.contains("Expected 2 spaces"));
1280 assert!(result[0].message.contains("found 3"));
1281
1282 let content = "- Item 1\n - Item 2 with 1 space";
1284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1285 let result = rule.check(&ctx).unwrap();
1286 assert_eq!(
1287 result.len(),
1288 1,
1289 "Should detect 1-space indent (insufficient for nesting, expected 0)"
1290 );
1291 assert_eq!(result[0].line, 2);
1292 assert!(result[0].message.contains("Expected 0 spaces"));
1293 assert!(result[0].message.contains("found 1"));
1294 }
1295
1296 #[test]
1297 fn test_excessive_indentation_with_4_space_config() {
1298 let rule = MD007ULIndent::new(4);
1301
1302 let content = "- Formatter:\n - The stable style changed";
1304 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1305 let result = rule.check(&ctx).unwrap();
1306 assert!(
1307 !result.is_empty(),
1308 "Should detect 5 spaces when expecting 4 (fixed style)"
1309 );
1310
1311 let correct_content = "- Formatter:\n - The stable style changed";
1313 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1314 let result = rule.check(&ctx).unwrap();
1315 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
1316 }
1317
1318 #[test]
1319 fn test_bullets_nested_under_numbered_items() {
1320 let rule = MD007ULIndent::default();
1321 let content = "\
13221. **Active Directory/LDAP**
1323 - User authentication and directory services
1324 - LDAP for user information and validation
1325
13262. **Oracle Unified Directory (OUD)**
1327 - Extended user directory services";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329 let result = rule.check(&ctx).unwrap();
1330 assert!(
1332 result.is_empty(),
1333 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1334 );
1335 }
1336
1337 #[test]
1338 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1339 let rule = MD007ULIndent::default();
1340 let content = "\
13411. **Active Directory/LDAP**
1342 - Wrong: only 2 spaces";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 let result = rule.check(&ctx).unwrap();
1345 assert_eq!(
1347 result.len(),
1348 1,
1349 "Expected warning for incorrect indentation under numbered items"
1350 );
1351 assert!(
1352 result
1353 .iter()
1354 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1355 );
1356 }
1357
1358 #[test]
1359 fn test_regular_bullet_nesting_still_works() {
1360 let rule = MD007ULIndent::default();
1361 let content = "\
1362* Top level
1363 * Nested bullet (2 spaces is correct)
1364 * Deeply nested (4 spaces)";
1365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1366 let result = rule.check(&ctx).unwrap();
1367 assert!(
1369 result.is_empty(),
1370 "Expected no warnings for standard bullet nesting, got: {result:?}"
1371 );
1372 }
1373
1374 #[test]
1375 fn test_blockquote_with_tab_after_marker() {
1376 let rule = MD007ULIndent::default();
1377 let content = ">\t* List item\n>\t * Nested\n";
1378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1379 let result = rule.check(&ctx).unwrap();
1380 assert!(
1381 result.is_empty(),
1382 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1383 );
1384 }
1385
1386 #[test]
1387 fn test_blockquote_with_space_then_tab_after_marker() {
1388 let rule = MD007ULIndent::default();
1389 let content = "> \t* List item\n";
1390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1391 let result = rule.check(&ctx).unwrap();
1392 assert!(
1397 result.iter().any(|w| w.line == 1 && w.message.contains("Expected 0")),
1398 "an indented blockquoted top-level item must be flagged with Expected 0, got: {result:?}"
1399 );
1400 }
1401
1402 #[test]
1403 fn test_blockquote_with_multiple_tabs() {
1404 let rule = MD007ULIndent::default();
1405 let content = ">\t\t* List item\n";
1406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1407 let result = rule.check(&ctx).unwrap();
1408 assert!(
1410 result.is_empty(),
1411 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1412 );
1413 }
1414
1415 #[test]
1416 fn test_nested_blockquote_with_tab() {
1417 let rule = MD007ULIndent::default();
1418 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420 let result = rule.check(&ctx).unwrap();
1421 assert!(
1422 result.is_empty(),
1423 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1424 );
1425 }
1426
1427 #[test]
1430 fn test_smart_style_pure_unordered_uses_fixed() {
1431 let rule = MD007ULIndent::new(4);
1433
1434 let content = "* Level 0\n * Level 1\n * Level 2";
1436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1437 let result = rule.check(&ctx).unwrap();
1438 assert!(
1439 result.is_empty(),
1440 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1441 );
1442 }
1443
1444 #[test]
1445 fn test_smart_style_mixed_lists_uses_text_aligned() {
1446 let rule = MD007ULIndent::new(4);
1448
1449 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1452 let result = rule.check(&ctx).unwrap();
1453 assert!(
1454 result.is_empty(),
1455 "Mixed lists should use text-aligned style, got: {result:?}"
1456 );
1457 }
1458
1459 #[test]
1460 fn test_smart_style_explicit_fixed_overrides() {
1461 let config = MD007Config {
1463 indent: crate::types::IndentSize::from_const(4),
1464 start_indented: false,
1465 start_indent: crate::types::IndentSize::from_const(2),
1466 style: md007_config::IndentStyle::Fixed,
1467 style_explicit: true, indent_explicit: false,
1469 };
1470 let rule = MD007ULIndent::from_config_struct(config);
1471
1472 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475 let result = rule.check(&ctx).unwrap();
1476 assert!(
1478 result.is_empty(),
1479 "Explicit fixed style should be respected, got: {result:?}"
1480 );
1481 }
1482
1483 #[test]
1484 fn test_smart_style_explicit_text_aligned_overrides() {
1485 let config = MD007Config {
1487 indent: crate::types::IndentSize::from_const(4),
1488 start_indented: false,
1489 start_indent: crate::types::IndentSize::from_const(2),
1490 style: md007_config::IndentStyle::TextAligned,
1491 style_explicit: true, indent_explicit: false,
1493 };
1494 let rule = MD007ULIndent::from_config_struct(config);
1495
1496 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1499 let result = rule.check(&ctx).unwrap();
1500 assert!(
1501 result.is_empty(),
1502 "Explicit text-aligned should be respected, got: {result:?}"
1503 );
1504
1505 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1507 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1508 let result = rule.check(&ctx).unwrap();
1509 assert!(
1510 !result.is_empty(),
1511 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1512 );
1513 }
1514
1515 #[test]
1516 fn test_smart_style_default_indent_no_autoswitch() {
1517 let rule = MD007ULIndent::new(2);
1519
1520 let content = "* Level 0\n * Level 1\n * Level 2";
1521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1522 let result = rule.check(&ctx).unwrap();
1523 assert!(
1524 result.is_empty(),
1525 "Default indent should work regardless of style, got: {result:?}"
1526 );
1527 }
1528
1529 #[test]
1530 fn test_has_mixed_list_nesting_detection() {
1531 let content = "* Item 1\n * Item 2\n * Item 3";
1535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1536 assert!(
1537 !ctx.has_mixed_list_nesting(),
1538 "Pure unordered should not be detected as mixed"
1539 );
1540
1541 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544 assert!(
1545 !ctx.has_mixed_list_nesting(),
1546 "Pure ordered should not be detected as mixed"
1547 );
1548
1549 let content = "1. Ordered\n * Unordered child";
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552 assert!(
1553 ctx.has_mixed_list_nesting(),
1554 "Unordered under ordered should be detected as mixed"
1555 );
1556
1557 let content = "* Unordered\n 1. Ordered child";
1559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1560 assert!(
1561 ctx.has_mixed_list_nesting(),
1562 "Ordered under unordered should be detected as mixed"
1563 );
1564
1565 let content = "* Unordered\n\n1. Ordered (separate list)";
1567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568 assert!(
1569 !ctx.has_mixed_list_nesting(),
1570 "Separate lists should not be detected as mixed"
1571 );
1572
1573 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1576 assert!(
1577 ctx.has_mixed_list_nesting(),
1578 "Mixed lists in blockquotes should be detected"
1579 );
1580 }
1581
1582 #[test]
1583 fn test_issue_210_exact_reproduction() {
1584 let config = MD007Config {
1586 indent: crate::types::IndentSize::from_const(4),
1587 start_indented: false,
1588 start_indent: crate::types::IndentSize::from_const(2),
1589 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1593 let rule = MD007ULIndent::from_config_struct(config);
1594
1595 let content = "# Title\n\n* some\n * list\n * items\n";
1596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1597 let result = rule.check(&ctx).unwrap();
1598
1599 assert!(
1600 result.is_empty(),
1601 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_issue_209_still_fixed() {
1607 let config = MD007Config {
1610 indent: crate::types::IndentSize::from_const(3),
1611 start_indented: false,
1612 start_indent: crate::types::IndentSize::from_const(2),
1613 style: md007_config::IndentStyle::TextAligned,
1614 style_explicit: true, indent_explicit: false,
1616 };
1617 let rule = MD007ULIndent::from_config_struct(config);
1618
1619 let content = r#"# Header 1
1621
1622- **Second item**:
1623 - **This is a nested list**:
1624 1. **First point**
1625 - First subpoint
1626"#;
1627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628 let result = rule.check(&ctx).unwrap();
1629
1630 assert!(
1631 result.is_empty(),
1632 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1633 );
1634 }
1635
1636 #[test]
1639 fn test_multi_level_mixed_detection_grandparent() {
1640 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645 assert!(
1646 ctx.has_mixed_list_nesting(),
1647 "Should detect mixed nesting when grandparent differs in type"
1648 );
1649
1650 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1653 assert!(
1654 ctx.has_mixed_list_nesting(),
1655 "Should detect mixed nesting for ordered descendants under unordered"
1656 );
1657 }
1658
1659 #[test]
1660 fn test_html_comments_skipped_in_detection() {
1661 let content = r#"* Unordered list
1663<!-- This is a comment
1664 1. This ordered list is inside a comment
1665 * This nested bullet is also inside
1666-->
1667 * Another unordered item"#;
1668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1669 assert!(
1670 !ctx.has_mixed_list_nesting(),
1671 "Lists in HTML comments should be ignored in mixed detection"
1672 );
1673 }
1674
1675 #[test]
1676 fn test_blank_lines_separate_lists() {
1677 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1680 assert!(
1681 !ctx.has_mixed_list_nesting(),
1682 "Blank line at root should separate lists"
1683 );
1684
1685 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1688 assert!(
1689 ctx.has_mixed_list_nesting(),
1690 "Indented list after blank is still nested"
1691 );
1692 }
1693
1694 #[test]
1695 fn test_column_1_normalization() {
1696 let content = "* First item\n * Second item with 1 space (sibling)";
1699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700 let rule = MD007ULIndent::default();
1701 let result = rule.check(&ctx).unwrap();
1702 assert!(
1704 result.iter().any(|w| w.line == 2),
1705 "1-space indent should be flagged as incorrect"
1706 );
1707 }
1708
1709 #[test]
1710 fn test_code_blocks_skipped_in_detection() {
1711 let content = r#"* Unordered list
1713```
17141. This ordered list is inside a code block
1715 * This nested bullet is also inside
1716```
1717 * Another unordered item"#;
1718 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1719 assert!(
1720 !ctx.has_mixed_list_nesting(),
1721 "Lists in code blocks should be ignored in mixed detection"
1722 );
1723 }
1724
1725 #[test]
1726 fn test_front_matter_skipped_in_detection() {
1727 let content = r#"---
1729items:
1730 - yaml list item
1731 - another item
1732---
1733* Unordered list after front matter"#;
1734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1735 assert!(
1736 !ctx.has_mixed_list_nesting(),
1737 "Lists in front matter should be ignored in mixed detection"
1738 );
1739 }
1740
1741 #[test]
1742 fn test_alternating_types_at_same_level() {
1743 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1747 assert!(
1748 !ctx.has_mixed_list_nesting(),
1749 "Alternating types at same level should not be detected as mixed"
1750 );
1751 }
1752
1753 #[test]
1754 fn test_five_level_deep_mixed_nesting() {
1755 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1758 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1759 }
1760
1761 #[test]
1762 fn test_very_deep_pure_unordered_nesting() {
1763 let mut content = String::from("* L1");
1765 for level in 2..=12 {
1766 let indent = " ".repeat(level - 1);
1767 content.push_str(&format!("\n{indent}* L{level}"));
1768 }
1769
1770 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1771
1772 assert!(
1774 !ctx.has_mixed_list_nesting(),
1775 "Pure unordered deep nesting should not be detected as mixed"
1776 );
1777
1778 let rule = MD007ULIndent::new(4);
1780 let result = rule.check(&ctx).unwrap();
1781 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1784 }
1785
1786 #[test]
1787 fn test_interleaved_content_between_list_items() {
1788 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1791 assert!(
1792 ctx.has_mixed_list_nesting(),
1793 "Should detect mixed nesting even with interleaved paragraphs"
1794 );
1795 }
1796
1797 #[test]
1798 fn test_esm_blocks_skipped_in_detection() {
1799 let content = "* Unordered list\n * Nested unordered";
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803 assert!(
1804 !ctx.has_mixed_list_nesting(),
1805 "Pure unordered should not be detected as mixed"
1806 );
1807 }
1808
1809 #[test]
1810 fn test_multiple_list_blocks_pure_then_mixed() {
1811 let content = r#"* Pure unordered
1814 * Nested unordered
1815
18161. Mixed section
1817 * Bullet under ordered"#;
1818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819 assert!(
1820 ctx.has_mixed_list_nesting(),
1821 "Should detect mixed nesting in any part of document"
1822 );
1823 }
1824
1825 #[test]
1826 fn test_multiple_separate_pure_lists() {
1827 let content = r#"* First list
1830 * Nested
1831
1832* Second list
1833 * Also nested
1834
1835* Third list
1836 * Deeply
1837 * Nested"#;
1838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839 assert!(
1840 !ctx.has_mixed_list_nesting(),
1841 "Multiple separate pure unordered lists should not be mixed"
1842 );
1843 }
1844
1845 #[test]
1846 fn test_code_block_between_list_items() {
1847 let content = r#"1. Ordered
1849 ```
1850 code
1851 ```
1852 * Still a mixed child"#;
1853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1854 assert!(
1855 ctx.has_mixed_list_nesting(),
1856 "Code block between items should not prevent mixed detection"
1857 );
1858 }
1859
1860 #[test]
1861 fn test_blockquoted_mixed_detection() {
1862 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1865 assert!(
1868 ctx.has_mixed_list_nesting(),
1869 "Should detect mixed nesting in blockquotes"
1870 );
1871 }
1872
1873 #[test]
1876 fn test_indent_explicit_uses_fixed_style() {
1877 let config = MD007Config {
1880 indent: crate::types::IndentSize::from_const(4),
1881 start_indented: false,
1882 start_indent: crate::types::IndentSize::from_const(2),
1883 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
1887 let rule = MD007ULIndent::from_config_struct(config);
1888
1889 let content = "* Level 0\n * Level 1\n * Level 2";
1892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893 let result = rule.check(&ctx).unwrap();
1894 assert!(
1895 result.is_empty(),
1896 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1897 );
1898
1899 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1901 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1902 let result = rule.check(&ctx).unwrap();
1903 assert!(
1904 !result.is_empty(),
1905 "Should flag text-aligned spacing when indent_explicit=true"
1906 );
1907 }
1908
1909 #[test]
1910 fn test_explicit_style_overrides_indent_explicit() {
1911 let config = MD007Config {
1914 indent: crate::types::IndentSize::from_const(4),
1915 start_indented: false,
1916 start_indent: crate::types::IndentSize::from_const(2),
1917 style: md007_config::IndentStyle::TextAligned,
1918 style_explicit: true, indent_explicit: true, };
1921 let rule = MD007ULIndent::from_config_struct(config);
1922
1923 let content = "* Level 0\n * Level 1\n * Level 2";
1925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926 let result = rule.check(&ctx).unwrap();
1927 assert!(
1928 result.is_empty(),
1929 "Explicit text-aligned style should be respected, got: {result:?}"
1930 );
1931 }
1932
1933 #[test]
1934 fn test_no_indent_explicit_uses_smart_detection() {
1935 let config = MD007Config {
1937 indent: crate::types::IndentSize::from_const(4),
1938 start_indented: false,
1939 start_indent: crate::types::IndentSize::from_const(2),
1940 style: md007_config::IndentStyle::TextAligned,
1941 style_explicit: false,
1942 indent_explicit: false, };
1944 let rule = MD007ULIndent::from_config_struct(config);
1945
1946 let content = "* Level 0\n * Level 1";
1949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1950 let result = rule.check(&ctx).unwrap();
1951 assert!(
1953 result.is_empty(),
1954 "Smart detection should accept 4-space indent, got: {result:?}"
1955 );
1956 }
1957
1958 #[test]
1959 fn test_issue_273_exact_reproduction() {
1960 let config = MD007Config {
1963 indent: crate::types::IndentSize::from_const(4),
1964 start_indented: false,
1965 start_indent: crate::types::IndentSize::from_const(2),
1966 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
1968 indent_explicit: true, };
1970 let rule = MD007ULIndent::from_config_struct(config);
1971
1972 let content = r#"* Item 1
1973 * Item 2
1974 * Item 3"#;
1975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1976 let result = rule.check(&ctx).unwrap();
1977 assert!(
1978 result.is_empty(),
1979 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
1980 );
1981 }
1982
1983 #[test]
1984 fn test_indent_explicit_with_ordered_parent() {
1985 let config = MD007Config {
1989 indent: crate::types::IndentSize::from_const(4),
1990 start_indented: false,
1991 start_indent: crate::types::IndentSize::from_const(2),
1992 style: md007_config::IndentStyle::TextAligned,
1993 style_explicit: false,
1994 indent_explicit: true, };
1996 let rule = MD007ULIndent::from_config_struct(config);
1997
1998 let content = "1. Ordered\n * Bullet with 4-space indent";
2000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2001 let result = rule.check(&ctx).unwrap();
2002 assert!(
2003 result.is_empty(),
2004 "4-space indent under ordered should pass with indent=4: {result:?}"
2005 );
2006
2007 let content_3 = "1. Ordered\n * Bullet with 3-space indent";
2009 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2010 let result = rule.check(&ctx).unwrap();
2011 assert!(
2012 result.is_empty(),
2013 "3-space indent under ordered should pass (text-aligned): {result:?}"
2014 );
2015
2016 let wrong_content = "1. Ordered\n * Bullet with 2-space indent";
2018 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2019 let result = rule.check(&ctx).unwrap();
2020 assert!(
2021 !result.is_empty(),
2022 "2-space indent under ordered list should be flagged when indent=4: {result:?}"
2023 );
2024 }
2025
2026 #[test]
2027 fn test_indent_explicit_mixed_list_deep_nesting() {
2028 let config = MD007Config {
2033 indent: crate::types::IndentSize::from_const(4),
2034 start_indented: false,
2035 start_indent: crate::types::IndentSize::from_const(2),
2036 style: md007_config::IndentStyle::TextAligned,
2037 style_explicit: false,
2038 indent_explicit: true,
2039 };
2040 let rule = MD007ULIndent::from_config_struct(config);
2041
2042 let content_text_aligned = r#"* Level 0
2048 * Level 1 (4-space indent from bullet parent)
2049 1. Level 2 ordered
2050 * Level 3 bullet (text-aligned under ordered)"#;
2051 let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
2052 let result = rule.check(&ctx).unwrap();
2053 assert!(
2054 result.is_empty(),
2055 "Text-aligned nesting under ordered should pass: {result:?}"
2056 );
2057
2058 let content_fixed = r#"* Level 0
2059 * Level 1 (4-space indent from bullet parent)
2060 1. Level 2 ordered
2061 * Level 3 bullet (fixed indent under ordered)"#;
2062 let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
2063 let result = rule.check(&ctx).unwrap();
2064 assert!(
2065 result.is_empty(),
2066 "Fixed indent nesting under ordered should also pass: {result:?}"
2067 );
2068 }
2069
2070 #[test]
2071 fn test_ordered_list_double_digit_markers() {
2072 let config = MD007Config {
2075 indent: crate::types::IndentSize::from_const(4),
2076 start_indented: false,
2077 start_indent: crate::types::IndentSize::from_const(2),
2078 style: md007_config::IndentStyle::TextAligned,
2079 style_explicit: false,
2080 indent_explicit: true,
2081 };
2082 let rule = MD007ULIndent::from_config_struct(config);
2083
2084 let content = "10. Double digit\n * Bullet at col 4";
2086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2087 let result = rule.check(&ctx).unwrap();
2088 assert!(
2089 result.is_empty(),
2090 "Bullet under '10.' should align at column 4: {result:?}"
2091 );
2092
2093 let content_3 = "1. Single digit\n * Bullet at col 3";
2096 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
2097 let result = rule.check(&ctx).unwrap();
2098 assert!(
2099 result.is_empty(),
2100 "Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
2101 );
2102
2103 let content_4 = "1. Single digit\n * Bullet at col 4";
2104 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2105 let result = rule.check(&ctx).unwrap();
2106 assert!(
2107 result.is_empty(),
2108 "Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
2109 );
2110 }
2111
2112 #[test]
2113 fn test_indent_explicit_pure_unordered_uses_fixed() {
2114 let config = MD007Config {
2117 indent: crate::types::IndentSize::from_const(4),
2118 start_indented: false,
2119 start_indent: crate::types::IndentSize::from_const(2),
2120 style: md007_config::IndentStyle::TextAligned,
2121 style_explicit: false,
2122 indent_explicit: true,
2123 };
2124 let rule = MD007ULIndent::from_config_struct(config);
2125
2126 let content = "* Level 0\n * Level 1\n * Level 2";
2128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2129 let result = rule.check(&ctx).unwrap();
2130 assert!(
2131 result.is_empty(),
2132 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
2133 );
2134
2135 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
2137 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
2138 let result = rule.check(&ctx).unwrap();
2139 assert!(
2140 !result.is_empty(),
2141 "2-space indent should be flagged when indent=4 is configured"
2142 );
2143 }
2144
2145 #[test]
2146 fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
2147 let rule = MD007ULIndent::default();
2151 let content = "1. text\n\n - nested item";
2152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2153 let result = rule.check(&ctx).unwrap();
2154 assert!(
2155 result.is_empty(),
2156 "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
2157 );
2158 }
2159
2160 #[test]
2161 fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
2162 let rule = MD007ULIndent::default();
2165 let content = "1. text\n\n - nested item";
2166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2167 let result = rule.check(&ctx).unwrap();
2168 assert!(
2169 result.is_empty(),
2170 "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
2171 );
2172 }
2173
2174 #[test]
2175 fn test_standard_flavor_ordered_list_under_ordered_is_exempt() {
2176 let rule = MD007ULIndent::default();
2181 let content = "1. text\n\n - nested item";
2182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2183 let result = rule.check(&ctx).unwrap();
2184 assert!(
2185 result.is_empty(),
2186 "unordered sublist of an ordered list must be exempt in Standard flavor, got: {result:?}"
2187 );
2188 }
2189
2190 #[test]
2191 fn test_mkdocs_multi_digit_ordered_list() {
2192 let rule = MD007ULIndent::default();
2195 let content = "10. text\n\n - nested item";
2196 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2197 let result = rule.check(&ctx).unwrap();
2198 assert!(
2199 result.is_empty(),
2200 "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
2201 );
2202 }
2203
2204 #[test]
2205 fn test_mkdocs_triple_digit_ordered_list() {
2206 let rule = MD007ULIndent::default();
2209 let content = "100. text\n\n - nested item";
2210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2211 let result = rule.check(&ctx).unwrap();
2212 assert!(
2213 result.is_empty(),
2214 "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
2215 );
2216 }
2217
2218 #[test]
2219 fn test_mkdocs_insufficient_indent_under_ordered() {
2220 let rule = MD007ULIndent::default();
2223 let content = "1. text\n\n - nested item";
2224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2225 let result = rule.check(&ctx).unwrap();
2226 assert_eq!(
2227 result.len(),
2228 1,
2229 "2-space indent under ordered list should warn in MkDocs flavor"
2230 );
2231 assert!(
2232 result[0].message.contains("Expected 4"),
2233 "Warning should expect 4 spaces (MkDocs minimum), got: {}",
2234 result[0].message
2235 );
2236 }
2237
2238 #[test]
2239 fn test_mkdocs_deeper_nesting_under_ordered() {
2240 let rule = MD007ULIndent::default();
2245 let content = "1. text\n\n - sub\n - subsub";
2246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2247 let result = rule.check(&ctx).unwrap();
2248 assert!(
2249 result.is_empty(),
2250 "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
2251 );
2252 }
2253
2254 #[test]
2255 fn test_mkdocs_fix_adjusts_to_4_spaces() {
2256 let rule = MD007ULIndent::default();
2258 let content = "1. text\n\n - nested item";
2259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2260 let result = rule.check(&ctx).unwrap();
2261 assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
2262 let fixed = rule.fix(&ctx).unwrap();
2263 assert_eq!(
2264 fixed, "1. text\n\n - nested item",
2265 "Fix should adjust indent to 4 spaces in MkDocs"
2266 );
2267 }
2268
2269 #[test]
2270 fn test_mkdocs_start_indented_with_ordered_parent() {
2271 let config = MD007Config {
2274 start_indented: true,
2275 ..Default::default()
2276 };
2277 let rule = MD007ULIndent::from_config_struct(config);
2278 let content = "1. text\n\n - nested item";
2279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2280 let result = rule.check(&ctx).unwrap();
2281 assert!(
2282 result.is_empty(),
2283 "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
2284 );
2285 }
2286
2287 #[test]
2288 fn test_mkdocs_ordered_at_nonzero_indent() {
2289 let rule = MD007ULIndent::default();
2294 let content = "- outer\n 1. inner\n - deep";
2295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2296 let result = rule.check(&ctx).unwrap();
2297 assert!(
2298 result.is_empty(),
2299 "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
2300 );
2301 }
2302
2303 #[test]
2304 fn test_mkdocs_blockquoted_ordered_list() {
2305 let rule = MD007ULIndent::default();
2309 let content = "> 1. text\n>\n> - nested item";
2310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2311 let result = rule.check(&ctx).unwrap();
2312 assert!(
2313 result.is_empty(),
2314 "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
2315 );
2316 }
2317
2318 #[test]
2319 fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
2320 let rule = MD007ULIndent::default();
2323 let content = "- outer\n 1. inner\n - deep";
2324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2325 let result = rule.check(&ctx).unwrap();
2326 assert_eq!(
2327 result.len(),
2328 1,
2329 "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
2330 );
2331 }
2332
2333 #[test]
2334 fn test_issue_504_indent4_ordered_parent() {
2335 let config = MD007Config {
2339 indent: crate::types::IndentSize::from_const(4),
2340 start_indented: false,
2341 start_indent: crate::types::IndentSize::from_const(2),
2342 style: md007_config::IndentStyle::TextAligned,
2343 style_explicit: false,
2344 indent_explicit: true,
2345 };
2346 let rule = MD007ULIndent::from_config_struct(config);
2347
2348 let content = r#"# Things
2349
2350+ An unordered list
2351 + An item with 4 spaces, ok.
2352
23531. A numbered list
2354 + A sublist with 4 spaces, not ok
2355 + A sub item with 4 spaces, ok
2356 + Why is rumdl expecting 3 spaces for a 4 space indent?
23572. Item 2
23583. Item 3"#;
2359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2360 let result = rule.check(&ctx).unwrap();
2361 assert!(
2362 result.is_empty(),
2363 "Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
2364 );
2365 }
2366
2367 #[test]
2368 fn test_indent2_explicit_with_ordered_parent() {
2369 let config = MD007Config {
2372 indent: crate::types::IndentSize::from_const(2),
2373 start_indented: false,
2374 start_indent: crate::types::IndentSize::from_const(2),
2375 style: md007_config::IndentStyle::TextAligned,
2376 style_explicit: false,
2377 indent_explicit: true,
2378 };
2379 let rule = MD007ULIndent::from_config_struct(config);
2380
2381 let content = "1. Ordered\n * Bullet at 3 spaces";
2383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2384 let result = rule.check(&ctx).unwrap();
2385 assert!(
2386 result.is_empty(),
2387 "indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
2388 );
2389
2390 let content_2 = "1. Ordered\n * Bullet at 2 spaces";
2392 let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
2393 let result = rule.check(&ctx).unwrap();
2394 assert!(
2395 result.is_empty(),
2396 "indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
2397 );
2398 }
2399
2400 const ISSUE_638_INPUT: &str = "# Title\n\n1. Some text\n - Indented text\n - more indented\n";
2404
2405 #[test]
2406 fn test_issue_638_unordered_under_ordered_smart_default() {
2407 let rule = MD007ULIndent::new(2);
2408 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2409 let result = rule.check(&ctx).unwrap();
2410 assert!(
2411 result.is_empty(),
2412 "smart default: unordered items under an ordered list must not be flagged, got: {result:?}"
2413 );
2414 }
2415
2416 #[test]
2417 fn test_issue_638_unordered_under_ordered_indent_explicit() {
2418 let config = MD007Config {
2419 indent: crate::types::IndentSize::from_const(2),
2420 start_indented: false,
2421 start_indent: crate::types::IndentSize::from_const(2),
2422 style: md007_config::IndentStyle::TextAligned,
2423 style_explicit: false,
2424 indent_explicit: true,
2425 };
2426 let rule = MD007ULIndent::from_config_struct(config);
2427 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2428 let result = rule.check(&ctx).unwrap();
2429 assert!(
2430 result.is_empty(),
2431 "indent=2 explicit: unordered items under an ordered list must not be flagged, got: {result:?}"
2432 );
2433 }
2434
2435 #[test]
2436 fn test_issue_638_unordered_under_ordered_style_fixed() {
2437 let config = MD007Config {
2439 indent: crate::types::IndentSize::from_const(2),
2440 start_indented: false,
2441 start_indent: crate::types::IndentSize::from_const(2),
2442 style: md007_config::IndentStyle::Fixed,
2443 style_explicit: true,
2444 indent_explicit: true,
2445 };
2446 let rule = MD007ULIndent::from_config_struct(config);
2447 let ctx = LintContext::new(ISSUE_638_INPUT, crate::config::MarkdownFlavor::Standard, None);
2448 let result = rule.check(&ctx).unwrap();
2449 assert!(
2450 result.is_empty(),
2451 "style=fixed: unordered items under an ordered list must not be flagged, got: {result:?}"
2452 );
2453 }
2454
2455 #[test]
2456 fn test_issue_638_deeper_unordered_chain_under_ordered() {
2457 let rule = MD007ULIndent::new(2);
2459 let content = "1. Ordered\n - child\n - grandchild\n - great-grandchild\n";
2460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2461 let result = rule.check(&ctx).unwrap();
2462 assert!(
2463 result.is_empty(),
2464 "all unordered descendants of an ordered list are exempt, got: {result:?}"
2465 );
2466 }
2467
2468 #[test]
2469 fn test_issue_638_pure_unordered_still_checked() {
2470 let rule = MD007ULIndent::new(2);
2472 let content = "- Top\n - three spaces (wrong, expected 2)\n";
2473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2474 let result = rule.check(&ctx).unwrap();
2475 assert_eq!(
2476 result.len(),
2477 1,
2478 "pure unordered nesting must still be checked, got: {result:?}"
2479 );
2480 }
2481
2482 #[test]
2483 fn test_issue_638_exemption_not_applied_after_list_terminated_by_paragraph() {
2484 let rule = MD007ULIndent::new(2);
2491 let content = "1. ordered\n\nparagraph\n\n - parent\n - child six\n";
2492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2493 let result = rule.check(&ctx).unwrap();
2494 assert_eq!(
2495 result.len(),
2496 2,
2497 "the new top-level list following a terminated ordered list is checked at both levels, got: {result:?}"
2498 );
2499 assert!(
2500 result.iter().any(|w| w.line == 5 && w.message.contains("Expected 0")),
2501 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2502 );
2503 assert!(
2504 result
2505 .iter()
2506 .any(|w| w.line == 6 && w.message.contains("Expected 2") && w.message.contains("found 6")),
2507 "the misindented child must be flagged with Expected 2, found 6, got: {result:?}"
2508 );
2509 }
2510
2511 #[test]
2512 fn test_issue_638_lazy_continuation_does_not_terminate_ordered_list() {
2513 let rule = MD007ULIndent::new(2);
2519 let content = "1. ordered\nlazy continuation\n - child\n - grandchild\n";
2520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2521 let result = rule.check(&ctx).unwrap();
2522 assert!(
2523 result.is_empty(),
2524 "lazy continuation must not terminate the ordered list; sublist stays exempt, got: {result:?}"
2525 );
2526 }
2527
2528 #[test]
2529 fn test_issue_638_heading_interrupts_ordered_list_without_blank() {
2530 let rule = MD007ULIndent::new(2);
2537 let content = "1. ordered\n# heading\n - child\n - grandchild\n";
2538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2539 let result = rule.check(&ctx).unwrap();
2540 assert_eq!(
2541 result.len(),
2542 2,
2543 "a heading terminates the ordered list, so the new top-level list and its child are both checked, got: {result:?}"
2544 );
2545 assert!(
2546 result.iter().any(|w| w.line == 3 && w.message.contains("Expected 0")),
2547 "the misindented top-level item must be flagged with Expected 0, got: {result:?}"
2548 );
2549 assert!(
2550 result.iter().any(|w| w.line == 4 && w.message.contains("Expected 2")),
2551 "the misindented child must be flagged with Expected 2, got: {result:?}"
2552 );
2553 }
2554
2555 #[test]
2556 fn test_issue_638_lazy_continuation_inside_blockquote_keeps_exemption() {
2557 let rule = MD007ULIndent::new(2);
2562 let content = "> 1. ordered\n> continuation\n>\n> - child\n> - grandchild\n";
2563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2564 let result = rule.check(&ctx).unwrap();
2565 assert!(
2566 result.is_empty(),
2567 "a lazy continuation within the same blockquote must keep the sublist exempt, got: {result:?}"
2568 );
2569 }
2570
2571 #[test]
2572 fn test_issue_638_indented_fence_inside_blockquoted_ordered_item_keeps_exemption() {
2573 let rule = MD007ULIndent::new(2);
2578 let content = "> 1. ordered\n> ```\n> code\n> ```\n> - child\n> - grandchild\n";
2579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2580 let result = rule.check(&ctx).unwrap();
2581 assert!(
2582 result.is_empty(),
2583 "an indented fence inside a blockquoted ordered item must keep the sublist exempt, got: {result:?}"
2584 );
2585 }
2586
2587 #[test]
2588 fn test_issue_638_fenced_code_block_terminates_ordered_list() {
2589 let rule = MD007ULIndent::new(2);
2595 let content = "1. ordered\n```\ncode\n```\n\n - parent\n - child\n";
2596 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2597 let result = rule.check(&ctx).unwrap();
2598 assert!(
2599 result.iter().any(|w| w.line == 7),
2600 "a top-level fenced code block terminates the ordered list; the child must be flagged, got: {result:?}"
2601 );
2602 }
2603
2604 #[test]
2605 fn test_issue_638_fenced_code_block_inside_item_keeps_exemption() {
2606 let rule = MD007ULIndent::new(2);
2611 let content = "1. ordered\n ```\n code\n ```\n - child\n - grandchild\n";
2612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2613 let result = rule.check(&ctx).unwrap();
2614 assert!(
2615 result.is_empty(),
2616 "a fenced code block nested inside the item must keep the sublist exempt, got: {result:?}"
2617 );
2618 }
2619
2620 #[test]
2621 fn test_issue_638_blockquote_terminates_ordered_list() {
2622 let rule = MD007ULIndent::new(2);
2629 let content = "1. ordered\n> quote\n\n - parent\n - child\n";
2630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2631 let result = rule.check(&ctx).unwrap();
2632 assert!(
2633 result.iter().any(|w| w.line == 5),
2634 "blockquote terminates the ordered list, so the child must still be flagged, got: {result:?}"
2635 );
2636 }
2637
2638 #[test]
2639 fn test_issue_638_blockquote_inside_item_keeps_exemption() {
2640 let rule = MD007ULIndent::new(2);
2645 let content = "1. ordered\n > quote inside item\n - child\n - grandchild\n";
2646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2647 let result = rule.check(&ctx).unwrap();
2648 assert!(
2649 result.is_empty(),
2650 "a blockquote nested inside the item must keep the sublist exempt, got: {result:?}"
2651 );
2652 }
2653
2654 #[test]
2655 fn test_issue_638_exemption_requires_genuine_nesting_under_ordered() {
2656 let rule = MD007ULIndent::new(2);
2665 let content = "100. ordered\n - parent\n - child\n";
2666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2667 let result = rule.check(&ctx).unwrap();
2668 assert!(
2669 result.iter().any(|w| w.line == 3),
2670 "the child of a non-nested bullet must still be checked, not exempted; got: {result:?}"
2671 );
2672 }
2673
2674 #[test]
2675 fn test_issue_638_paragraph_after_fenced_code_closes_ordered_list() {
2676 let rule = MD007ULIndent::new(2);
2685 let content = "1. ordered\n ```\n code\n ```\nnot lazy text\n - parent\n - child\n";
2686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2687 let result = rule.check(&ctx).unwrap();
2688 assert!(
2689 result.iter().any(|w| w.line == 7),
2690 "fenced code is not paragraph text, so the list closes and the nested child must still be checked, not exempted; got: {result:?}"
2691 );
2692 }
2693
2694 #[test]
2695 fn test_issue_638_overlong_ordered_marker_is_lazy_continuation() {
2696 let rule = MD007ULIndent::new(2);
2702 let content = "1. ordered\n1234567890. this is continuation text\n - child\n - grandchild\n";
2703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2704 let result = rule.check(&ctx).unwrap();
2705 assert!(
2706 result.is_empty(),
2707 "an overlong digit run is not a valid ordered marker, so the list stays open and the nested bullets are exempt; got: {result:?}"
2708 );
2709 }
2710
2711 #[test]
2712 fn test_indented_top_level_list_item_is_flagged() {
2713 let rule = MD007ULIndent::new(2);
2719 for indent in 2..=3 {
2720 let pad = " ".repeat(indent);
2721 let content = format!("{pad}- parent\n{pad} - child\n");
2722 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2723 let result = rule.check(&ctx).unwrap();
2724 assert!(
2725 result.iter().any(|w| w.line == 1),
2726 "a top-level item indented {indent} spaces must be flagged (Expected 0); got: {result:?}"
2727 );
2728 }
2729 }
2730
2731 #[test]
2732 fn test_indented_code_block_bullet_is_not_a_list_item() {
2733 let rule = MD007ULIndent::new(2);
2736 let content = " - not a list, this is code\n";
2737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2738 let result = rule.check(&ctx).unwrap();
2739 assert!(
2740 result.is_empty(),
2741 "a 4-space-indented bullet is an indented code block, not a misindented list; got: {result:?}"
2742 );
2743 }
2744
2745 #[test]
2746 fn test_tab_indent_expands_to_four_column_tabstop() {
2747 let rule = MD007ULIndent::new(2);
2754 let content = "- a\n\t- b\n";
2755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2756 let result = rule.check(&ctx).unwrap();
2757 let warning = result
2758 .iter()
2759 .find(|w| w.line == 2)
2760 .expect("a tab-indented sublist at column 4 is over-indented for depth 1 and must be flagged");
2761 assert!(
2762 warning.message.contains("found 4"),
2763 "the tab must expand to the 4-column tab stop (found 4), not be counted as one character; got: {}",
2764 warning.message
2765 );
2766 }
2767
2768 #[test]
2769 fn test_tab_completing_two_space_indent_to_tabstop_is_accepted() {
2770 let rule = MD007ULIndent::new(2);
2776 let content = "- a\n - b\n \t- c\n";
2777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2778 let result = rule.check(&ctx).unwrap();
2779 assert!(
2780 result.is_empty(),
2781 "` \\t` expands to column 4, the correct depth-2 indent, so no MD007 warning is expected; got: {result:?}"
2782 );
2783 }
2784
2785 #[test]
2786 fn test_issue_638_html_comment_terminates_ordered_list() {
2787 let rule = MD007ULIndent::new(2);
2794 let content = "1. ordered\n<!-- comment -->\n\n - parent\n - child\n";
2795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2796 let result = rule.check(&ctx).unwrap();
2797 assert!(
2798 result.iter().any(|w| w.line == 5),
2799 "an HTML comment terminates the ordered list, so the child must still be flagged, got: {result:?}"
2800 );
2801 }
2802
2803 #[test]
2804 fn test_issue_638_blockquoted_list_item_terminates_ordered_list() {
2805 let rule = MD007ULIndent::new(2);
2813 let content = "1. ordered\n> - quote list\n\n - parent\n - child\n";
2814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2815 let result = rule.check(&ctx).unwrap();
2816 assert!(
2817 result.iter().any(|w| w.line == 5),
2818 "a blockquoted list item terminates the ordered list, so the child must still be flagged, got: {result:?}"
2819 );
2820 }
2821
2822 #[test]
2823 fn test_issue_638_deeper_nested_quote_terminates_blockquoted_ordered_list() {
2824 let rule = MD007ULIndent::new(2);
2834 let content = "> 1. ordered\n> > quote\n>\n> - parent\n> - child\n";
2835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2836 let result = rule.check(&ctx).unwrap();
2837 assert!(
2838 result.iter().any(|w| w.line == 4),
2839 "deeper nested quote closes the ordered list, so the misindented parent must be flagged, got: {result:?}"
2840 );
2841 assert!(
2842 result.iter().any(|w| w.line == 5),
2843 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
2844 );
2845 }
2846
2847 #[test]
2848 fn test_issue_638_deeper_quote_list_item_terminates_blockquoted_ordered_list() {
2849 let rule = MD007ULIndent::new(2);
2857 let content = "> 1. ordered\n> > - quote list\n>\n> - parent\n> - child\n";
2858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2859 let result = rule.check(&ctx).unwrap();
2860 assert!(
2861 result.iter().any(|w| w.line == 4),
2862 "a deeper-quote list item closes the ordered list, so the parent must be flagged, got: {result:?}"
2863 );
2864 assert!(
2865 result.iter().any(|w| w.line == 5),
2866 "the child of the fresh unordered list must be flagged, not exempted, got: {result:?}"
2867 );
2868 }
2869
2870 #[test]
2871 fn test_issue_638_deeper_quote_indented_into_item_keeps_exemption() {
2872 let rule = MD007ULIndent::new(2);
2877 let content = "> 1. ordered\n> > quote inside item\n> - child\n> - grandchild\n";
2878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2879 let result = rule.check(&ctx).unwrap();
2880 assert!(
2881 result.is_empty(),
2882 "a deeper quote indented into the item must keep the sublist exempt, got: {result:?}"
2883 );
2884 }
2885
2886 #[test]
2887 fn test_indent4_explicit_with_wide_ordered_parent() {
2888 let config = MD007Config {
2892 indent: crate::types::IndentSize::from_const(4),
2893 start_indented: false,
2894 start_indent: crate::types::IndentSize::from_const(2),
2895 style: md007_config::IndentStyle::TextAligned,
2896 style_explicit: false,
2897 indent_explicit: true,
2898 };
2899 let rule = MD007ULIndent::from_config_struct(config);
2900
2901 let content = "100. Wide ordered\n * Bullet at 5 spaces";
2903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2904 let result = rule.check(&ctx).unwrap();
2905 assert!(
2906 result.is_empty(),
2907 "indent=4 under '100.' should accept 5-space indent: {result:?}"
2908 );
2909
2910 let content_4 = "100. Wide ordered\n * Bullet at 4 spaces";
2912 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2913 let result = rule.check(&ctx).unwrap();
2914 assert!(
2915 result.is_empty(),
2916 "indent=4 under '100.' should accept 4-space indent: {result:?}"
2917 );
2918 }
2919}