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, _)) => {
84 nesting_level * self.config.indent.get() as usize
87 }
88 None => {
89 nesting_level * self.config.indent.get() as usize
91 }
92 }
93 }
94}
95
96impl Rule for MD007ULIndent {
97 fn name(&self) -> &'static str {
98 "MD007"
99 }
100
101 fn description(&self) -> &'static str {
102 "Unordered list indentation"
103 }
104
105 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
106 let mut warnings = Vec::new();
107 let mut list_stack: Vec<(usize, usize, bool, usize)> = Vec::new(); for (line_idx, line_info) in ctx.lines.iter().enumerate() {
110 if line_info.in_code_block || line_info.in_front_matter || line_info.in_mkdocstrings {
112 continue;
113 }
114
115 if let Some(list_item) = &line_info.list_item {
117 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
121 let line_content = line_info.content(ctx.content);
123 let mut remaining = line_content;
124 let mut content_start = 0;
125
126 loop {
127 let trimmed = remaining.trim_start();
128 if !trimmed.starts_with('>') {
129 break;
130 }
131 content_start += remaining.len() - trimmed.len();
133 content_start += 1;
135 let after_gt = &trimmed[1..];
136 if let Some(stripped) = after_gt.strip_prefix(' ') {
138 content_start += 1;
139 remaining = stripped;
140 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
141 content_start += 1;
142 remaining = stripped;
143 } else {
144 remaining = after_gt;
145 }
146 }
147
148 let content_after_prefix = &line_content[content_start..];
150 let adjusted_col = if list_item.marker_column >= content_start {
152 list_item.marker_column - content_start
153 } else {
154 list_item.marker_column
156 };
157 (content_after_prefix.to_string(), adjusted_col)
158 } else {
159 (line_info.content(ctx.content).to_string(), list_item.marker_column)
160 };
161
162 let visual_marker_column =
164 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
165
166 let visual_content_column = if line_info.blockquote.is_some() {
168 let adjusted_content_col =
170 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
171 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
172 } else {
173 list_item.content_column
174 };
175 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
176 } else {
177 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
178 };
179
180 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
184 0
185 } else {
186 visual_marker_column
187 };
188
189 while let Some(&(indent, _, _, _)) = list_stack.last() {
191 if indent >= visual_marker_for_nesting {
192 list_stack.pop();
193 } else {
194 break;
195 }
196 }
197
198 if list_item.is_ordered {
200 list_stack.push((visual_marker_column, line_idx, true, visual_content_column));
203 continue;
204 }
205
206 let nesting_level = list_stack.len();
209
210 let parent_info = list_stack
212 .get(nesting_level.wrapping_sub(1))
213 .map(|&(_, _, is_ordered, content_col)| (is_ordered, content_col));
214
215 let expected_indent = if self.config.start_indented {
217 self.config.start_indent.get() as usize + (nesting_level * self.config.indent.get() as usize)
218 } else {
219 self.calculate_expected_indent(nesting_level, parent_info)
220 };
221
222 let expected_content_visual_col = expected_indent + 2; list_stack.push((visual_marker_column, line_idx, false, expected_content_visual_col));
228
229 if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
232 continue;
233 }
234
235 if visual_marker_column != expected_indent {
236 let fix = {
238 let correct_indent = " ".repeat(expected_indent);
239
240 let replacement = if line_info.blockquote.is_some() {
243 let mut blockquote_count = 0;
245 for ch in line_info.content(ctx.content).chars() {
246 if ch == '>' {
247 blockquote_count += 1;
248 } else if ch != ' ' && ch != '\t' {
249 break;
250 }
251 }
252 let blockquote_prefix = if blockquote_count > 1 {
254 (0..blockquote_count)
255 .map(|_| "> ")
256 .collect::<String>()
257 .trim_end()
258 .to_string()
259 } else {
260 ">".to_string()
261 };
262 format!("{blockquote_prefix} {correct_indent}")
265 } else {
266 correct_indent
267 };
268
269 let start_byte = line_info.byte_offset;
272 let mut end_byte = line_info.byte_offset;
273
274 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
276 if i >= list_item.marker_column {
277 break;
278 }
279 end_byte += ch.len_utf8();
280 }
281
282 Some(crate::rule::Fix {
283 range: start_byte..end_byte,
284 replacement,
285 })
286 };
287
288 warnings.push(LintWarning {
289 rule_name: Some(self.name().to_string()),
290 message: format!(
291 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
292 ),
293 line: line_idx + 1, column: 1, end_line: line_idx + 1,
296 end_column: visual_marker_column + 1, severity: Severity::Warning,
298 fix,
299 });
300 }
301 }
302 }
303 Ok(warnings)
304 }
305
306 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
308 let warnings = self.check(ctx)?;
310
311 if warnings.is_empty() {
313 return Ok(ctx.content.to_string());
314 }
315
316 let mut fixes: Vec<_> = warnings
318 .iter()
319 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
320 .collect();
321 fixes.sort_by(|a, b| b.0.cmp(&a.0));
322
323 let mut result = ctx.content.to_string();
325 for (start, end, replacement) in fixes {
326 if start < result.len() && end <= result.len() && start <= end {
327 result.replace_range(start..end, replacement);
328 }
329 }
330
331 Ok(result)
332 }
333
334 fn category(&self) -> RuleCategory {
336 RuleCategory::List
337 }
338
339 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
341 if ctx.content.is_empty() || !ctx.likely_has_lists() {
343 return true;
344 }
345 !ctx.lines
347 .iter()
348 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
349 }
350
351 fn as_any(&self) -> &dyn std::any::Any {
352 self
353 }
354
355 fn default_config_section(&self) -> Option<(String, toml::Value)> {
356 let default_config = MD007Config::default();
357 let json_value = serde_json::to_value(&default_config).ok()?;
358 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
359
360 if let toml::Value::Table(table) = toml_value {
361 if !table.is_empty() {
362 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
363 } else {
364 None
365 }
366 } else {
367 None
368 }
369 }
370
371 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
372 where
373 Self: Sized,
374 {
375 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
376
377 if let Some(rule_cfg) = config.rules.get("MD007") {
382 rule_config.style_explicit = rule_cfg.values.contains_key("style");
383 }
384
385 Box::new(Self::from_config_struct(rule_config))
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::lint_context::LintContext;
393 use crate::rule::Rule;
394
395 #[test]
396 fn test_valid_list_indent() {
397 let rule = MD007ULIndent::default();
398 let content = "* Item 1\n * Item 2\n * Item 3";
399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400 let result = rule.check(&ctx).unwrap();
401 assert!(
402 result.is_empty(),
403 "Expected no warnings for valid indentation, but got {} warnings",
404 result.len()
405 );
406 }
407
408 #[test]
409 fn test_invalid_list_indent() {
410 let rule = MD007ULIndent::default();
411 let content = "* Item 1\n * Item 2\n * Item 3";
412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
413 let result = rule.check(&ctx).unwrap();
414 assert_eq!(result.len(), 2);
415 assert_eq!(result[0].line, 2);
416 assert_eq!(result[0].column, 1);
417 assert_eq!(result[1].line, 3);
418 assert_eq!(result[1].column, 1);
419 }
420
421 #[test]
422 fn test_mixed_indentation() {
423 let rule = MD007ULIndent::default();
424 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426 let result = rule.check(&ctx).unwrap();
427 assert_eq!(result.len(), 1);
428 assert_eq!(result[0].line, 3);
429 assert_eq!(result[0].column, 1);
430 }
431
432 #[test]
433 fn test_fix_indentation() {
434 let rule = MD007ULIndent::default();
435 let content = "* Item 1\n * Item 2\n * Item 3";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437 let result = rule.fix(&ctx).unwrap();
438 let expected = "* Item 1\n * Item 2\n * Item 3";
442 assert_eq!(result, expected);
443 }
444
445 #[test]
446 fn test_md007_in_yaml_code_block() {
447 let rule = MD007ULIndent::default();
448 let content = r#"```yaml
449repos:
450- repo: https://github.com/rvben/rumdl
451 rev: v0.5.0
452 hooks:
453 - id: rumdl-check
454```"#;
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456 let result = rule.check(&ctx).unwrap();
457 assert!(
458 result.is_empty(),
459 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
460 );
461 }
462
463 #[test]
464 fn test_blockquoted_list_indent() {
465 let rule = MD007ULIndent::default();
466 let content = "> * Item 1\n> * Item 2\n> * Item 3";
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 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
472 );
473 }
474
475 #[test]
476 fn test_blockquoted_list_invalid_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_eq!(
482 result.len(),
483 2,
484 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
485 );
486 assert_eq!(result[0].line, 2);
487 assert_eq!(result[1].line, 3);
488 }
489
490 #[test]
491 fn test_nested_blockquote_list_indent() {
492 let rule = MD007ULIndent::default();
493 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
495 let result = rule.check(&ctx).unwrap();
496 assert!(
497 result.is_empty(),
498 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
499 );
500 }
501
502 #[test]
503 fn test_blockquote_list_with_code_block() {
504 let rule = MD007ULIndent::default();
505 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\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 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
511 );
512 }
513
514 #[test]
515 fn test_properly_indented_lists() {
516 let rule = MD007ULIndent::default();
517
518 let test_cases = vec![
520 "* Item 1\n* Item 2",
521 "* Item 1\n * Item 1.1\n * Item 1.1.1",
522 "- Item 1\n - Item 1.1",
523 "+ Item 1\n + Item 1.1",
524 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
525 ];
526
527 for content in test_cases {
528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529 let result = rule.check(&ctx).unwrap();
530 assert!(
531 result.is_empty(),
532 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
533 content,
534 result.len()
535 );
536 }
537 }
538
539 #[test]
540 fn test_under_indented_lists() {
541 let rule = MD007ULIndent::default();
542
543 let test_cases = vec![
544 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
547
548 for (content, expected_warnings, line) in test_cases {
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550 let result = rule.check(&ctx).unwrap();
551 assert_eq!(
552 result.len(),
553 expected_warnings,
554 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
555 );
556 if expected_warnings > 0 {
557 assert_eq!(result[0].line, line);
558 }
559 }
560 }
561
562 #[test]
563 fn test_over_indented_lists() {
564 let rule = MD007ULIndent::default();
565
566 let test_cases = vec![
567 ("* 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), ];
571
572 for (content, expected_warnings, line) in test_cases {
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let result = rule.check(&ctx).unwrap();
575 assert_eq!(
576 result.len(),
577 expected_warnings,
578 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
579 );
580 if expected_warnings > 0 {
581 assert_eq!(result[0].line, line);
582 }
583 }
584 }
585
586 #[test]
587 fn test_custom_indent_2_spaces() {
588 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let result = rule.check(&ctx).unwrap();
592 assert!(result.is_empty());
593 }
594
595 #[test]
596 fn test_custom_indent_3_spaces() {
597 let rule = MD007ULIndent::new(3);
600
601 let correct_content = "* Item 1\n * Item 2\n * Item 3";
603 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
604 let result = rule.check(&ctx).unwrap();
605 assert!(
606 result.is_empty(),
607 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
608 );
609
610 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
612 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
613 let result = rule.check(&ctx).unwrap();
614 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
615 }
616
617 #[test]
618 fn test_custom_indent_4_spaces() {
619 let rule = MD007ULIndent::new(4);
622
623 let correct_content = "* Item 1\n * Item 2\n * Item 3";
625 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
626 let result = rule.check(&ctx).unwrap();
627 assert!(
628 result.is_empty(),
629 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
630 );
631
632 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
634 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
635 let result = rule.check(&ctx).unwrap();
636 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
637 }
638
639 #[test]
640 fn test_tab_indentation() {
641 let rule = MD007ULIndent::default();
642
643 let content = "* Item 1\n\t* Item 2";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let result = rule.check(&ctx).unwrap();
647 assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
648
649 let fixed = rule.fix(&ctx).unwrap();
651 assert_eq!(fixed, "* Item 1\n * Item 2");
652
653 let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
655 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
656 let fixed = rule.fix(&ctx).unwrap();
657 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
660
661 let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
663 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
664 let fixed = rule.fix(&ctx).unwrap();
665 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
668 }
669
670 #[test]
671 fn test_mixed_ordered_unordered_lists() {
672 let rule = MD007ULIndent::default();
673
674 let content = r#"1. Ordered item
677 * Unordered sub-item (correct - 3 spaces under ordered)
678 2. Ordered sub-item
679* Unordered item
680 1. Ordered sub-item
681 * Unordered sub-item"#;
682
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684 let result = rule.check(&ctx).unwrap();
685 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
686
687 let fixed = rule.fix(&ctx).unwrap();
689 assert_eq!(fixed, content);
690 }
691
692 #[test]
693 fn test_list_markers_variety() {
694 let rule = MD007ULIndent::default();
695
696 let content = r#"* Asterisk
698 * Nested asterisk
699- Hyphen
700 - Nested hyphen
701+ Plus
702 + Nested plus"#;
703
704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706 assert!(
707 result.is_empty(),
708 "All unordered list markers should work with proper indentation"
709 );
710
711 let wrong_content = r#"* Asterisk
713 * Wrong asterisk
714- Hyphen
715 - Wrong hyphen
716+ Plus
717 + Wrong plus"#;
718
719 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
720 let result = rule.check(&ctx).unwrap();
721 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
722 }
723
724 #[test]
725 fn test_empty_list_items() {
726 let rule = MD007ULIndent::default();
727 let content = "* Item 1\n* \n * Item 2";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.check(&ctx).unwrap();
730 assert!(
731 result.is_empty(),
732 "Empty list items should not affect indentation checks"
733 );
734 }
735
736 #[test]
737 fn test_list_with_code_blocks() {
738 let rule = MD007ULIndent::default();
739 let content = r#"* Item 1
740 ```
741 code
742 ```
743 * Item 2
744 * Item 3"#;
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx).unwrap();
747 assert!(result.is_empty());
748 }
749
750 #[test]
751 fn test_list_in_front_matter() {
752 let rule = MD007ULIndent::default();
753 let content = r#"---
754tags:
755 - tag1
756 - tag2
757---
758* Item 1
759 * Item 2"#;
760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
761 let result = rule.check(&ctx).unwrap();
762 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
763 }
764
765 #[test]
766 fn test_fix_preserves_content() {
767 let rule = MD007ULIndent::default();
768 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let fixed = rule.fix(&ctx).unwrap();
771 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
774 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
775 }
776
777 #[test]
778 fn test_start_indented_config() {
779 let config = MD007Config {
780 start_indented: true,
781 start_indent: crate::types::IndentSize::from_const(4),
782 indent: crate::types::IndentSize::from_const(2),
783 style: md007_config::IndentStyle::TextAligned,
784 style_explicit: true, };
786 let rule = MD007ULIndent::from_config_struct(config);
787
788 let content = " * Item 1\n * Item 2\n * Item 3";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794 let result = rule.check(&ctx).unwrap();
795 assert!(result.is_empty(), "Expected no warnings with start_indented config");
796
797 let wrong_content = " * Item 1\n * Item 2";
799 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
800 let result = rule.check(&ctx).unwrap();
801 assert_eq!(result.len(), 2);
802 assert_eq!(result[0].line, 1);
803 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
804 assert_eq!(result[1].line, 2);
805 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
806
807 let fixed = rule.fix(&ctx).unwrap();
809 assert_eq!(fixed, " * Item 1\n * Item 2");
810 }
811
812 #[test]
813 fn test_start_indented_false_allows_any_first_level() {
814 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let result = rule.check(&ctx).unwrap();
820 assert!(
821 result.is_empty(),
822 "First level at any indentation should be allowed when start_indented is false"
823 );
824
825 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829 assert!(
830 result.is_empty(),
831 "All first-level items should be allowed at any indentation"
832 );
833 }
834
835 #[test]
836 fn test_deeply_nested_lists() {
837 let rule = MD007ULIndent::default();
838 let content = r#"* L1
839 * L2
840 * L3
841 * L4
842 * L5
843 * L6"#;
844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
845 let result = rule.check(&ctx).unwrap();
846 assert!(result.is_empty());
847
848 let wrong_content = r#"* L1
850 * L2
851 * L3
852 * L4
853 * L5
854 * L6"#;
855 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
856 let result = rule.check(&ctx).unwrap();
857 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
858 }
859
860 #[test]
861 fn test_excessive_indentation_detected() {
862 let rule = MD007ULIndent::default();
863
864 let content = "- Item 1\n - Item 2 with 5 spaces";
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 let result = rule.check(&ctx).unwrap();
868 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
869 assert_eq!(result[0].line, 2);
870 assert!(result[0].message.contains("Expected 2 spaces"));
871 assert!(result[0].message.contains("found 5"));
872
873 let content = "- Item 1\n - Item 2 with 3 spaces";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let result = rule.check(&ctx).unwrap();
877 assert_eq!(
878 result.len(),
879 1,
880 "Should detect slightly excessive indentation (3 instead of 2)"
881 );
882 assert_eq!(result[0].line, 2);
883 assert!(result[0].message.contains("Expected 2 spaces"));
884 assert!(result[0].message.contains("found 3"));
885
886 let content = "- Item 1\n - Item 2 with 1 space";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889 let result = rule.check(&ctx).unwrap();
890 assert_eq!(
891 result.len(),
892 1,
893 "Should detect 1-space indent (insufficient for nesting, expected 0)"
894 );
895 assert_eq!(result[0].line, 2);
896 assert!(result[0].message.contains("Expected 0 spaces"));
897 assert!(result[0].message.contains("found 1"));
898 }
899
900 #[test]
901 fn test_excessive_indentation_with_4_space_config() {
902 let rule = MD007ULIndent::new(4);
905
906 let content = "- Formatter:\n - The stable style changed";
908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909 let result = rule.check(&ctx).unwrap();
910 assert!(
911 !result.is_empty(),
912 "Should detect 5 spaces when expecting 4 (fixed style)"
913 );
914
915 let correct_content = "- Formatter:\n - The stable style changed";
917 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
918 let result = rule.check(&ctx).unwrap();
919 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
920 }
921
922 #[test]
923 fn test_bullets_nested_under_numbered_items() {
924 let rule = MD007ULIndent::default();
925 let content = "\
9261. **Active Directory/LDAP**
927 - User authentication and directory services
928 - LDAP for user information and validation
929
9302. **Oracle Unified Directory (OUD)**
931 - Extended user directory services";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.check(&ctx).unwrap();
934 assert!(
936 result.is_empty(),
937 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
938 );
939 }
940
941 #[test]
942 fn test_bullets_nested_under_numbered_items_wrong_indent() {
943 let rule = MD007ULIndent::default();
944 let content = "\
9451. **Active Directory/LDAP**
946 - Wrong: only 2 spaces";
947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
948 let result = rule.check(&ctx).unwrap();
949 assert_eq!(
951 result.len(),
952 1,
953 "Expected warning for incorrect indentation under numbered items"
954 );
955 assert!(
956 result
957 .iter()
958 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
959 );
960 }
961
962 #[test]
963 fn test_regular_bullet_nesting_still_works() {
964 let rule = MD007ULIndent::default();
965 let content = "\
966* Top level
967 * Nested bullet (2 spaces is correct)
968 * Deeply nested (4 spaces)";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 let result = rule.check(&ctx).unwrap();
971 assert!(
973 result.is_empty(),
974 "Expected no warnings for standard bullet nesting, got: {result:?}"
975 );
976 }
977
978 #[test]
979 fn test_blockquote_with_tab_after_marker() {
980 let rule = MD007ULIndent::default();
981 let content = ">\t* List item\n>\t * Nested\n";
982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
983 let result = rule.check(&ctx).unwrap();
984 assert!(
985 result.is_empty(),
986 "Tab after blockquote marker should be handled correctly, got: {result:?}"
987 );
988 }
989
990 #[test]
991 fn test_blockquote_with_space_then_tab_after_marker() {
992 let rule = MD007ULIndent::default();
993 let content = "> \t* List item\n";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996 assert!(
998 result.is_empty(),
999 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1000 );
1001 }
1002
1003 #[test]
1004 fn test_blockquote_with_multiple_tabs() {
1005 let rule = MD007ULIndent::default();
1006 let content = ">\t\t* List item\n";
1007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008 let result = rule.check(&ctx).unwrap();
1009 assert!(
1011 result.is_empty(),
1012 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_nested_blockquote_with_tab() {
1018 let rule = MD007ULIndent::default();
1019 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021 let result = rule.check(&ctx).unwrap();
1022 assert!(
1023 result.is_empty(),
1024 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1025 );
1026 }
1027
1028 #[test]
1031 fn test_smart_style_pure_unordered_uses_fixed() {
1032 let rule = MD007ULIndent::new(4);
1034
1035 let content = "* Level 0\n * Level 1\n * Level 2";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038 let result = rule.check(&ctx).unwrap();
1039 assert!(
1040 result.is_empty(),
1041 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_smart_style_mixed_lists_uses_text_aligned() {
1047 let rule = MD007ULIndent::new(4);
1049
1050 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053 let result = rule.check(&ctx).unwrap();
1054 assert!(
1055 result.is_empty(),
1056 "Mixed lists should use text-aligned style, got: {result:?}"
1057 );
1058 }
1059
1060 #[test]
1061 fn test_smart_style_explicit_fixed_overrides() {
1062 let config = MD007Config {
1064 indent: crate::types::IndentSize::from_const(4),
1065 start_indented: false,
1066 start_indent: crate::types::IndentSize::from_const(2),
1067 style: md007_config::IndentStyle::Fixed,
1068 style_explicit: true, };
1070 let rule = MD007ULIndent::from_config_struct(config);
1071
1072 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1074 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1075 let result = rule.check(&ctx).unwrap();
1076 assert!(
1078 result.is_empty(),
1079 "Explicit fixed style should be respected, got: {result:?}"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_smart_style_explicit_text_aligned_overrides() {
1085 let config = MD007Config {
1087 indent: crate::types::IndentSize::from_const(4),
1088 start_indented: false,
1089 start_indent: crate::types::IndentSize::from_const(2),
1090 style: md007_config::IndentStyle::TextAligned,
1091 style_explicit: true, };
1093 let rule = MD007ULIndent::from_config_struct(config);
1094
1095 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1098 let result = rule.check(&ctx).unwrap();
1099 assert!(
1100 result.is_empty(),
1101 "Explicit text-aligned should be respected, got: {result:?}"
1102 );
1103
1104 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1106 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1107 let result = rule.check(&ctx).unwrap();
1108 assert!(
1109 !result.is_empty(),
1110 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_smart_style_default_indent_no_autoswitch() {
1116 let rule = MD007ULIndent::new(2);
1118
1119 let content = "* Level 0\n * Level 1\n * Level 2";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121 let result = rule.check(&ctx).unwrap();
1122 assert!(
1123 result.is_empty(),
1124 "Default indent should work regardless of style, got: {result:?}"
1125 );
1126 }
1127
1128 #[test]
1129 fn test_has_mixed_list_nesting_detection() {
1130 let content = "* Item 1\n * Item 2\n * Item 3";
1134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1135 assert!(
1136 !ctx.has_mixed_list_nesting(),
1137 "Pure unordered should not be detected as mixed"
1138 );
1139
1140 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1143 assert!(
1144 !ctx.has_mixed_list_nesting(),
1145 "Pure ordered should not be detected as mixed"
1146 );
1147
1148 let content = "1. Ordered\n * Unordered child";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151 assert!(
1152 ctx.has_mixed_list_nesting(),
1153 "Unordered under ordered should be detected as mixed"
1154 );
1155
1156 let content = "* Unordered\n 1. Ordered child";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159 assert!(
1160 ctx.has_mixed_list_nesting(),
1161 "Ordered under unordered should be detected as mixed"
1162 );
1163
1164 let content = "* Unordered\n\n1. Ordered (separate list)";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 assert!(
1168 !ctx.has_mixed_list_nesting(),
1169 "Separate lists should not be detected as mixed"
1170 );
1171
1172 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175 assert!(
1176 ctx.has_mixed_list_nesting(),
1177 "Mixed lists in blockquotes should be detected"
1178 );
1179 }
1180
1181 #[test]
1182 fn test_issue_210_exact_reproduction() {
1183 let config = MD007Config {
1185 indent: crate::types::IndentSize::from_const(4),
1186 start_indented: false,
1187 start_indent: crate::types::IndentSize::from_const(2),
1188 style: md007_config::IndentStyle::TextAligned, style_explicit: false, };
1191 let rule = MD007ULIndent::from_config_struct(config);
1192
1193 let content = "# Title\n\n* some\n * list\n * items\n";
1194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195 let result = rule.check(&ctx).unwrap();
1196
1197 assert!(
1198 result.is_empty(),
1199 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1200 );
1201 }
1202
1203 #[test]
1204 fn test_issue_209_still_fixed() {
1205 let config = MD007Config {
1208 indent: crate::types::IndentSize::from_const(3),
1209 start_indented: false,
1210 start_indent: crate::types::IndentSize::from_const(2),
1211 style: md007_config::IndentStyle::TextAligned,
1212 style_explicit: true, };
1214 let rule = MD007ULIndent::from_config_struct(config);
1215
1216 let content = r#"# Header 1
1218
1219- **Second item**:
1220 - **This is a nested list**:
1221 1. **First point**
1222 - First subpoint
1223"#;
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226
1227 assert!(
1228 result.is_empty(),
1229 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1230 );
1231 }
1232
1233 #[test]
1236 fn test_multi_level_mixed_detection_grandparent() {
1237 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 assert!(
1243 ctx.has_mixed_list_nesting(),
1244 "Should detect mixed nesting when grandparent differs in type"
1245 );
1246
1247 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 assert!(
1251 ctx.has_mixed_list_nesting(),
1252 "Should detect mixed nesting for ordered descendants under unordered"
1253 );
1254 }
1255
1256 #[test]
1257 fn test_html_comments_skipped_in_detection() {
1258 let content = r#"* Unordered list
1260<!-- This is a comment
1261 1. This ordered list is inside a comment
1262 * This nested bullet is also inside
1263-->
1264 * Another unordered item"#;
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 assert!(
1267 !ctx.has_mixed_list_nesting(),
1268 "Lists in HTML comments should be ignored in mixed detection"
1269 );
1270 }
1271
1272 #[test]
1273 fn test_blank_lines_separate_lists() {
1274 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 assert!(
1278 !ctx.has_mixed_list_nesting(),
1279 "Blank line at root should separate lists"
1280 );
1281
1282 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1285 assert!(
1286 ctx.has_mixed_list_nesting(),
1287 "Indented list after blank is still nested"
1288 );
1289 }
1290
1291 #[test]
1292 fn test_column_1_normalization() {
1293 let content = "* First item\n * Second item with 1 space (sibling)";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297 let rule = MD007ULIndent::default();
1298 let result = rule.check(&ctx).unwrap();
1299 assert!(
1301 result.iter().any(|w| w.line == 2),
1302 "1-space indent should be flagged as incorrect"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_code_blocks_skipped_in_detection() {
1308 let content = r#"* Unordered list
1310```
13111. This ordered list is inside a code block
1312 * This nested bullet is also inside
1313```
1314 * Another unordered item"#;
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316 assert!(
1317 !ctx.has_mixed_list_nesting(),
1318 "Lists in code blocks should be ignored in mixed detection"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_front_matter_skipped_in_detection() {
1324 let content = r#"---
1326items:
1327 - yaml list item
1328 - another item
1329---
1330* Unordered list after front matter"#;
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332 assert!(
1333 !ctx.has_mixed_list_nesting(),
1334 "Lists in front matter should be ignored in mixed detection"
1335 );
1336 }
1337
1338 #[test]
1339 fn test_alternating_types_at_same_level() {
1340 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 assert!(
1345 !ctx.has_mixed_list_nesting(),
1346 "Alternating types at same level should not be detected as mixed"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_five_level_deep_mixed_nesting() {
1352 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1356 }
1357
1358 #[test]
1359 fn test_very_deep_pure_unordered_nesting() {
1360 let mut content = String::from("* L1");
1362 for level in 2..=12 {
1363 let indent = " ".repeat(level - 1);
1364 content.push_str(&format!("\n{indent}* L{level}"));
1365 }
1366
1367 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1368
1369 assert!(
1371 !ctx.has_mixed_list_nesting(),
1372 "Pure unordered deep nesting should not be detected as mixed"
1373 );
1374
1375 let rule = MD007ULIndent::new(4);
1377 let result = rule.check(&ctx).unwrap();
1378 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1381 }
1382
1383 #[test]
1384 fn test_interleaved_content_between_list_items() {
1385 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388 assert!(
1389 ctx.has_mixed_list_nesting(),
1390 "Should detect mixed nesting even with interleaved paragraphs"
1391 );
1392 }
1393
1394 #[test]
1395 fn test_esm_blocks_skipped_in_detection() {
1396 let content = "* Unordered list\n * Nested unordered";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 assert!(
1401 !ctx.has_mixed_list_nesting(),
1402 "Pure unordered should not be detected as mixed"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_multiple_list_blocks_pure_then_mixed() {
1408 let content = r#"* Pure unordered
1411 * Nested unordered
1412
14131. Mixed section
1414 * Bullet under ordered"#;
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416 assert!(
1417 ctx.has_mixed_list_nesting(),
1418 "Should detect mixed nesting in any part of document"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_multiple_separate_pure_lists() {
1424 let content = r#"* First list
1427 * Nested
1428
1429* Second list
1430 * Also nested
1431
1432* Third list
1433 * Deeply
1434 * Nested"#;
1435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1436 assert!(
1437 !ctx.has_mixed_list_nesting(),
1438 "Multiple separate pure unordered lists should not be mixed"
1439 );
1440 }
1441
1442 #[test]
1443 fn test_code_block_between_list_items() {
1444 let content = r#"1. Ordered
1446 ```
1447 code
1448 ```
1449 * Still a mixed child"#;
1450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451 assert!(
1452 ctx.has_mixed_list_nesting(),
1453 "Code block between items should not prevent mixed detection"
1454 );
1455 }
1456
1457 #[test]
1458 fn test_blockquoted_mixed_detection() {
1459 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 assert!(
1465 ctx.has_mixed_list_nesting(),
1466 "Should detect mixed nesting in blockquotes"
1467 );
1468 }
1469}