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\t* Item 2";
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658 let result = rule.check(&ctx).unwrap();
659 assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
660
661 let fixed = rule.fix(&ctx).unwrap();
663 assert_eq!(fixed, "* Item 1\n * Item 2");
664
665 let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
667 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
668 let fixed = rule.fix(&ctx).unwrap();
669 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
672
673 let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
675 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
676 let fixed = rule.fix(&ctx).unwrap();
677 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
680 }
681
682 #[test]
683 fn test_mixed_ordered_unordered_lists() {
684 let rule = MD007ULIndent::default();
685
686 let content = r#"1. Ordered item
689 * Unordered sub-item (correct - 3 spaces under ordered)
690 2. Ordered sub-item
691* Unordered item
692 1. Ordered sub-item
693 * Unordered sub-item"#;
694
695 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696 let result = rule.check(&ctx).unwrap();
697 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
698
699 let fixed = rule.fix(&ctx).unwrap();
701 assert_eq!(fixed, content);
702 }
703
704 #[test]
705 fn test_list_markers_variety() {
706 let rule = MD007ULIndent::default();
707
708 let content = r#"* Asterisk
710 * Nested asterisk
711- Hyphen
712 - Nested hyphen
713+ Plus
714 + Nested plus"#;
715
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let result = rule.check(&ctx).unwrap();
718 assert!(
719 result.is_empty(),
720 "All unordered list markers should work with proper indentation"
721 );
722
723 let wrong_content = r#"* Asterisk
725 * Wrong asterisk
726- Hyphen
727 - Wrong hyphen
728+ Plus
729 + Wrong plus"#;
730
731 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
732 let result = rule.check(&ctx).unwrap();
733 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
734 }
735
736 #[test]
737 fn test_empty_list_items() {
738 let rule = MD007ULIndent::default();
739 let content = "* Item 1\n* \n * Item 2";
740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let result = rule.check(&ctx).unwrap();
742 assert!(
743 result.is_empty(),
744 "Empty list items should not affect indentation checks"
745 );
746 }
747
748 #[test]
749 fn test_list_with_code_blocks() {
750 let rule = MD007ULIndent::default();
751 let content = r#"* Item 1
752 ```
753 code
754 ```
755 * Item 2
756 * Item 3"#;
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759 assert!(result.is_empty());
760 }
761
762 #[test]
763 fn test_list_in_front_matter() {
764 let rule = MD007ULIndent::default();
765 let content = r#"---
766tags:
767 - tag1
768 - tag2
769---
770* Item 1
771 * Item 2"#;
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.check(&ctx).unwrap();
774 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
775 }
776
777 #[test]
778 fn test_fix_preserves_content() {
779 let rule = MD007ULIndent::default();
780 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
781 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let fixed = rule.fix(&ctx).unwrap();
783 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
786 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
787 }
788
789 #[test]
790 fn test_start_indented_config() {
791 let config = MD007Config {
792 start_indented: true,
793 start_indent: crate::types::IndentSize::from_const(4),
794 indent: crate::types::IndentSize::from_const(2),
795 style: md007_config::IndentStyle::TextAligned,
796 style_explicit: true, };
798 let rule = MD007ULIndent::from_config_struct(config);
799
800 let content = " * Item 1\n * Item 2\n * Item 3";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806 let result = rule.check(&ctx).unwrap();
807 assert!(result.is_empty(), "Expected no warnings with start_indented config");
808
809 let wrong_content = " * Item 1\n * Item 2";
811 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
812 let result = rule.check(&ctx).unwrap();
813 assert_eq!(result.len(), 2);
814 assert_eq!(result[0].line, 1);
815 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
816 assert_eq!(result[1].line, 2);
817 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
818
819 let fixed = rule.fix(&ctx).unwrap();
821 assert_eq!(fixed, " * Item 1\n * Item 2");
822 }
823
824 #[test]
825 fn test_start_indented_false_allows_any_first_level() {
826 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832 assert!(
833 result.is_empty(),
834 "First level at any indentation should be allowed when start_indented is false"
835 );
836
837 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840 let result = rule.check(&ctx).unwrap();
841 assert!(
842 result.is_empty(),
843 "All first-level items should be allowed at any indentation"
844 );
845 }
846
847 #[test]
848 fn test_deeply_nested_lists() {
849 let rule = MD007ULIndent::default();
850 let content = r#"* L1
851 * L2
852 * L3
853 * L4
854 * L5
855 * L6"#;
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858 assert!(result.is_empty());
859
860 let wrong_content = r#"* L1
862 * L2
863 * L3
864 * L4
865 * L5
866 * L6"#;
867 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
868 let result = rule.check(&ctx).unwrap();
869 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
870 }
871
872 #[test]
873 fn test_excessive_indentation_detected() {
874 let rule = MD007ULIndent::default();
875
876 let content = "- Item 1\n - Item 2 with 5 spaces";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let result = rule.check(&ctx).unwrap();
880 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
881 assert_eq!(result[0].line, 2);
882 assert!(result[0].message.contains("Expected 2 spaces"));
883 assert!(result[0].message.contains("found 5"));
884
885 let content = "- Item 1\n - Item 2 with 3 spaces";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let result = rule.check(&ctx).unwrap();
889 assert_eq!(
890 result.len(),
891 1,
892 "Should detect slightly excessive indentation (3 instead of 2)"
893 );
894 assert_eq!(result[0].line, 2);
895 assert!(result[0].message.contains("Expected 2 spaces"));
896 assert!(result[0].message.contains("found 3"));
897
898 let content = "- Item 1\n - Item 2 with 1 space";
900 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901 let result = rule.check(&ctx).unwrap();
902 assert_eq!(
903 result.len(),
904 1,
905 "Should detect 1-space indent (insufficient for nesting, expected 0)"
906 );
907 assert_eq!(result[0].line, 2);
908 assert!(result[0].message.contains("Expected 0 spaces"));
909 assert!(result[0].message.contains("found 1"));
910 }
911
912 #[test]
913 fn test_excessive_indentation_with_4_space_config() {
914 let rule = MD007ULIndent::new(4);
917
918 let content = "- Formatter:\n - The stable style changed";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921 let result = rule.check(&ctx).unwrap();
922 assert!(
923 !result.is_empty(),
924 "Should detect 5 spaces when expecting 4 (fixed style)"
925 );
926
927 let correct_content = "- Formatter:\n - The stable style changed";
929 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
932 }
933
934 #[test]
935 fn test_bullets_nested_under_numbered_items() {
936 let rule = MD007ULIndent::default();
937 let content = "\
9381. **Active Directory/LDAP**
939 - User authentication and directory services
940 - LDAP for user information and validation
941
9422. **Oracle Unified Directory (OUD)**
943 - Extended user directory services";
944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945 let result = rule.check(&ctx).unwrap();
946 assert!(
948 result.is_empty(),
949 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
950 );
951 }
952
953 #[test]
954 fn test_bullets_nested_under_numbered_items_wrong_indent() {
955 let rule = MD007ULIndent::default();
956 let content = "\
9571. **Active Directory/LDAP**
958 - Wrong: only 2 spaces";
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.check(&ctx).unwrap();
961 assert_eq!(
963 result.len(),
964 1,
965 "Expected warning for incorrect indentation under numbered items"
966 );
967 assert!(
968 result
969 .iter()
970 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
971 );
972 }
973
974 #[test]
975 fn test_regular_bullet_nesting_still_works() {
976 let rule = MD007ULIndent::default();
977 let content = "\
978* Top level
979 * Nested bullet (2 spaces is correct)
980 * Deeply nested (4 spaces)";
981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982 let result = rule.check(&ctx).unwrap();
983 assert!(
985 result.is_empty(),
986 "Expected no warnings for standard bullet nesting, got: {result:?}"
987 );
988 }
989
990 #[test]
991 fn test_blockquote_with_tab_after_marker() {
992 let rule = MD007ULIndent::default();
993 let content = ">\t* List item\n>\t * Nested\n";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996 assert!(
997 result.is_empty(),
998 "Tab after blockquote marker should be handled correctly, got: {result:?}"
999 );
1000 }
1001
1002 #[test]
1003 fn test_blockquote_with_space_then_tab_after_marker() {
1004 let rule = MD007ULIndent::default();
1005 let content = "> \t* List item\n";
1006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1007 let result = rule.check(&ctx).unwrap();
1008 assert!(
1010 result.is_empty(),
1011 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1012 );
1013 }
1014
1015 #[test]
1016 fn test_blockquote_with_multiple_tabs() {
1017 let rule = MD007ULIndent::default();
1018 let content = ">\t\t* List item\n";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020 let result = rule.check(&ctx).unwrap();
1021 assert!(
1023 result.is_empty(),
1024 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1025 );
1026 }
1027
1028 #[test]
1029 fn test_nested_blockquote_with_tab() {
1030 let rule = MD007ULIndent::default();
1031 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033 let result = rule.check(&ctx).unwrap();
1034 assert!(
1035 result.is_empty(),
1036 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1037 );
1038 }
1039
1040 #[test]
1043 fn test_smart_style_pure_unordered_uses_fixed() {
1044 let rule = MD007ULIndent::new(4);
1046
1047 let content = "* Level 0\n * Level 1\n * Level 2";
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050 let result = rule.check(&ctx).unwrap();
1051 assert!(
1052 result.is_empty(),
1053 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1054 );
1055 }
1056
1057 #[test]
1058 fn test_smart_style_mixed_lists_uses_text_aligned() {
1059 let rule = MD007ULIndent::new(4);
1061
1062 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let result = rule.check(&ctx).unwrap();
1066 assert!(
1067 result.is_empty(),
1068 "Mixed lists should use text-aligned style, got: {result:?}"
1069 );
1070 }
1071
1072 #[test]
1073 fn test_smart_style_explicit_fixed_overrides() {
1074 let config = MD007Config {
1076 indent: crate::types::IndentSize::from_const(4),
1077 start_indented: false,
1078 start_indent: crate::types::IndentSize::from_const(2),
1079 style: md007_config::IndentStyle::Fixed,
1080 style_explicit: true, };
1082 let rule = MD007ULIndent::from_config_struct(config);
1083
1084 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087 let result = rule.check(&ctx).unwrap();
1088 assert!(
1090 result.is_empty(),
1091 "Explicit fixed style should be respected, got: {result:?}"
1092 );
1093 }
1094
1095 #[test]
1096 fn test_smart_style_explicit_text_aligned_overrides() {
1097 let config = MD007Config {
1099 indent: crate::types::IndentSize::from_const(4),
1100 start_indented: false,
1101 start_indent: crate::types::IndentSize::from_const(2),
1102 style: md007_config::IndentStyle::TextAligned,
1103 style_explicit: true, };
1105 let rule = MD007ULIndent::from_config_struct(config);
1106
1107 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
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 "Explicit text-aligned should be respected, got: {result:?}"
1114 );
1115
1116 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1118 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1119 let result = rule.check(&ctx).unwrap();
1120 assert!(
1121 !result.is_empty(),
1122 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1123 );
1124 }
1125
1126 #[test]
1127 fn test_smart_style_default_indent_no_autoswitch() {
1128 let rule = MD007ULIndent::new(2);
1130
1131 let content = "* Level 0\n * Level 1\n * Level 2";
1132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133 let result = rule.check(&ctx).unwrap();
1134 assert!(
1135 result.is_empty(),
1136 "Default indent should work regardless of style, got: {result:?}"
1137 );
1138 }
1139
1140 #[test]
1141 fn test_has_mixed_list_nesting_detection() {
1142 let content = "* Item 1\n * Item 2\n * Item 3";
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147 assert!(
1148 !ctx.has_mixed_list_nesting(),
1149 "Pure unordered should not be detected as mixed"
1150 );
1151
1152 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155 assert!(
1156 !ctx.has_mixed_list_nesting(),
1157 "Pure ordered should not be detected as mixed"
1158 );
1159
1160 let content = "1. Ordered\n * Unordered child";
1162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163 assert!(
1164 ctx.has_mixed_list_nesting(),
1165 "Unordered under ordered should be detected as mixed"
1166 );
1167
1168 let content = "* Unordered\n 1. Ordered child";
1170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1171 assert!(
1172 ctx.has_mixed_list_nesting(),
1173 "Ordered under unordered should be detected as mixed"
1174 );
1175
1176 let content = "* Unordered\n\n1. Ordered (separate list)";
1178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179 assert!(
1180 !ctx.has_mixed_list_nesting(),
1181 "Separate lists should not be detected as mixed"
1182 );
1183
1184 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1186 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 assert!(
1188 ctx.has_mixed_list_nesting(),
1189 "Mixed lists in blockquotes should be detected"
1190 );
1191 }
1192
1193 #[test]
1194 fn test_issue_210_exact_reproduction() {
1195 let config = MD007Config {
1197 indent: crate::types::IndentSize::from_const(4),
1198 start_indented: false,
1199 start_indent: crate::types::IndentSize::from_const(2),
1200 style: md007_config::IndentStyle::TextAligned, style_explicit: false, };
1203 let rule = MD007ULIndent::from_config_struct(config);
1204
1205 let content = "# Title\n\n* some\n * list\n * items\n";
1206 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1207 let result = rule.check(&ctx).unwrap();
1208
1209 assert!(
1210 result.is_empty(),
1211 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1212 );
1213 }
1214
1215 #[test]
1216 fn test_issue_209_still_fixed() {
1217 let config = MD007Config {
1220 indent: crate::types::IndentSize::from_const(3),
1221 start_indented: false,
1222 start_indent: crate::types::IndentSize::from_const(2),
1223 style: md007_config::IndentStyle::TextAligned,
1224 style_explicit: true, };
1226 let rule = MD007ULIndent::from_config_struct(config);
1227
1228 let content = r#"# Header 1
1230
1231- **Second item**:
1232 - **This is a nested list**:
1233 1. **First point**
1234 - First subpoint
1235"#;
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237 let result = rule.check(&ctx).unwrap();
1238
1239 assert!(
1240 result.is_empty(),
1241 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1242 );
1243 }
1244
1245 #[test]
1248 fn test_multi_level_mixed_detection_grandparent() {
1249 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 assert!(
1255 ctx.has_mixed_list_nesting(),
1256 "Should detect mixed nesting when grandparent differs in type"
1257 );
1258
1259 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1262 assert!(
1263 ctx.has_mixed_list_nesting(),
1264 "Should detect mixed nesting for ordered descendants under unordered"
1265 );
1266 }
1267
1268 #[test]
1269 fn test_html_comments_skipped_in_detection() {
1270 let content = r#"* Unordered list
1272<!-- This is a comment
1273 1. This ordered list is inside a comment
1274 * This nested bullet is also inside
1275-->
1276 * Another unordered item"#;
1277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278 assert!(
1279 !ctx.has_mixed_list_nesting(),
1280 "Lists in HTML comments should be ignored in mixed detection"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_blank_lines_separate_lists() {
1286 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289 assert!(
1290 !ctx.has_mixed_list_nesting(),
1291 "Blank line at root should separate lists"
1292 );
1293
1294 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297 assert!(
1298 ctx.has_mixed_list_nesting(),
1299 "Indented list after blank is still nested"
1300 );
1301 }
1302
1303 #[test]
1304 fn test_column_1_normalization() {
1305 let content = "* First item\n * Second item with 1 space (sibling)";
1308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309 let rule = MD007ULIndent::default();
1310 let result = rule.check(&ctx).unwrap();
1311 assert!(
1313 result.iter().any(|w| w.line == 2),
1314 "1-space indent should be flagged as incorrect"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_code_blocks_skipped_in_detection() {
1320 let content = r#"* Unordered list
1322```
13231. This ordered list is inside a code block
1324 * This nested bullet is also inside
1325```
1326 * Another unordered item"#;
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328 assert!(
1329 !ctx.has_mixed_list_nesting(),
1330 "Lists in code blocks should be ignored in mixed detection"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_front_matter_skipped_in_detection() {
1336 let content = r#"---
1338items:
1339 - yaml list item
1340 - another item
1341---
1342* Unordered list after front matter"#;
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 assert!(
1345 !ctx.has_mixed_list_nesting(),
1346 "Lists in front matter should be ignored in mixed detection"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_alternating_types_at_same_level() {
1352 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356 assert!(
1357 !ctx.has_mixed_list_nesting(),
1358 "Alternating types at same level should not be detected as mixed"
1359 );
1360 }
1361
1362 #[test]
1363 fn test_five_level_deep_mixed_nesting() {
1364 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1367 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1368 }
1369
1370 #[test]
1371 fn test_very_deep_pure_unordered_nesting() {
1372 let mut content = String::from("* L1");
1374 for level in 2..=12 {
1375 let indent = " ".repeat(level - 1);
1376 content.push_str(&format!("\n{indent}* L{level}"));
1377 }
1378
1379 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1380
1381 assert!(
1383 !ctx.has_mixed_list_nesting(),
1384 "Pure unordered deep nesting should not be detected as mixed"
1385 );
1386
1387 let rule = MD007ULIndent::new(4);
1389 let result = rule.check(&ctx).unwrap();
1390 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1393 }
1394
1395 #[test]
1396 fn test_interleaved_content_between_list_items() {
1397 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 assert!(
1401 ctx.has_mixed_list_nesting(),
1402 "Should detect mixed nesting even with interleaved paragraphs"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_esm_blocks_skipped_in_detection() {
1408 let content = "* Unordered list\n * Nested unordered";
1411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1412 assert!(
1413 !ctx.has_mixed_list_nesting(),
1414 "Pure unordered should not be detected as mixed"
1415 );
1416 }
1417
1418 #[test]
1419 fn test_multiple_list_blocks_pure_then_mixed() {
1420 let content = r#"* Pure unordered
1423 * Nested unordered
1424
14251. Mixed section
1426 * Bullet under ordered"#;
1427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1428 assert!(
1429 ctx.has_mixed_list_nesting(),
1430 "Should detect mixed nesting in any part of document"
1431 );
1432 }
1433
1434 #[test]
1435 fn test_multiple_separate_pure_lists() {
1436 let content = r#"* First list
1439 * Nested
1440
1441* Second list
1442 * Also nested
1443
1444* Third list
1445 * Deeply
1446 * Nested"#;
1447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448 assert!(
1449 !ctx.has_mixed_list_nesting(),
1450 "Multiple separate pure unordered lists should not be mixed"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_code_block_between_list_items() {
1456 let content = r#"1. Ordered
1458 ```
1459 code
1460 ```
1461 * Still a mixed child"#;
1462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463 assert!(
1464 ctx.has_mixed_list_nesting(),
1465 "Code block between items should not prevent mixed detection"
1466 );
1467 }
1468
1469 #[test]
1470 fn test_blockquoted_mixed_detection() {
1471 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474 assert!(
1477 ctx.has_mixed_list_nesting(),
1478 "Should detect mixed nesting in blockquotes"
1479 );
1480 }
1481}