1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6
7mod 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, },
25 }
26 }
27
28 pub fn from_config_struct(config: MD007Config) -> Self {
29 Self { config }
30 }
31
32 fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
34 let mut visual_col = 0;
35
36 for (current_pos, ch) in content.chars().enumerate() {
37 if current_pos >= char_pos {
38 break;
39 }
40 if ch == '\t' {
41 visual_col = (visual_col / 4 + 1) * 4;
43 } else {
44 visual_col += 1;
45 }
46 }
47 visual_col
48 }
49
50 fn calculate_expected_indent(
58 &self,
59 nesting_level: usize,
60 parent_info: Option<(bool, usize)>, ) -> usize {
62 if nesting_level == 0 {
63 return 0;
64 }
65
66 if self.config.style_explicit {
68 return match self.config.style {
69 md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
70 md007_config::IndentStyle::TextAligned => {
71 parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
72 }
73 };
74 }
75
76 match parent_info {
78 Some((true, parent_content_col)) => {
79 parent_content_col
82 }
83 Some((false, parent_content_col)) => {
84 let parent_level = nesting_level.saturating_sub(1);
88 let expected_parent_marker = parent_level * self.config.indent.get() as usize;
89 let parent_marker_col = parent_content_col.saturating_sub(2);
91
92 if parent_marker_col == expected_parent_marker {
93 nesting_level * self.config.indent.get() as usize
95 } else {
96 parent_content_col
98 }
99 }
100 None => {
101 nesting_level * self.config.indent.get() as usize
103 }
104 }
105 }
106}
107
108impl Rule for MD007ULIndent {
109 fn name(&self) -> &'static str {
110 "MD007"
111 }
112
113 fn description(&self) -> &'static str {
114 "Unordered list indentation"
115 }
116
117 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
118 let mut warnings = Vec::new();
119 let mut list_stack: Vec<(usize, usize, bool, usize)> = Vec::new(); for (line_idx, line_info) in ctx.lines.iter().enumerate() {
122 if line_info.in_code_block || line_info.in_front_matter || line_info.in_mkdocstrings {
124 continue;
125 }
126
127 if let Some(list_item) = &line_info.list_item {
129 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
133 let line_content = line_info.content(ctx.content);
135 let mut remaining = line_content;
136 let mut content_start = 0;
137
138 loop {
139 let trimmed = remaining.trim_start();
140 if !trimmed.starts_with('>') {
141 break;
142 }
143 content_start += remaining.len() - trimmed.len();
145 content_start += 1;
147 let after_gt = &trimmed[1..];
148 if let Some(stripped) = after_gt.strip_prefix(' ') {
150 content_start += 1;
151 remaining = stripped;
152 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
153 content_start += 1;
154 remaining = stripped;
155 } else {
156 remaining = after_gt;
157 }
158 }
159
160 let content_after_prefix = &line_content[content_start..];
162 let adjusted_col = if list_item.marker_column >= content_start {
164 list_item.marker_column - content_start
165 } else {
166 list_item.marker_column
168 };
169 (content_after_prefix.to_string(), adjusted_col)
170 } else {
171 (line_info.content(ctx.content).to_string(), list_item.marker_column)
172 };
173
174 let visual_marker_column =
176 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
177
178 let visual_content_column = if line_info.blockquote.is_some() {
180 let adjusted_content_col =
182 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
183 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
184 } else {
185 list_item.content_column
186 };
187 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
188 } else {
189 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
190 };
191
192 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
196 0
197 } else {
198 visual_marker_column
199 };
200
201 while let Some(&(indent, _, _, _)) = list_stack.last() {
203 if indent >= visual_marker_for_nesting {
204 list_stack.pop();
205 } else {
206 break;
207 }
208 }
209
210 if list_item.is_ordered {
212 list_stack.push((visual_marker_column, line_idx, true, visual_content_column));
215 continue;
216 }
217
218 let nesting_level = list_stack.len();
221
222 let parent_info = list_stack
224 .get(nesting_level.wrapping_sub(1))
225 .map(|&(_, _, is_ordered, content_col)| (is_ordered, content_col));
226
227 let expected_indent = if self.config.start_indented {
229 self.config.start_indent.get() as usize + (nesting_level * self.config.indent.get() as usize)
230 } else {
231 self.calculate_expected_indent(nesting_level, parent_info)
232 };
233
234 let expected_content_visual_col = expected_indent + 2; list_stack.push((visual_marker_column, line_idx, false, expected_content_visual_col));
240
241 if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
244 continue;
245 }
246
247 if visual_marker_column != expected_indent {
248 let fix = {
250 let correct_indent = " ".repeat(expected_indent);
251
252 let replacement = if line_info.blockquote.is_some() {
255 let mut blockquote_count = 0;
257 for ch in line_info.content(ctx.content).chars() {
258 if ch == '>' {
259 blockquote_count += 1;
260 } else if ch != ' ' && ch != '\t' {
261 break;
262 }
263 }
264 let blockquote_prefix = if blockquote_count > 1 {
266 (0..blockquote_count)
267 .map(|_| "> ")
268 .collect::<String>()
269 .trim_end()
270 .to_string()
271 } else {
272 ">".to_string()
273 };
274 format!("{blockquote_prefix} {correct_indent}")
277 } else {
278 correct_indent
279 };
280
281 let start_byte = line_info.byte_offset;
284 let mut end_byte = line_info.byte_offset;
285
286 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
288 if i >= list_item.marker_column {
289 break;
290 }
291 end_byte += ch.len_utf8();
292 }
293
294 Some(crate::rule::Fix {
295 range: start_byte..end_byte,
296 replacement,
297 })
298 };
299
300 warnings.push(LintWarning {
301 rule_name: Some(self.name().to_string()),
302 message: format!(
303 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
304 ),
305 line: line_idx + 1, column: 1, end_line: line_idx + 1,
308 end_column: visual_marker_column + 1, severity: Severity::Warning,
310 fix,
311 });
312 }
313 }
314 }
315 Ok(warnings)
316 }
317
318 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
320 let warnings = self.check(ctx)?;
322
323 if warnings.is_empty() {
325 return Ok(ctx.content.to_string());
326 }
327
328 let mut fixes: Vec<_> = warnings
330 .iter()
331 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
332 .collect();
333 fixes.sort_by(|a, b| b.0.cmp(&a.0));
334
335 let mut result = ctx.content.to_string();
337 for (start, end, replacement) in fixes {
338 if start < result.len() && end <= result.len() && start <= end {
339 result.replace_range(start..end, replacement);
340 }
341 }
342
343 Ok(result)
344 }
345
346 fn category(&self) -> RuleCategory {
348 RuleCategory::List
349 }
350
351 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
353 if ctx.content.is_empty() || !ctx.likely_has_lists() {
355 return true;
356 }
357 !ctx.lines
359 .iter()
360 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
361 }
362
363 fn as_any(&self) -> &dyn std::any::Any {
364 self
365 }
366
367 fn default_config_section(&self) -> Option<(String, toml::Value)> {
368 let default_config = MD007Config::default();
369 let json_value = serde_json::to_value(&default_config).ok()?;
370 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
371
372 if let toml::Value::Table(table) = toml_value {
373 if !table.is_empty() {
374 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
375 } else {
376 None
377 }
378 } else {
379 None
380 }
381 }
382
383 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
384 where
385 Self: Sized,
386 {
387 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
388
389 if let Some(rule_cfg) = config.rules.get("MD007") {
394 rule_config.style_explicit = rule_cfg.values.contains_key("style");
395 }
396
397 Box::new(Self::from_config_struct(rule_config))
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::lint_context::LintContext;
405 use crate::rule::Rule;
406
407 #[test]
408 fn test_valid_list_indent() {
409 let rule = MD007ULIndent::default();
410 let content = "* Item 1\n * Item 2\n * Item 3";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412 let result = rule.check(&ctx).unwrap();
413 assert!(
414 result.is_empty(),
415 "Expected no warnings for valid indentation, but got {} warnings",
416 result.len()
417 );
418 }
419
420 #[test]
421 fn test_invalid_list_indent() {
422 let rule = MD007ULIndent::default();
423 let content = "* Item 1\n * Item 2\n * Item 3";
424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
425 let result = rule.check(&ctx).unwrap();
426 assert_eq!(result.len(), 2);
427 assert_eq!(result[0].line, 2);
428 assert_eq!(result[0].column, 1);
429 assert_eq!(result[1].line, 3);
430 assert_eq!(result[1].column, 1);
431 }
432
433 #[test]
434 fn test_mixed_indentation() {
435 let rule = MD007ULIndent::default();
436 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
438 let result = rule.check(&ctx).unwrap();
439 assert_eq!(result.len(), 1);
440 assert_eq!(result[0].line, 3);
441 assert_eq!(result[0].column, 1);
442 }
443
444 #[test]
445 fn test_fix_indentation() {
446 let rule = MD007ULIndent::default();
447 let content = "* Item 1\n * Item 2\n * Item 3";
448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449 let result = rule.fix(&ctx).unwrap();
450 let expected = "* Item 1\n * Item 2\n * Item 3";
454 assert_eq!(result, expected);
455 }
456
457 #[test]
458 fn test_md007_in_yaml_code_block() {
459 let rule = MD007ULIndent::default();
460 let content = r#"```yaml
461repos:
462- repo: https://github.com/rvben/rumdl
463 rev: v0.5.0
464 hooks:
465 - id: rumdl-check
466```"#;
467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
468 let result = rule.check(&ctx).unwrap();
469 assert!(
470 result.is_empty(),
471 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
472 );
473 }
474
475 #[test]
476 fn test_blockquoted_list_indent() {
477 let rule = MD007ULIndent::default();
478 let content = "> * Item 1\n> * Item 2\n> * Item 3";
479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480 let result = rule.check(&ctx).unwrap();
481 assert!(
482 result.is_empty(),
483 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
484 );
485 }
486
487 #[test]
488 fn test_blockquoted_list_invalid_indent() {
489 let rule = MD007ULIndent::default();
490 let content = "> * Item 1\n> * Item 2\n> * Item 3";
491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
492 let result = rule.check(&ctx).unwrap();
493 assert_eq!(
494 result.len(),
495 2,
496 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
497 );
498 assert_eq!(result[0].line, 2);
499 assert_eq!(result[1].line, 3);
500 }
501
502 #[test]
503 fn test_nested_blockquote_list_indent() {
504 let rule = MD007ULIndent::default();
505 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507 let result = rule.check(&ctx).unwrap();
508 assert!(
509 result.is_empty(),
510 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
511 );
512 }
513
514 #[test]
515 fn test_blockquote_list_with_code_block() {
516 let rule = MD007ULIndent::default();
517 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519 let result = rule.check(&ctx).unwrap();
520 assert!(
521 result.is_empty(),
522 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
523 );
524 }
525
526 #[test]
527 fn test_properly_indented_lists() {
528 let rule = MD007ULIndent::default();
529
530 let test_cases = vec![
532 "* Item 1\n* Item 2",
533 "* Item 1\n * Item 1.1\n * Item 1.1.1",
534 "- Item 1\n - Item 1.1",
535 "+ Item 1\n + Item 1.1",
536 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
537 ];
538
539 for content in test_cases {
540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541 let result = rule.check(&ctx).unwrap();
542 assert!(
543 result.is_empty(),
544 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
545 content,
546 result.len()
547 );
548 }
549 }
550
551 #[test]
552 fn test_under_indented_lists() {
553 let rule = MD007ULIndent::default();
554
555 let test_cases = vec![
556 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
559
560 for (content, expected_warnings, line) in test_cases {
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert_eq!(
564 result.len(),
565 expected_warnings,
566 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
567 );
568 if expected_warnings > 0 {
569 assert_eq!(result[0].line, line);
570 }
571 }
572 }
573
574 #[test]
575 fn test_over_indented_lists() {
576 let rule = MD007ULIndent::default();
577
578 let test_cases = vec![
579 ("* 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), ];
583
584 for (content, expected_warnings, line) in test_cases {
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 let result = rule.check(&ctx).unwrap();
587 assert_eq!(
588 result.len(),
589 expected_warnings,
590 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
591 );
592 if expected_warnings > 0 {
593 assert_eq!(result[0].line, line);
594 }
595 }
596 }
597
598 #[test]
599 fn test_custom_indent_2_spaces() {
600 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let result = rule.check(&ctx).unwrap();
604 assert!(result.is_empty());
605 }
606
607 #[test]
608 fn test_custom_indent_3_spaces() {
609 let rule = MD007ULIndent::new(3);
612
613 let correct_content = "* Item 1\n * Item 2\n * Item 3";
615 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
616 let result = rule.check(&ctx).unwrap();
617 assert!(
618 result.is_empty(),
619 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
620 );
621
622 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
624 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
625 let result = rule.check(&ctx).unwrap();
626 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
627 }
628
629 #[test]
630 fn test_custom_indent_4_spaces() {
631 let rule = MD007ULIndent::new(4);
634
635 let correct_content = "* Item 1\n * Item 2\n * Item 3";
637 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
638 let result = rule.check(&ctx).unwrap();
639 assert!(
640 result.is_empty(),
641 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
642 );
643
644 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
646 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
647 let result = rule.check(&ctx).unwrap();
648 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
649 }
650
651 #[test]
652 fn test_tab_indentation() {
653 let rule = MD007ULIndent::default();
654
655 let content = "* Item 1\n * Item 2";
661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662 let result = rule.check(&ctx).unwrap();
663 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
664
665 let fixed = rule.fix(&ctx).unwrap();
667 assert_eq!(fixed, "* Item 1\n * Item 2");
668
669 let content_multi = "* Item 1\n * Item 2\n * Item 3";
671 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
672 let fixed = rule.fix(&ctx).unwrap();
673 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
676
677 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
679 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
680 let fixed = rule.fix(&ctx).unwrap();
681 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
684 }
685
686 #[test]
687 fn test_mixed_ordered_unordered_lists() {
688 let rule = MD007ULIndent::default();
689
690 let content = r#"1. Ordered item
693 * Unordered sub-item (correct - 3 spaces under ordered)
694 2. Ordered sub-item
695* Unordered item
696 1. Ordered sub-item
697 * Unordered sub-item"#;
698
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
701 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
702
703 let fixed = rule.fix(&ctx).unwrap();
705 assert_eq!(fixed, content);
706 }
707
708 #[test]
709 fn test_list_markers_variety() {
710 let rule = MD007ULIndent::default();
711
712 let content = r#"* Asterisk
714 * Nested asterisk
715- Hyphen
716 - Nested hyphen
717+ Plus
718 + Nested plus"#;
719
720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
721 let result = rule.check(&ctx).unwrap();
722 assert!(
723 result.is_empty(),
724 "All unordered list markers should work with proper indentation"
725 );
726
727 let wrong_content = r#"* Asterisk
729 * Wrong asterisk
730- Hyphen
731 - Wrong hyphen
732+ Plus
733 + Wrong plus"#;
734
735 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
736 let result = rule.check(&ctx).unwrap();
737 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
738 }
739
740 #[test]
741 fn test_empty_list_items() {
742 let rule = MD007ULIndent::default();
743 let content = "* Item 1\n* \n * Item 2";
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745 let result = rule.check(&ctx).unwrap();
746 assert!(
747 result.is_empty(),
748 "Empty list items should not affect indentation checks"
749 );
750 }
751
752 #[test]
753 fn test_list_with_code_blocks() {
754 let rule = MD007ULIndent::default();
755 let content = r#"* Item 1
756 ```
757 code
758 ```
759 * Item 2
760 * Item 3"#;
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763 assert!(result.is_empty());
764 }
765
766 #[test]
767 fn test_list_in_front_matter() {
768 let rule = MD007ULIndent::default();
769 let content = r#"---
770tags:
771 - tag1
772 - tag2
773---
774* Item 1
775 * Item 2"#;
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let result = rule.check(&ctx).unwrap();
778 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
779 }
780
781 #[test]
782 fn test_fix_preserves_content() {
783 let rule = MD007ULIndent::default();
784 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let fixed = rule.fix(&ctx).unwrap();
787 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
790 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
791 }
792
793 #[test]
794 fn test_start_indented_config() {
795 let config = MD007Config {
796 start_indented: true,
797 start_indent: crate::types::IndentSize::from_const(4),
798 indent: crate::types::IndentSize::from_const(2),
799 style: md007_config::IndentStyle::TextAligned,
800 style_explicit: true, };
802 let rule = MD007ULIndent::from_config_struct(config);
803
804 let content = " * Item 1\n * Item 2\n * Item 3";
809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
810 let result = rule.check(&ctx).unwrap();
811 assert!(result.is_empty(), "Expected no warnings with start_indented config");
812
813 let wrong_content = " * Item 1\n * Item 2";
815 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.check(&ctx).unwrap();
817 assert_eq!(result.len(), 2);
818 assert_eq!(result[0].line, 1);
819 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
820 assert_eq!(result[1].line, 2);
821 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
822
823 let fixed = rule.fix(&ctx).unwrap();
825 assert_eq!(fixed, " * Item 1\n * Item 2");
826 }
827
828 #[test]
829 fn test_start_indented_false_allows_any_first_level() {
830 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835 let result = rule.check(&ctx).unwrap();
836 assert!(
837 result.is_empty(),
838 "First level at any indentation should be allowed when start_indented is false"
839 );
840
841 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
844 let result = rule.check(&ctx).unwrap();
845 assert!(
846 result.is_empty(),
847 "All first-level items should be allowed at any indentation"
848 );
849 }
850
851 #[test]
852 fn test_deeply_nested_lists() {
853 let rule = MD007ULIndent::default();
854 let content = r#"* L1
855 * L2
856 * L3
857 * L4
858 * L5
859 * L6"#;
860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861 let result = rule.check(&ctx).unwrap();
862 assert!(result.is_empty());
863
864 let wrong_content = r#"* L1
866 * L2
867 * L3
868 * L4
869 * L5
870 * L6"#;
871 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
872 let result = rule.check(&ctx).unwrap();
873 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
874 }
875
876 #[test]
877 fn test_excessive_indentation_detected() {
878 let rule = MD007ULIndent::default();
879
880 let content = "- Item 1\n - Item 2 with 5 spaces";
882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883 let result = rule.check(&ctx).unwrap();
884 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
885 assert_eq!(result[0].line, 2);
886 assert!(result[0].message.contains("Expected 2 spaces"));
887 assert!(result[0].message.contains("found 5"));
888
889 let content = "- Item 1\n - Item 2 with 3 spaces";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892 let result = rule.check(&ctx).unwrap();
893 assert_eq!(
894 result.len(),
895 1,
896 "Should detect slightly excessive indentation (3 instead of 2)"
897 );
898 assert_eq!(result[0].line, 2);
899 assert!(result[0].message.contains("Expected 2 spaces"));
900 assert!(result[0].message.contains("found 3"));
901
902 let content = "- Item 1\n - Item 2 with 1 space";
904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905 let result = rule.check(&ctx).unwrap();
906 assert_eq!(
907 result.len(),
908 1,
909 "Should detect 1-space indent (insufficient for nesting, expected 0)"
910 );
911 assert_eq!(result[0].line, 2);
912 assert!(result[0].message.contains("Expected 0 spaces"));
913 assert!(result[0].message.contains("found 1"));
914 }
915
916 #[test]
917 fn test_excessive_indentation_with_4_space_config() {
918 let rule = MD007ULIndent::new(4);
921
922 let content = "- Formatter:\n - The stable style changed";
924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925 let result = rule.check(&ctx).unwrap();
926 assert!(
927 !result.is_empty(),
928 "Should detect 5 spaces when expecting 4 (fixed style)"
929 );
930
931 let correct_content = "- Formatter:\n - The stable style changed";
933 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
934 let result = rule.check(&ctx).unwrap();
935 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
936 }
937
938 #[test]
939 fn test_bullets_nested_under_numbered_items() {
940 let rule = MD007ULIndent::default();
941 let content = "\
9421. **Active Directory/LDAP**
943 - User authentication and directory services
944 - LDAP for user information and validation
945
9462. **Oracle Unified Directory (OUD)**
947 - Extended user directory services";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.check(&ctx).unwrap();
950 assert!(
952 result.is_empty(),
953 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
954 );
955 }
956
957 #[test]
958 fn test_bullets_nested_under_numbered_items_wrong_indent() {
959 let rule = MD007ULIndent::default();
960 let content = "\
9611. **Active Directory/LDAP**
962 - Wrong: only 2 spaces";
963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964 let result = rule.check(&ctx).unwrap();
965 assert_eq!(
967 result.len(),
968 1,
969 "Expected warning for incorrect indentation under numbered items"
970 );
971 assert!(
972 result
973 .iter()
974 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
975 );
976 }
977
978 #[test]
979 fn test_regular_bullet_nesting_still_works() {
980 let rule = MD007ULIndent::default();
981 let content = "\
982* Top level
983 * Nested bullet (2 spaces is correct)
984 * Deeply nested (4 spaces)";
985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
986 let result = rule.check(&ctx).unwrap();
987 assert!(
989 result.is_empty(),
990 "Expected no warnings for standard bullet nesting, got: {result:?}"
991 );
992 }
993
994 #[test]
995 fn test_blockquote_with_tab_after_marker() {
996 let rule = MD007ULIndent::default();
997 let content = ">\t* List item\n>\t * Nested\n";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert!(
1001 result.is_empty(),
1002 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1003 );
1004 }
1005
1006 #[test]
1007 fn test_blockquote_with_space_then_tab_after_marker() {
1008 let rule = MD007ULIndent::default();
1009 let content = "> \t* List item\n";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011 let result = rule.check(&ctx).unwrap();
1012 assert!(
1014 result.is_empty(),
1015 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1016 );
1017 }
1018
1019 #[test]
1020 fn test_blockquote_with_multiple_tabs() {
1021 let rule = MD007ULIndent::default();
1022 let content = ">\t\t* List item\n";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024 let result = rule.check(&ctx).unwrap();
1025 assert!(
1027 result.is_empty(),
1028 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1029 );
1030 }
1031
1032 #[test]
1033 fn test_nested_blockquote_with_tab() {
1034 let rule = MD007ULIndent::default();
1035 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037 let result = rule.check(&ctx).unwrap();
1038 assert!(
1039 result.is_empty(),
1040 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1041 );
1042 }
1043
1044 #[test]
1047 fn test_smart_style_pure_unordered_uses_fixed() {
1048 let rule = MD007ULIndent::new(4);
1050
1051 let content = "* Level 0\n * Level 1\n * Level 2";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054 let result = rule.check(&ctx).unwrap();
1055 assert!(
1056 result.is_empty(),
1057 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1058 );
1059 }
1060
1061 #[test]
1062 fn test_smart_style_mixed_lists_uses_text_aligned() {
1063 let rule = MD007ULIndent::new(4);
1065
1066 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1068 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1069 let result = rule.check(&ctx).unwrap();
1070 assert!(
1071 result.is_empty(),
1072 "Mixed lists should use text-aligned style, got: {result:?}"
1073 );
1074 }
1075
1076 #[test]
1077 fn test_smart_style_explicit_fixed_overrides() {
1078 let config = MD007Config {
1080 indent: crate::types::IndentSize::from_const(4),
1081 start_indented: false,
1082 start_indent: crate::types::IndentSize::from_const(2),
1083 style: md007_config::IndentStyle::Fixed,
1084 style_explicit: true, };
1086 let rule = MD007ULIndent::from_config_struct(config);
1087
1088 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1090 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1091 let result = rule.check(&ctx).unwrap();
1092 assert!(
1094 result.is_empty(),
1095 "Explicit fixed style should be respected, got: {result:?}"
1096 );
1097 }
1098
1099 #[test]
1100 fn test_smart_style_explicit_text_aligned_overrides() {
1101 let config = MD007Config {
1103 indent: crate::types::IndentSize::from_const(4),
1104 start_indented: false,
1105 start_indent: crate::types::IndentSize::from_const(2),
1106 style: md007_config::IndentStyle::TextAligned,
1107 style_explicit: true, };
1109 let rule = MD007ULIndent::from_config_struct(config);
1110
1111 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1113 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1114 let result = rule.check(&ctx).unwrap();
1115 assert!(
1116 result.is_empty(),
1117 "Explicit text-aligned should be respected, got: {result:?}"
1118 );
1119
1120 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1122 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1123 let result = rule.check(&ctx).unwrap();
1124 assert!(
1125 !result.is_empty(),
1126 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_smart_style_default_indent_no_autoswitch() {
1132 let rule = MD007ULIndent::new(2);
1134
1135 let content = "* Level 0\n * Level 1\n * Level 2";
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1137 let result = rule.check(&ctx).unwrap();
1138 assert!(
1139 result.is_empty(),
1140 "Default indent should work regardless of style, got: {result:?}"
1141 );
1142 }
1143
1144 #[test]
1145 fn test_has_mixed_list_nesting_detection() {
1146 let content = "* Item 1\n * Item 2\n * Item 3";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151 assert!(
1152 !ctx.has_mixed_list_nesting(),
1153 "Pure unordered should not be detected as mixed"
1154 );
1155
1156 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159 assert!(
1160 !ctx.has_mixed_list_nesting(),
1161 "Pure ordered should not be detected as mixed"
1162 );
1163
1164 let content = "1. Ordered\n * Unordered child";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 assert!(
1168 ctx.has_mixed_list_nesting(),
1169 "Unordered under ordered should be detected as mixed"
1170 );
1171
1172 let content = "* Unordered\n 1. Ordered child";
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175 assert!(
1176 ctx.has_mixed_list_nesting(),
1177 "Ordered under unordered should be detected as mixed"
1178 );
1179
1180 let content = "* Unordered\n\n1. Ordered (separate list)";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183 assert!(
1184 !ctx.has_mixed_list_nesting(),
1185 "Separate lists should not be detected as mixed"
1186 );
1187
1188 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1191 assert!(
1192 ctx.has_mixed_list_nesting(),
1193 "Mixed lists in blockquotes should be detected"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_issue_210_exact_reproduction() {
1199 let config = MD007Config {
1201 indent: crate::types::IndentSize::from_const(4),
1202 start_indented: false,
1203 start_indent: crate::types::IndentSize::from_const(2),
1204 style: md007_config::IndentStyle::TextAligned, style_explicit: false, };
1207 let rule = MD007ULIndent::from_config_struct(config);
1208
1209 let content = "# Title\n\n* some\n * list\n * items\n";
1210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211 let result = rule.check(&ctx).unwrap();
1212
1213 assert!(
1214 result.is_empty(),
1215 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1216 );
1217 }
1218
1219 #[test]
1220 fn test_issue_209_still_fixed() {
1221 let config = MD007Config {
1224 indent: crate::types::IndentSize::from_const(3),
1225 start_indented: false,
1226 start_indent: crate::types::IndentSize::from_const(2),
1227 style: md007_config::IndentStyle::TextAligned,
1228 style_explicit: true, };
1230 let rule = MD007ULIndent::from_config_struct(config);
1231
1232 let content = r#"# Header 1
1234
1235- **Second item**:
1236 - **This is a nested list**:
1237 1. **First point**
1238 - First subpoint
1239"#;
1240 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1241 let result = rule.check(&ctx).unwrap();
1242
1243 assert!(
1244 result.is_empty(),
1245 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1246 );
1247 }
1248
1249 #[test]
1252 fn test_multi_level_mixed_detection_grandparent() {
1253 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 assert!(
1259 ctx.has_mixed_list_nesting(),
1260 "Should detect mixed nesting when grandparent differs in type"
1261 );
1262
1263 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 assert!(
1267 ctx.has_mixed_list_nesting(),
1268 "Should detect mixed nesting for ordered descendants under unordered"
1269 );
1270 }
1271
1272 #[test]
1273 fn test_html_comments_skipped_in_detection() {
1274 let content = r#"* Unordered list
1276<!-- This is a comment
1277 1. This ordered list is inside a comment
1278 * This nested bullet is also inside
1279-->
1280 * Another unordered item"#;
1281 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1282 assert!(
1283 !ctx.has_mixed_list_nesting(),
1284 "Lists in HTML comments should be ignored in mixed detection"
1285 );
1286 }
1287
1288 #[test]
1289 fn test_blank_lines_separate_lists() {
1290 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 assert!(
1294 !ctx.has_mixed_list_nesting(),
1295 "Blank line at root should separate lists"
1296 );
1297
1298 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1301 assert!(
1302 ctx.has_mixed_list_nesting(),
1303 "Indented list after blank is still nested"
1304 );
1305 }
1306
1307 #[test]
1308 fn test_column_1_normalization() {
1309 let content = "* First item\n * Second item with 1 space (sibling)";
1312 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1313 let rule = MD007ULIndent::default();
1314 let result = rule.check(&ctx).unwrap();
1315 assert!(
1317 result.iter().any(|w| w.line == 2),
1318 "1-space indent should be flagged as incorrect"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_code_blocks_skipped_in_detection() {
1324 let content = r#"* Unordered list
1326```
13271. This ordered list is inside a code block
1328 * This nested bullet is also inside
1329```
1330 * Another unordered item"#;
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332 assert!(
1333 !ctx.has_mixed_list_nesting(),
1334 "Lists in code blocks should be ignored in mixed detection"
1335 );
1336 }
1337
1338 #[test]
1339 fn test_front_matter_skipped_in_detection() {
1340 let content = r#"---
1342items:
1343 - yaml list item
1344 - another item
1345---
1346* Unordered list after front matter"#;
1347 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1348 assert!(
1349 !ctx.has_mixed_list_nesting(),
1350 "Lists in front matter should be ignored in mixed detection"
1351 );
1352 }
1353
1354 #[test]
1355 fn test_alternating_types_at_same_level() {
1356 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1360 assert!(
1361 !ctx.has_mixed_list_nesting(),
1362 "Alternating types at same level should not be detected as mixed"
1363 );
1364 }
1365
1366 #[test]
1367 fn test_five_level_deep_mixed_nesting() {
1368 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1371 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1372 }
1373
1374 #[test]
1375 fn test_very_deep_pure_unordered_nesting() {
1376 let mut content = String::from("* L1");
1378 for level in 2..=12 {
1379 let indent = " ".repeat(level - 1);
1380 content.push_str(&format!("\n{indent}* L{level}"));
1381 }
1382
1383 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1384
1385 assert!(
1387 !ctx.has_mixed_list_nesting(),
1388 "Pure unordered deep nesting should not be detected as mixed"
1389 );
1390
1391 let rule = MD007ULIndent::new(4);
1393 let result = rule.check(&ctx).unwrap();
1394 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1397 }
1398
1399 #[test]
1400 fn test_interleaved_content_between_list_items() {
1401 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1404 assert!(
1405 ctx.has_mixed_list_nesting(),
1406 "Should detect mixed nesting even with interleaved paragraphs"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_esm_blocks_skipped_in_detection() {
1412 let content = "* Unordered list\n * Nested unordered";
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416 assert!(
1417 !ctx.has_mixed_list_nesting(),
1418 "Pure unordered should not be detected as mixed"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_multiple_list_blocks_pure_then_mixed() {
1424 let content = r#"* Pure unordered
1427 * Nested unordered
1428
14291. Mixed section
1430 * Bullet under ordered"#;
1431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432 assert!(
1433 ctx.has_mixed_list_nesting(),
1434 "Should detect mixed nesting in any part of document"
1435 );
1436 }
1437
1438 #[test]
1439 fn test_multiple_separate_pure_lists() {
1440 let content = r#"* First list
1443 * Nested
1444
1445* Second list
1446 * Also nested
1447
1448* Third list
1449 * Deeply
1450 * Nested"#;
1451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1452 assert!(
1453 !ctx.has_mixed_list_nesting(),
1454 "Multiple separate pure unordered lists should not be mixed"
1455 );
1456 }
1457
1458 #[test]
1459 fn test_code_block_between_list_items() {
1460 let content = r#"1. Ordered
1462 ```
1463 code
1464 ```
1465 * Still a mixed child"#;
1466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1467 assert!(
1468 ctx.has_mixed_list_nesting(),
1469 "Code block between items should not prevent mixed detection"
1470 );
1471 }
1472
1473 #[test]
1474 fn test_blockquoted_mixed_detection() {
1475 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1478 assert!(
1481 ctx.has_mixed_list_nesting(),
1482 "Should detect mixed nesting in blockquotes"
1483 );
1484 }
1485}