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 calculate_expected_indent(
60 &self,
61 nesting_level: usize,
62 parent_info: Option<(bool, usize)>, ) -> usize {
64 if nesting_level == 0 {
65 return 0;
66 }
67
68 if self.config.style_explicit {
70 return match self.config.style {
71 md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
72 md007_config::IndentStyle::TextAligned => {
73 parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
74 }
75 };
76 }
77
78 if self.config.indent_explicit {
81 match parent_info {
82 Some((true, parent_content_col)) => {
83 return parent_content_col;
86 }
87 _ => {
88 return nesting_level * self.config.indent.get() as usize;
90 }
91 }
92 }
93
94 match parent_info {
96 Some((true, parent_content_col)) => {
97 parent_content_col
100 }
101 Some((false, parent_content_col)) => {
102 let parent_level = nesting_level.saturating_sub(1);
106 let expected_parent_marker = parent_level * self.config.indent.get() as usize;
107 let parent_marker_col = parent_content_col.saturating_sub(2);
109
110 if parent_marker_col == expected_parent_marker {
111 nesting_level * self.config.indent.get() as usize
113 } else {
114 parent_content_col
116 }
117 }
118 None => {
119 nesting_level * self.config.indent.get() as usize
121 }
122 }
123 }
124}
125
126impl Rule for MD007ULIndent {
127 fn name(&self) -> &'static str {
128 "MD007"
129 }
130
131 fn description(&self) -> &'static str {
132 "Unordered list indentation"
133 }
134
135 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
136 let mut warnings = Vec::new();
137 let mut list_stack: Vec<(usize, usize, bool, usize, usize)> = Vec::new(); for (line_idx, line_info) in ctx.lines.iter().enumerate() {
140 if line_info.in_code_block
142 || line_info.in_front_matter
143 || line_info.in_mkdocstrings
144 || line_info.in_footnote_definition
145 {
146 continue;
147 }
148
149 if let Some(list_item) = &line_info.list_item {
151 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
155 let line_content = line_info.content(ctx.content);
157 let mut remaining = line_content;
158 let mut content_start = 0;
159
160 loop {
161 let trimmed = remaining.trim_start();
162 if !trimmed.starts_with('>') {
163 break;
164 }
165 content_start += remaining.len() - trimmed.len();
167 content_start += 1;
169 let after_gt = &trimmed[1..];
170 if let Some(stripped) = after_gt.strip_prefix(' ') {
172 content_start += 1;
173 remaining = stripped;
174 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
175 content_start += 1;
176 remaining = stripped;
177 } else {
178 remaining = after_gt;
179 }
180 }
181
182 let content_after_prefix = &line_content[content_start..];
184 let adjusted_col = if list_item.marker_column >= content_start {
186 list_item.marker_column - content_start
187 } else {
188 list_item.marker_column
190 };
191 (content_after_prefix.to_string(), adjusted_col)
192 } else {
193 (line_info.content(ctx.content).to_string(), list_item.marker_column)
194 };
195
196 let visual_marker_column =
198 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
199
200 let visual_content_column = if line_info.blockquote.is_some() {
202 let adjusted_content_col =
204 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
205 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
206 } else {
207 list_item.content_column
208 };
209 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
210 } else {
211 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
212 };
213
214 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
218 0
219 } else {
220 visual_marker_column
221 };
222
223 let bq_depth = line_info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
225
226 while let Some(&(indent, _, _, _, item_bq_depth)) = list_stack.last() {
229 if item_bq_depth == bq_depth && indent >= visual_marker_for_nesting {
230 list_stack.pop();
231 } else if item_bq_depth > bq_depth {
232 list_stack.pop();
234 } else {
235 break;
236 }
237 }
238
239 if list_item.is_ordered {
241 list_stack.push((visual_marker_column, line_idx, true, visual_content_column, bq_depth));
244 continue;
245 }
246
247 let nesting_level = list_stack.iter().filter(|item| item.4 == bq_depth).count();
250
251 let parent_info = list_stack
253 .iter()
254 .rev()
255 .find(|item| item.4 == bq_depth)
256 .map(|&(_, _, is_ordered, content_col, _)| (is_ordered, content_col));
257
258 let mut expected_indent = if self.config.start_indented && nesting_level == 0 {
264 self.config.start_indent.get() as usize
265 } else {
266 self.calculate_expected_indent(nesting_level, parent_info)
267 };
268
269 let also_acceptable =
273 if self.config.indent_explicit && parent_info.is_some_and(|(is_ordered, _)| is_ordered) {
274 Some(nesting_level * self.config.indent.get() as usize)
275 } else {
276 None
277 };
278
279 if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
283 && let Some(&(parent_marker_col, _, true, _, _)) =
284 list_stack.iter().rev().find(|item| item.4 == bq_depth && item.2)
285 {
286 expected_indent = expected_indent.max(parent_marker_col + 4);
287 }
288
289 let accepted_indent = if also_acceptable.is_some_and(|alt| visual_marker_column == alt) {
295 visual_marker_column
296 } else {
297 expected_indent
298 };
299 let expected_content_visual_col = accepted_indent + 2;
300 list_stack.push((
301 visual_marker_column,
302 line_idx,
303 false,
304 expected_content_visual_col,
305 bq_depth,
306 ));
307
308 if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
311 continue;
312 }
313
314 if visual_marker_column != expected_indent && also_acceptable != Some(visual_marker_column) {
315 if let Some(alt) = also_acceptable {
317 expected_indent = alt;
318 }
319 let fix = {
321 let correct_indent = " ".repeat(expected_indent);
322
323 let replacement = if line_info.blockquote.is_some() {
326 let mut blockquote_count = 0;
328 for ch in line_info.content(ctx.content).chars() {
329 if ch == '>' {
330 blockquote_count += 1;
331 } else if ch != ' ' && ch != '\t' {
332 break;
333 }
334 }
335 let blockquote_prefix = if blockquote_count > 1 {
337 (0..blockquote_count)
338 .map(|_| "> ")
339 .collect::<String>()
340 .trim_end()
341 .to_string()
342 } else {
343 ">".to_string()
344 };
345 format!("{blockquote_prefix} {correct_indent}")
348 } else {
349 correct_indent
350 };
351
352 let start_byte = line_info.byte_offset;
355 let mut end_byte = line_info.byte_offset;
356
357 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
359 if i >= list_item.marker_column {
360 break;
361 }
362 end_byte += ch.len_utf8();
363 }
364
365 Some(crate::rule::Fix {
366 range: start_byte..end_byte,
367 replacement,
368 })
369 };
370
371 warnings.push(LintWarning {
372 rule_name: Some(self.name().to_string()),
373 message: format!(
374 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
375 ),
376 line: line_idx + 1, column: 1, end_line: line_idx + 1,
379 end_column: visual_marker_column + 1, severity: Severity::Warning,
381 fix,
382 });
383 }
384 }
385 }
386 Ok(warnings)
387 }
388
389 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
391 let warnings = self.check(ctx)?;
393 let warnings =
394 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
395
396 if warnings.is_empty() {
398 return Ok(ctx.content.to_string());
399 }
400
401 let mut fixes: Vec<_> = warnings
403 .iter()
404 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
405 .collect();
406 fixes.sort_by(|a, b| b.0.cmp(&a.0));
407
408 let mut result = ctx.content.to_string();
410 for (start, end, replacement) in fixes {
411 if start < result.len() && end <= result.len() && start <= end {
412 result.replace_range(start..end, replacement);
413 }
414 }
415
416 Ok(result)
417 }
418
419 fn category(&self) -> RuleCategory {
421 RuleCategory::List
422 }
423
424 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
426 if ctx.content.is_empty() || !ctx.likely_has_lists() {
428 return true;
429 }
430 !ctx.lines
432 .iter()
433 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
434 }
435
436 fn as_any(&self) -> &dyn std::any::Any {
437 self
438 }
439
440 fn default_config_section(&self) -> Option<(String, toml::Value)> {
441 let default_config = MD007Config::default();
442 let json_value = serde_json::to_value(&default_config).ok()?;
443 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
444
445 if let toml::Value::Table(table) = toml_value {
446 if !table.is_empty() {
447 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
448 } else {
449 None
450 }
451 } else {
452 None
453 }
454 }
455
456 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
457 where
458 Self: Sized,
459 {
460 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
461
462 if let Some(rule_cfg) = config.rules.get("MD007") {
464 rule_config.style_explicit = rule_cfg.values.contains_key("style");
465 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
466
467 if rule_config.indent_explicit
471 && rule_config.style_explicit
472 && rule_config.style == md007_config::IndentStyle::TextAligned
473 {
474 eprintln!(
475 "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
476 Text-aligned style ignores indent and aligns nested items with parent text. \
477 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
478 rule_config.indent.get()
479 );
480 }
481 }
482
483 if config.markdown_flavor() == crate::config::MarkdownFlavor::MkDocs {
486 if rule_config.indent_explicit && rule_config.indent.get() < 4 {
487 eprintln!(
488 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires indent >= 4 \
489 (Python-Markdown enforces 4-space indentation). \
490 Overriding indent={} to indent=4.",
491 rule_config.indent.get()
492 );
493 }
494 if rule_config.style_explicit && rule_config.style == md007_config::IndentStyle::TextAligned {
495 eprintln!(
496 "\x1b[33m[config warning]\x1b[0m MD007: MkDocs flavor requires style=\"fixed\" \
497 (Python-Markdown uses fixed 4-space indentation). \
498 Overriding style=\"text-aligned\" to style=\"fixed\"."
499 );
500 }
501 if rule_config.indent.get() < 4 {
502 rule_config.indent = crate::types::IndentSize::from_const(4);
503 }
504 rule_config.style = md007_config::IndentStyle::Fixed;
505 }
506
507 Box::new(Self::from_config_struct(rule_config))
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use crate::lint_context::LintContext;
515 use crate::rule::Rule;
516
517 #[test]
518 fn test_valid_list_indent() {
519 let rule = MD007ULIndent::default();
520 let content = "* Item 1\n * Item 2\n * Item 3";
521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522 let result = rule.check(&ctx).unwrap();
523 assert!(
524 result.is_empty(),
525 "Expected no warnings for valid indentation, but got {} warnings",
526 result.len()
527 );
528 }
529
530 #[test]
531 fn test_invalid_list_indent() {
532 let rule = MD007ULIndent::default();
533 let content = "* Item 1\n * Item 2\n * Item 3";
534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535 let result = rule.check(&ctx).unwrap();
536 assert_eq!(result.len(), 2);
537 assert_eq!(result[0].line, 2);
538 assert_eq!(result[0].column, 1);
539 assert_eq!(result[1].line, 3);
540 assert_eq!(result[1].column, 1);
541 }
542
543 #[test]
544 fn test_mixed_indentation() {
545 let rule = MD007ULIndent::default();
546 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548 let result = rule.check(&ctx).unwrap();
549 assert_eq!(result.len(), 1);
550 assert_eq!(result[0].line, 3);
551 assert_eq!(result[0].column, 1);
552 }
553
554 #[test]
555 fn test_fix_indentation() {
556 let rule = MD007ULIndent::default();
557 let content = "* Item 1\n * Item 2\n * Item 3";
558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
559 let result = rule.fix(&ctx).unwrap();
560 let expected = "* Item 1\n * Item 2\n * Item 3";
564 assert_eq!(result, expected);
565 }
566
567 #[test]
568 fn test_md007_in_yaml_code_block() {
569 let rule = MD007ULIndent::default();
570 let content = r#"```yaml
571repos:
572- repo: https://github.com/rvben/rumdl
573 rev: v0.5.0
574 hooks:
575 - id: rumdl-check
576```"#;
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let result = rule.check(&ctx).unwrap();
579 assert!(
580 result.is_empty(),
581 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
582 );
583 }
584
585 #[test]
586 fn test_blockquoted_list_indent() {
587 let rule = MD007ULIndent::default();
588 let content = "> * Item 1\n> * Item 2\n> * Item 3";
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590 let result = rule.check(&ctx).unwrap();
591 assert!(
592 result.is_empty(),
593 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
594 );
595 }
596
597 #[test]
598 fn test_blockquoted_list_invalid_indent() {
599 let rule = MD007ULIndent::default();
600 let content = "> * Item 1\n> * Item 2\n> * Item 3";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602 let result = rule.check(&ctx).unwrap();
603 assert_eq!(
604 result.len(),
605 2,
606 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
607 );
608 assert_eq!(result[0].line, 2);
609 assert_eq!(result[1].line, 3);
610 }
611
612 #[test]
613 fn test_nested_blockquote_list_indent() {
614 let rule = MD007ULIndent::default();
615 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617 let result = rule.check(&ctx).unwrap();
618 assert!(
619 result.is_empty(),
620 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
621 );
622 }
623
624 #[test]
625 fn test_blockquote_list_with_code_block() {
626 let rule = MD007ULIndent::default();
627 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let result = rule.check(&ctx).unwrap();
630 assert!(
631 result.is_empty(),
632 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
633 );
634 }
635
636 #[test]
637 fn test_properly_indented_lists() {
638 let rule = MD007ULIndent::default();
639
640 let test_cases = vec![
642 "* Item 1\n* Item 2",
643 "* Item 1\n * Item 1.1\n * Item 1.1.1",
644 "- Item 1\n - Item 1.1",
645 "+ Item 1\n + Item 1.1",
646 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
647 ];
648
649 for content in test_cases {
650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
651 let result = rule.check(&ctx).unwrap();
652 assert!(
653 result.is_empty(),
654 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
655 content,
656 result.len()
657 );
658 }
659 }
660
661 #[test]
662 fn test_under_indented_lists() {
663 let rule = MD007ULIndent::default();
664
665 let test_cases = vec![
666 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
669
670 for (content, expected_warnings, line) in test_cases {
671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672 let result = rule.check(&ctx).unwrap();
673 assert_eq!(
674 result.len(),
675 expected_warnings,
676 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
677 );
678 if expected_warnings > 0 {
679 assert_eq!(result[0].line, line);
680 }
681 }
682 }
683
684 #[test]
685 fn test_over_indented_lists() {
686 let rule = MD007ULIndent::default();
687
688 let test_cases = vec![
689 ("* 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), ];
693
694 for (content, expected_warnings, line) in test_cases {
695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696 let result = rule.check(&ctx).unwrap();
697 assert_eq!(
698 result.len(),
699 expected_warnings,
700 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
701 );
702 if expected_warnings > 0 {
703 assert_eq!(result[0].line, line);
704 }
705 }
706 }
707
708 #[test]
709 fn test_custom_indent_2_spaces() {
710 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let result = rule.check(&ctx).unwrap();
714 assert!(result.is_empty());
715 }
716
717 #[test]
718 fn test_custom_indent_3_spaces() {
719 let rule = MD007ULIndent::new(3);
722
723 let correct_content = "* Item 1\n * Item 2\n * Item 3";
725 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
726 let result = rule.check(&ctx).unwrap();
727 assert!(
728 result.is_empty(),
729 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
730 );
731
732 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
734 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
735 let result = rule.check(&ctx).unwrap();
736 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
737 }
738
739 #[test]
740 fn test_custom_indent_4_spaces() {
741 let rule = MD007ULIndent::new(4);
744
745 let correct_content = "* Item 1\n * Item 2\n * Item 3";
747 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
748 let result = rule.check(&ctx).unwrap();
749 assert!(
750 result.is_empty(),
751 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
752 );
753
754 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
756 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
759 }
760
761 #[test]
762 fn test_tab_indentation() {
763 let rule = MD007ULIndent::default();
764
765 let content = "* Item 1\n * Item 2";
771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 let result = rule.check(&ctx).unwrap();
773 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
774
775 let fixed = rule.fix(&ctx).unwrap();
777 assert_eq!(fixed, "* Item 1\n * Item 2");
778
779 let content_multi = "* Item 1\n * Item 2\n * Item 3";
781 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
782 let fixed = rule.fix(&ctx).unwrap();
783 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
786
787 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
789 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
790 let fixed = rule.fix(&ctx).unwrap();
791 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
794 }
795
796 #[test]
797 fn test_mixed_ordered_unordered_lists() {
798 let rule = MD007ULIndent::default();
799
800 let content = r#"1. Ordered item
803 * Unordered sub-item (correct - 3 spaces under ordered)
804 2. Ordered sub-item
805* Unordered item
806 1. Ordered sub-item
807 * Unordered sub-item"#;
808
809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
810 let result = rule.check(&ctx).unwrap();
811 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
812
813 let fixed = rule.fix(&ctx).unwrap();
815 assert_eq!(fixed, content);
816 }
817
818 #[test]
819 fn test_list_markers_variety() {
820 let rule = MD007ULIndent::default();
821
822 let content = r#"* Asterisk
824 * Nested asterisk
825- Hyphen
826 - Nested hyphen
827+ Plus
828 + Nested plus"#;
829
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832 assert!(
833 result.is_empty(),
834 "All unordered list markers should work with proper indentation"
835 );
836
837 let wrong_content = r#"* Asterisk
839 * Wrong asterisk
840- Hyphen
841 - Wrong hyphen
842+ Plus
843 + Wrong plus"#;
844
845 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
848 }
849
850 #[test]
851 fn test_empty_list_items() {
852 let rule = MD007ULIndent::default();
853 let content = "* Item 1\n* \n * Item 2";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let result = rule.check(&ctx).unwrap();
856 assert!(
857 result.is_empty(),
858 "Empty list items should not affect indentation checks"
859 );
860 }
861
862 #[test]
863 fn test_list_with_code_blocks() {
864 let rule = MD007ULIndent::default();
865 let content = r#"* Item 1
866 ```
867 code
868 ```
869 * Item 2
870 * Item 3"#;
871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
872 let result = rule.check(&ctx).unwrap();
873 assert!(result.is_empty());
874 }
875
876 #[test]
877 fn test_list_in_front_matter() {
878 let rule = MD007ULIndent::default();
879 let content = r#"---
880tags:
881 - tag1
882 - tag2
883---
884* Item 1
885 * Item 2"#;
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887 let result = rule.check(&ctx).unwrap();
888 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
889 }
890
891 #[test]
892 fn test_fix_preserves_content() {
893 let rule = MD007ULIndent::default();
894 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let fixed = rule.fix(&ctx).unwrap();
897 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
900 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
901 }
902
903 #[test]
904 fn test_start_indented_config() {
905 let config = MD007Config {
906 start_indented: true,
907 start_indent: crate::types::IndentSize::from_const(4),
908 indent: crate::types::IndentSize::from_const(2),
909 style: md007_config::IndentStyle::TextAligned,
910 style_explicit: true, indent_explicit: false,
912 };
913 let rule = MD007ULIndent::from_config_struct(config);
914
915 let content = " * Item 1\n * Item 2\n * Item 3";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921 let result = rule.check(&ctx).unwrap();
922 assert!(result.is_empty(), "Expected no warnings with start_indented config");
923
924 let wrong_content = " * Item 1\n * Item 2";
926 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
927 let result = rule.check(&ctx).unwrap();
928 assert_eq!(result.len(), 2);
929 assert_eq!(result[0].line, 1);
930 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
931 assert_eq!(result[1].line, 2);
932 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
933
934 let fixed = rule.fix(&ctx).unwrap();
936 assert_eq!(fixed, " * Item 1\n * Item 2");
937 }
938
939 #[test]
940 fn test_start_indented_false_allows_any_first_level() {
941 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
946 let result = rule.check(&ctx).unwrap();
947 assert!(
948 result.is_empty(),
949 "First level at any indentation should be allowed when start_indented is false"
950 );
951
952 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let result = rule.check(&ctx).unwrap();
956 assert!(
957 result.is_empty(),
958 "All first-level items should be allowed at any indentation"
959 );
960 }
961
962 #[test]
963 fn test_deeply_nested_lists() {
964 let rule = MD007ULIndent::default();
965 let content = r#"* L1
966 * L2
967 * L3
968 * L4
969 * L5
970 * L6"#;
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let result = rule.check(&ctx).unwrap();
973 assert!(result.is_empty());
974
975 let wrong_content = r#"* L1
977 * L2
978 * L3
979 * L4
980 * L5
981 * L6"#;
982 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
983 let result = rule.check(&ctx).unwrap();
984 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
985 }
986
987 #[test]
988 fn test_excessive_indentation_detected() {
989 let rule = MD007ULIndent::default();
990
991 let content = "- Item 1\n - Item 2 with 5 spaces";
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994 let result = rule.check(&ctx).unwrap();
995 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
996 assert_eq!(result[0].line, 2);
997 assert!(result[0].message.contains("Expected 2 spaces"));
998 assert!(result[0].message.contains("found 5"));
999
1000 let content = "- Item 1\n - Item 2 with 3 spaces";
1002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003 let result = rule.check(&ctx).unwrap();
1004 assert_eq!(
1005 result.len(),
1006 1,
1007 "Should detect slightly excessive indentation (3 instead of 2)"
1008 );
1009 assert_eq!(result[0].line, 2);
1010 assert!(result[0].message.contains("Expected 2 spaces"));
1011 assert!(result[0].message.contains("found 3"));
1012
1013 let content = "- Item 1\n - Item 2 with 1 space";
1015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016 let result = rule.check(&ctx).unwrap();
1017 assert_eq!(
1018 result.len(),
1019 1,
1020 "Should detect 1-space indent (insufficient for nesting, expected 0)"
1021 );
1022 assert_eq!(result[0].line, 2);
1023 assert!(result[0].message.contains("Expected 0 spaces"));
1024 assert!(result[0].message.contains("found 1"));
1025 }
1026
1027 #[test]
1028 fn test_excessive_indentation_with_4_space_config() {
1029 let rule = MD007ULIndent::new(4);
1032
1033 let content = "- Formatter:\n - The stable style changed";
1035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036 let result = rule.check(&ctx).unwrap();
1037 assert!(
1038 !result.is_empty(),
1039 "Should detect 5 spaces when expecting 4 (fixed style)"
1040 );
1041
1042 let correct_content = "- Formatter:\n - The stable style changed";
1044 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
1045 let result = rule.check(&ctx).unwrap();
1046 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
1047 }
1048
1049 #[test]
1050 fn test_bullets_nested_under_numbered_items() {
1051 let rule = MD007ULIndent::default();
1052 let content = "\
10531. **Active Directory/LDAP**
1054 - User authentication and directory services
1055 - LDAP for user information and validation
1056
10572. **Oracle Unified Directory (OUD)**
1058 - Extended user directory services";
1059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1060 let result = rule.check(&ctx).unwrap();
1061 assert!(
1063 result.is_empty(),
1064 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
1065 );
1066 }
1067
1068 #[test]
1069 fn test_bullets_nested_under_numbered_items_wrong_indent() {
1070 let rule = MD007ULIndent::default();
1071 let content = "\
10721. **Active Directory/LDAP**
1073 - Wrong: only 2 spaces";
1074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1075 let result = rule.check(&ctx).unwrap();
1076 assert_eq!(
1078 result.len(),
1079 1,
1080 "Expected warning for incorrect indentation under numbered items"
1081 );
1082 assert!(
1083 result
1084 .iter()
1085 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1086 );
1087 }
1088
1089 #[test]
1090 fn test_regular_bullet_nesting_still_works() {
1091 let rule = MD007ULIndent::default();
1092 let content = "\
1093* Top level
1094 * Nested bullet (2 spaces is correct)
1095 * Deeply nested (4 spaces)";
1096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1097 let result = rule.check(&ctx).unwrap();
1098 assert!(
1100 result.is_empty(),
1101 "Expected no warnings for standard bullet nesting, got: {result:?}"
1102 );
1103 }
1104
1105 #[test]
1106 fn test_blockquote_with_tab_after_marker() {
1107 let rule = MD007ULIndent::default();
1108 let content = ">\t* List item\n>\t * Nested\n";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let result = rule.check(&ctx).unwrap();
1111 assert!(
1112 result.is_empty(),
1113 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1114 );
1115 }
1116
1117 #[test]
1118 fn test_blockquote_with_space_then_tab_after_marker() {
1119 let rule = MD007ULIndent::default();
1120 let content = "> \t* List item\n";
1121 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1122 let result = rule.check(&ctx).unwrap();
1123 assert!(
1125 result.is_empty(),
1126 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_blockquote_with_multiple_tabs() {
1132 let rule = MD007ULIndent::default();
1133 let content = ">\t\t* List item\n";
1134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1135 let result = rule.check(&ctx).unwrap();
1136 assert!(
1138 result.is_empty(),
1139 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1140 );
1141 }
1142
1143 #[test]
1144 fn test_nested_blockquote_with_tab() {
1145 let rule = MD007ULIndent::default();
1146 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148 let result = rule.check(&ctx).unwrap();
1149 assert!(
1150 result.is_empty(),
1151 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1152 );
1153 }
1154
1155 #[test]
1158 fn test_smart_style_pure_unordered_uses_fixed() {
1159 let rule = MD007ULIndent::new(4);
1161
1162 let content = "* Level 0\n * Level 1\n * Level 2";
1164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1165 let result = rule.check(&ctx).unwrap();
1166 assert!(
1167 result.is_empty(),
1168 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1169 );
1170 }
1171
1172 #[test]
1173 fn test_smart_style_mixed_lists_uses_text_aligned() {
1174 let rule = MD007ULIndent::new(4);
1176
1177 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180 let result = rule.check(&ctx).unwrap();
1181 assert!(
1182 result.is_empty(),
1183 "Mixed lists should use text-aligned style, got: {result:?}"
1184 );
1185 }
1186
1187 #[test]
1188 fn test_smart_style_explicit_fixed_overrides() {
1189 let config = MD007Config {
1191 indent: crate::types::IndentSize::from_const(4),
1192 start_indented: false,
1193 start_indent: crate::types::IndentSize::from_const(2),
1194 style: md007_config::IndentStyle::Fixed,
1195 style_explicit: true, indent_explicit: false,
1197 };
1198 let rule = MD007ULIndent::from_config_struct(config);
1199
1200 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203 let result = rule.check(&ctx).unwrap();
1204 assert!(
1206 result.is_empty(),
1207 "Explicit fixed style should be respected, got: {result:?}"
1208 );
1209 }
1210
1211 #[test]
1212 fn test_smart_style_explicit_text_aligned_overrides() {
1213 let config = MD007Config {
1215 indent: crate::types::IndentSize::from_const(4),
1216 start_indented: false,
1217 start_indent: crate::types::IndentSize::from_const(2),
1218 style: md007_config::IndentStyle::TextAligned,
1219 style_explicit: true, indent_explicit: false,
1221 };
1222 let rule = MD007ULIndent::from_config_struct(config);
1223
1224 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1227 let result = rule.check(&ctx).unwrap();
1228 assert!(
1229 result.is_empty(),
1230 "Explicit text-aligned should be respected, got: {result:?}"
1231 );
1232
1233 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1235 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1236 let result = rule.check(&ctx).unwrap();
1237 assert!(
1238 !result.is_empty(),
1239 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1240 );
1241 }
1242
1243 #[test]
1244 fn test_smart_style_default_indent_no_autoswitch() {
1245 let rule = MD007ULIndent::new(2);
1247
1248 let content = "* Level 0\n * Level 1\n * Level 2";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 let result = rule.check(&ctx).unwrap();
1251 assert!(
1252 result.is_empty(),
1253 "Default indent should work regardless of style, got: {result:?}"
1254 );
1255 }
1256
1257 #[test]
1258 fn test_has_mixed_list_nesting_detection() {
1259 let content = "* Item 1\n * Item 2\n * Item 3";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264 assert!(
1265 !ctx.has_mixed_list_nesting(),
1266 "Pure unordered should not be detected as mixed"
1267 );
1268
1269 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272 assert!(
1273 !ctx.has_mixed_list_nesting(),
1274 "Pure ordered should not be detected as mixed"
1275 );
1276
1277 let content = "1. Ordered\n * Unordered child";
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 assert!(
1281 ctx.has_mixed_list_nesting(),
1282 "Unordered under ordered should be detected as mixed"
1283 );
1284
1285 let content = "* Unordered\n 1. Ordered child";
1287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1288 assert!(
1289 ctx.has_mixed_list_nesting(),
1290 "Ordered under unordered should be detected as mixed"
1291 );
1292
1293 let content = "* Unordered\n\n1. Ordered (separate list)";
1295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1296 assert!(
1297 !ctx.has_mixed_list_nesting(),
1298 "Separate lists should not be detected as mixed"
1299 );
1300
1301 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304 assert!(
1305 ctx.has_mixed_list_nesting(),
1306 "Mixed lists in blockquotes should be detected"
1307 );
1308 }
1309
1310 #[test]
1311 fn test_issue_210_exact_reproduction() {
1312 let config = MD007Config {
1314 indent: crate::types::IndentSize::from_const(4),
1315 start_indented: false,
1316 start_indent: crate::types::IndentSize::from_const(2),
1317 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1321 let rule = MD007ULIndent::from_config_struct(config);
1322
1323 let content = "# Title\n\n* some\n * list\n * items\n";
1324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325 let result = rule.check(&ctx).unwrap();
1326
1327 assert!(
1328 result.is_empty(),
1329 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1330 );
1331 }
1332
1333 #[test]
1334 fn test_issue_209_still_fixed() {
1335 let config = MD007Config {
1338 indent: crate::types::IndentSize::from_const(3),
1339 start_indented: false,
1340 start_indent: crate::types::IndentSize::from_const(2),
1341 style: md007_config::IndentStyle::TextAligned,
1342 style_explicit: true, indent_explicit: false,
1344 };
1345 let rule = MD007ULIndent::from_config_struct(config);
1346
1347 let content = r#"# Header 1
1349
1350- **Second item**:
1351 - **This is a nested list**:
1352 1. **First point**
1353 - First subpoint
1354"#;
1355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356 let result = rule.check(&ctx).unwrap();
1357
1358 assert!(
1359 result.is_empty(),
1360 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1361 );
1362 }
1363
1364 #[test]
1367 fn test_multi_level_mixed_detection_grandparent() {
1368 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1373 assert!(
1374 ctx.has_mixed_list_nesting(),
1375 "Should detect mixed nesting when grandparent differs in type"
1376 );
1377
1378 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1381 assert!(
1382 ctx.has_mixed_list_nesting(),
1383 "Should detect mixed nesting for ordered descendants under unordered"
1384 );
1385 }
1386
1387 #[test]
1388 fn test_html_comments_skipped_in_detection() {
1389 let content = r#"* Unordered list
1391<!-- This is a comment
1392 1. This ordered list is inside a comment
1393 * This nested bullet is also inside
1394-->
1395 * Another unordered item"#;
1396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1397 assert!(
1398 !ctx.has_mixed_list_nesting(),
1399 "Lists in HTML comments should be ignored in mixed detection"
1400 );
1401 }
1402
1403 #[test]
1404 fn test_blank_lines_separate_lists() {
1405 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 assert!(
1409 !ctx.has_mixed_list_nesting(),
1410 "Blank line at root should separate lists"
1411 );
1412
1413 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416 assert!(
1417 ctx.has_mixed_list_nesting(),
1418 "Indented list after blank is still nested"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_column_1_normalization() {
1424 let content = "* First item\n * Second item with 1 space (sibling)";
1427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1428 let rule = MD007ULIndent::default();
1429 let result = rule.check(&ctx).unwrap();
1430 assert!(
1432 result.iter().any(|w| w.line == 2),
1433 "1-space indent should be flagged as incorrect"
1434 );
1435 }
1436
1437 #[test]
1438 fn test_code_blocks_skipped_in_detection() {
1439 let content = r#"* Unordered list
1441```
14421. This ordered list is inside a code block
1443 * This nested bullet is also inside
1444```
1445 * Another unordered item"#;
1446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1447 assert!(
1448 !ctx.has_mixed_list_nesting(),
1449 "Lists in code blocks should be ignored in mixed detection"
1450 );
1451 }
1452
1453 #[test]
1454 fn test_front_matter_skipped_in_detection() {
1455 let content = r#"---
1457items:
1458 - yaml list item
1459 - another item
1460---
1461* Unordered list after front matter"#;
1462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463 assert!(
1464 !ctx.has_mixed_list_nesting(),
1465 "Lists in front matter should be ignored in mixed detection"
1466 );
1467 }
1468
1469 #[test]
1470 fn test_alternating_types_at_same_level() {
1471 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475 assert!(
1476 !ctx.has_mixed_list_nesting(),
1477 "Alternating types at same level should not be detected as mixed"
1478 );
1479 }
1480
1481 #[test]
1482 fn test_five_level_deep_mixed_nesting() {
1483 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1486 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1487 }
1488
1489 #[test]
1490 fn test_very_deep_pure_unordered_nesting() {
1491 let mut content = String::from("* L1");
1493 for level in 2..=12 {
1494 let indent = " ".repeat(level - 1);
1495 content.push_str(&format!("\n{indent}* L{level}"));
1496 }
1497
1498 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1499
1500 assert!(
1502 !ctx.has_mixed_list_nesting(),
1503 "Pure unordered deep nesting should not be detected as mixed"
1504 );
1505
1506 let rule = MD007ULIndent::new(4);
1508 let result = rule.check(&ctx).unwrap();
1509 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1512 }
1513
1514 #[test]
1515 fn test_interleaved_content_between_list_items() {
1516 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519 assert!(
1520 ctx.has_mixed_list_nesting(),
1521 "Should detect mixed nesting even with interleaved paragraphs"
1522 );
1523 }
1524
1525 #[test]
1526 fn test_esm_blocks_skipped_in_detection() {
1527 let content = "* Unordered list\n * Nested unordered";
1530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531 assert!(
1532 !ctx.has_mixed_list_nesting(),
1533 "Pure unordered should not be detected as mixed"
1534 );
1535 }
1536
1537 #[test]
1538 fn test_multiple_list_blocks_pure_then_mixed() {
1539 let content = r#"* Pure unordered
1542 * Nested unordered
1543
15441. Mixed section
1545 * Bullet under ordered"#;
1546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1547 assert!(
1548 ctx.has_mixed_list_nesting(),
1549 "Should detect mixed nesting in any part of document"
1550 );
1551 }
1552
1553 #[test]
1554 fn test_multiple_separate_pure_lists() {
1555 let content = r#"* First list
1558 * Nested
1559
1560* Second list
1561 * Also nested
1562
1563* Third list
1564 * Deeply
1565 * Nested"#;
1566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1567 assert!(
1568 !ctx.has_mixed_list_nesting(),
1569 "Multiple separate pure unordered lists should not be mixed"
1570 );
1571 }
1572
1573 #[test]
1574 fn test_code_block_between_list_items() {
1575 let content = r#"1. Ordered
1577 ```
1578 code
1579 ```
1580 * Still a mixed child"#;
1581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1582 assert!(
1583 ctx.has_mixed_list_nesting(),
1584 "Code block between items should not prevent mixed detection"
1585 );
1586 }
1587
1588 #[test]
1589 fn test_blockquoted_mixed_detection() {
1590 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1593 assert!(
1596 ctx.has_mixed_list_nesting(),
1597 "Should detect mixed nesting in blockquotes"
1598 );
1599 }
1600
1601 #[test]
1604 fn test_indent_explicit_uses_fixed_style() {
1605 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::TextAligned, style_explicit: false, indent_explicit: true, };
1615 let rule = MD007ULIndent::from_config_struct(config);
1616
1617 let content = "* Level 0\n * Level 1\n * Level 2";
1620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1621 let result = rule.check(&ctx).unwrap();
1622 assert!(
1623 result.is_empty(),
1624 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1625 );
1626
1627 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1629 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1630 let result = rule.check(&ctx).unwrap();
1631 assert!(
1632 !result.is_empty(),
1633 "Should flag text-aligned spacing when indent_explicit=true"
1634 );
1635 }
1636
1637 #[test]
1638 fn test_explicit_style_overrides_indent_explicit() {
1639 let config = MD007Config {
1642 indent: crate::types::IndentSize::from_const(4),
1643 start_indented: false,
1644 start_indent: crate::types::IndentSize::from_const(2),
1645 style: md007_config::IndentStyle::TextAligned,
1646 style_explicit: true, indent_explicit: true, };
1649 let rule = MD007ULIndent::from_config_struct(config);
1650
1651 let content = "* Level 0\n * Level 1\n * Level 2";
1653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654 let result = rule.check(&ctx).unwrap();
1655 assert!(
1656 result.is_empty(),
1657 "Explicit text-aligned style should be respected, got: {result:?}"
1658 );
1659 }
1660
1661 #[test]
1662 fn test_no_indent_explicit_uses_smart_detection() {
1663 let config = MD007Config {
1665 indent: crate::types::IndentSize::from_const(4),
1666 start_indented: false,
1667 start_indent: crate::types::IndentSize::from_const(2),
1668 style: md007_config::IndentStyle::TextAligned,
1669 style_explicit: false,
1670 indent_explicit: false, };
1672 let rule = MD007ULIndent::from_config_struct(config);
1673
1674 let content = "* Level 0\n * Level 1";
1677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1678 let result = rule.check(&ctx).unwrap();
1679 assert!(
1681 result.is_empty(),
1682 "Smart detection should accept 4-space indent, got: {result:?}"
1683 );
1684 }
1685
1686 #[test]
1687 fn test_issue_273_exact_reproduction() {
1688 let config = MD007Config {
1691 indent: crate::types::IndentSize::from_const(4),
1692 start_indented: false,
1693 start_indent: crate::types::IndentSize::from_const(2),
1694 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
1696 indent_explicit: true, };
1698 let rule = MD007ULIndent::from_config_struct(config);
1699
1700 let content = r#"* Item 1
1701 * Item 2
1702 * Item 3"#;
1703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1704 let result = rule.check(&ctx).unwrap();
1705 assert!(
1706 result.is_empty(),
1707 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
1708 );
1709 }
1710
1711 #[test]
1712 fn test_indent_explicit_with_ordered_parent() {
1713 let config = MD007Config {
1717 indent: crate::types::IndentSize::from_const(4),
1718 start_indented: false,
1719 start_indent: crate::types::IndentSize::from_const(2),
1720 style: md007_config::IndentStyle::TextAligned,
1721 style_explicit: false,
1722 indent_explicit: true, };
1724 let rule = MD007ULIndent::from_config_struct(config);
1725
1726 let content = "1. Ordered\n * Bullet with 4-space indent";
1728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1729 let result = rule.check(&ctx).unwrap();
1730 assert!(
1731 result.is_empty(),
1732 "4-space indent under ordered should pass with indent=4: {result:?}"
1733 );
1734
1735 let content_3 = "1. Ordered\n * Bullet with 3-space indent";
1737 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
1738 let result = rule.check(&ctx).unwrap();
1739 assert!(
1740 result.is_empty(),
1741 "3-space indent under ordered should pass (text-aligned): {result:?}"
1742 );
1743
1744 let wrong_content = "1. Ordered\n * Bullet with 2-space indent";
1746 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1747 let result = rule.check(&ctx).unwrap();
1748 assert!(
1749 !result.is_empty(),
1750 "2-space indent under ordered list should be flagged when indent=4: {result:?}"
1751 );
1752 }
1753
1754 #[test]
1755 fn test_indent_explicit_mixed_list_deep_nesting() {
1756 let config = MD007Config {
1761 indent: crate::types::IndentSize::from_const(4),
1762 start_indented: false,
1763 start_indent: crate::types::IndentSize::from_const(2),
1764 style: md007_config::IndentStyle::TextAligned,
1765 style_explicit: false,
1766 indent_explicit: true,
1767 };
1768 let rule = MD007ULIndent::from_config_struct(config);
1769
1770 let content_text_aligned = r#"* Level 0
1776 * Level 1 (4-space indent from bullet parent)
1777 1. Level 2 ordered
1778 * Level 3 bullet (text-aligned under ordered)"#;
1779 let ctx = LintContext::new(content_text_aligned, crate::config::MarkdownFlavor::Standard, None);
1780 let result = rule.check(&ctx).unwrap();
1781 assert!(
1782 result.is_empty(),
1783 "Text-aligned nesting under ordered should pass: {result:?}"
1784 );
1785
1786 let content_fixed = r#"* Level 0
1787 * Level 1 (4-space indent from bullet parent)
1788 1. Level 2 ordered
1789 * Level 3 bullet (fixed indent under ordered)"#;
1790 let ctx = LintContext::new(content_fixed, crate::config::MarkdownFlavor::Standard, None);
1791 let result = rule.check(&ctx).unwrap();
1792 assert!(
1793 result.is_empty(),
1794 "Fixed indent nesting under ordered should also pass: {result:?}"
1795 );
1796 }
1797
1798 #[test]
1799 fn test_ordered_list_double_digit_markers() {
1800 let config = MD007Config {
1803 indent: crate::types::IndentSize::from_const(4),
1804 start_indented: false,
1805 start_indent: crate::types::IndentSize::from_const(2),
1806 style: md007_config::IndentStyle::TextAligned,
1807 style_explicit: false,
1808 indent_explicit: true,
1809 };
1810 let rule = MD007ULIndent::from_config_struct(config);
1811
1812 let content = "10. Double digit\n * Bullet at col 4";
1814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1815 let result = rule.check(&ctx).unwrap();
1816 assert!(
1817 result.is_empty(),
1818 "Bullet under '10.' should align at column 4: {result:?}"
1819 );
1820
1821 let content_3 = "1. Single digit\n * Bullet at col 3";
1824 let ctx = LintContext::new(content_3, crate::config::MarkdownFlavor::Standard, None);
1825 let result = rule.check(&ctx).unwrap();
1826 assert!(
1827 result.is_empty(),
1828 "Bullet under '1.' with 3-space indent should pass (text-aligned): {result:?}"
1829 );
1830
1831 let content_4 = "1. Single digit\n * Bullet at col 4";
1832 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
1833 let result = rule.check(&ctx).unwrap();
1834 assert!(
1835 result.is_empty(),
1836 "Bullet under '1.' with 4-space indent should pass (fixed): {result:?}"
1837 );
1838 }
1839
1840 #[test]
1841 fn test_indent_explicit_pure_unordered_uses_fixed() {
1842 let config = MD007Config {
1845 indent: crate::types::IndentSize::from_const(4),
1846 start_indented: false,
1847 start_indent: crate::types::IndentSize::from_const(2),
1848 style: md007_config::IndentStyle::TextAligned,
1849 style_explicit: false,
1850 indent_explicit: true,
1851 };
1852 let rule = MD007ULIndent::from_config_struct(config);
1853
1854 let content = "* Level 0\n * Level 1\n * Level 2";
1856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1857 let result = rule.check(&ctx).unwrap();
1858 assert!(
1859 result.is_empty(),
1860 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
1861 );
1862
1863 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1865 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1866 let result = rule.check(&ctx).unwrap();
1867 assert!(
1868 !result.is_empty(),
1869 "2-space indent should be flagged when indent=4 is configured"
1870 );
1871 }
1872
1873 #[test]
1874 fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
1875 let rule = MD007ULIndent::default();
1879 let content = "1. text\n\n - nested item";
1880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1881 let result = rule.check(&ctx).unwrap();
1882 assert!(
1883 result.is_empty(),
1884 "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
1885 );
1886 }
1887
1888 #[test]
1889 fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
1890 let rule = MD007ULIndent::default();
1893 let content = "1. text\n\n - nested item";
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895 let result = rule.check(&ctx).unwrap();
1896 assert!(
1897 result.is_empty(),
1898 "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
1899 );
1900 }
1901
1902 #[test]
1903 fn test_standard_flavor_ordered_list_with_4_space_warns() {
1904 let rule = MD007ULIndent::default();
1907 let content = "1. text\n\n - nested item";
1908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1909 let result = rule.check(&ctx).unwrap();
1910 assert_eq!(
1911 result.len(),
1912 1,
1913 "4-space indent under ordered list should warn in Standard flavor"
1914 );
1915 }
1916
1917 #[test]
1918 fn test_mkdocs_multi_digit_ordered_list() {
1919 let rule = MD007ULIndent::default();
1922 let content = "10. text\n\n - nested item";
1923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1924 let result = rule.check(&ctx).unwrap();
1925 assert!(
1926 result.is_empty(),
1927 "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
1928 );
1929 }
1930
1931 #[test]
1932 fn test_mkdocs_triple_digit_ordered_list() {
1933 let rule = MD007ULIndent::default();
1936 let content = "100. text\n\n - nested item";
1937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1938 let result = rule.check(&ctx).unwrap();
1939 assert!(
1940 result.is_empty(),
1941 "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
1942 );
1943 }
1944
1945 #[test]
1946 fn test_mkdocs_insufficient_indent_under_ordered() {
1947 let rule = MD007ULIndent::default();
1950 let content = "1. text\n\n - nested item";
1951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1952 let result = rule.check(&ctx).unwrap();
1953 assert_eq!(
1954 result.len(),
1955 1,
1956 "2-space indent under ordered list should warn in MkDocs flavor"
1957 );
1958 assert!(
1959 result[0].message.contains("Expected 4"),
1960 "Warning should expect 4 spaces (MkDocs minimum), got: {}",
1961 result[0].message
1962 );
1963 }
1964
1965 #[test]
1966 fn test_mkdocs_deeper_nesting_under_ordered() {
1967 let rule = MD007ULIndent::default();
1972 let content = "1. text\n\n - sub\n - subsub";
1973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1974 let result = rule.check(&ctx).unwrap();
1975 assert!(
1976 result.is_empty(),
1977 "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
1978 );
1979 }
1980
1981 #[test]
1982 fn test_mkdocs_fix_adjusts_to_4_spaces() {
1983 let rule = MD007ULIndent::default();
1985 let content = "1. text\n\n - nested item";
1986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1987 let result = rule.check(&ctx).unwrap();
1988 assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
1989 let fixed = rule.fix(&ctx).unwrap();
1990 assert_eq!(
1991 fixed, "1. text\n\n - nested item",
1992 "Fix should adjust indent to 4 spaces in MkDocs"
1993 );
1994 }
1995
1996 #[test]
1997 fn test_mkdocs_start_indented_with_ordered_parent() {
1998 let config = MD007Config {
2001 start_indented: true,
2002 ..Default::default()
2003 };
2004 let rule = MD007ULIndent::from_config_struct(config);
2005 let content = "1. text\n\n - nested item";
2006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2007 let result = rule.check(&ctx).unwrap();
2008 assert!(
2009 result.is_empty(),
2010 "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
2011 );
2012 }
2013
2014 #[test]
2015 fn test_mkdocs_ordered_at_nonzero_indent() {
2016 let rule = MD007ULIndent::default();
2021 let content = "- outer\n 1. inner\n - deep";
2022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2023 let result = rule.check(&ctx).unwrap();
2024 assert!(
2025 result.is_empty(),
2026 "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
2027 );
2028 }
2029
2030 #[test]
2031 fn test_mkdocs_blockquoted_ordered_list() {
2032 let rule = MD007ULIndent::default();
2036 let content = "> 1. text\n>\n> - nested item";
2037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2038 let result = rule.check(&ctx).unwrap();
2039 assert!(
2040 result.is_empty(),
2041 "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
2042 );
2043 }
2044
2045 #[test]
2046 fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
2047 let rule = MD007ULIndent::default();
2050 let content = "- outer\n 1. inner\n - deep";
2051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2052 let result = rule.check(&ctx).unwrap();
2053 assert_eq!(
2054 result.len(),
2055 1,
2056 "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
2057 );
2058 }
2059
2060 #[test]
2061 fn test_issue_504_indent4_ordered_parent() {
2062 let config = MD007Config {
2066 indent: crate::types::IndentSize::from_const(4),
2067 start_indented: false,
2068 start_indent: crate::types::IndentSize::from_const(2),
2069 style: md007_config::IndentStyle::TextAligned,
2070 style_explicit: false,
2071 indent_explicit: true,
2072 };
2073 let rule = MD007ULIndent::from_config_struct(config);
2074
2075 let content = r#"# Things
2076
2077+ An unordered list
2078 + An item with 4 spaces, ok.
2079
20801. A numbered list
2081 + A sublist with 4 spaces, not ok
2082 + A sub item with 4 spaces, ok
2083 + Why is rumdl expecting 3 spaces for a 4 space indent?
20842. Item 2
20853. Item 3"#;
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 "Issue #504: indent=4 with ordered parent should accept 4-space indent: {result:?}"
2091 );
2092 }
2093
2094 #[test]
2095 fn test_indent2_explicit_with_ordered_parent() {
2096 let config = MD007Config {
2099 indent: crate::types::IndentSize::from_const(2),
2100 start_indented: false,
2101 start_indent: crate::types::IndentSize::from_const(2),
2102 style: md007_config::IndentStyle::TextAligned,
2103 style_explicit: false,
2104 indent_explicit: true,
2105 };
2106 let rule = MD007ULIndent::from_config_struct(config);
2107
2108 let content = "1. Ordered\n * Bullet at 3 spaces";
2110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2111 let result = rule.check(&ctx).unwrap();
2112 assert!(
2113 result.is_empty(),
2114 "indent=2 under '1.' should accept text-aligned (3 spaces): {result:?}"
2115 );
2116
2117 let content_2 = "1. Ordered\n * Bullet at 2 spaces";
2119 let ctx = LintContext::new(content_2, crate::config::MarkdownFlavor::Standard, None);
2120 let result = rule.check(&ctx).unwrap();
2121 assert!(
2122 result.is_empty(),
2123 "indent=2 under '1.' should accept fixed indent (2 spaces): {result:?}"
2124 );
2125 }
2126
2127 #[test]
2128 fn test_indent4_explicit_with_wide_ordered_parent() {
2129 let config = MD007Config {
2133 indent: crate::types::IndentSize::from_const(4),
2134 start_indented: false,
2135 start_indent: crate::types::IndentSize::from_const(2),
2136 style: md007_config::IndentStyle::TextAligned,
2137 style_explicit: false,
2138 indent_explicit: true,
2139 };
2140 let rule = MD007ULIndent::from_config_struct(config);
2141
2142 let content = "100. Wide ordered\n * Bullet at 5 spaces";
2144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2145 let result = rule.check(&ctx).unwrap();
2146 assert!(
2147 result.is_empty(),
2148 "indent=4 under '100.' should accept 5-space indent: {result:?}"
2149 );
2150
2151 let content_4 = "100. Wide ordered\n * Bullet at 4 spaces";
2153 let ctx = LintContext::new(content_4, crate::config::MarkdownFlavor::Standard, None);
2154 let result = rule.check(&ctx).unwrap();
2155 assert!(
2156 result.is_empty(),
2157 "indent=4 under '100.' should accept 4-space indent: {result:?}"
2158 );
2159 }
2160}