1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use toml;
7
8mod md007_config;
9use md007_config::MD007Config;
10
11#[derive(Debug, Clone, Default)]
12pub struct MD007ULIndent {
13 config: MD007Config,
14}
15
16impl MD007ULIndent {
17 pub fn new(indent: usize) -> Self {
18 Self {
19 config: MD007Config {
20 indent,
21 start_indented: false,
22 start_indent: 2,
23 style: md007_config::IndentStyle::TextAligned,
24 },
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
51impl Rule for MD007ULIndent {
52 fn name(&self) -> &'static str {
53 "MD007"
54 }
55
56 fn description(&self) -> &'static str {
57 "Unordered list indentation"
58 }
59
60 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
61 let mut warnings = Vec::new();
62 let mut list_stack: Vec<(usize, usize, bool, usize)> = Vec::new(); for (line_idx, line_info) in ctx.lines.iter().enumerate() {
65 if line_info.in_code_block || line_info.in_front_matter || line_info.in_mkdocstrings {
67 continue;
68 }
69
70 if let Some(list_item) = &line_info.list_item {
72 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
76 let mut content_start = 0;
79 let mut found_gt = false;
80
81 for (i, ch) in line_info.content.chars().enumerate() {
82 if ch == '>' {
83 found_gt = true;
84 content_start = i + 1;
85 } else if found_gt && ch == ' ' {
86 content_start = i + 1;
88 break;
89 } else if found_gt {
90 break;
92 }
93 }
94
95 let content_after_prefix = &line_info.content[content_start..];
97 let adjusted_col = if list_item.marker_column >= content_start {
99 list_item.marker_column - content_start
100 } else {
101 list_item.marker_column
103 };
104 (content_after_prefix.to_string(), adjusted_col)
105 } else {
106 (line_info.content.clone(), list_item.marker_column)
107 };
108
109 let visual_marker_column =
111 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
112
113 let visual_content_column = if line_info.blockquote.is_some() {
115 let adjusted_content_col =
117 if list_item.content_column >= (line_info.content.len() - content_for_calculation.len()) {
118 list_item.content_column - (line_info.content.len() - content_for_calculation.len())
119 } else {
120 list_item.content_column
121 };
122 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
123 } else {
124 Self::char_pos_to_visual_column(&line_info.content, list_item.content_column)
125 };
126
127 let visual_marker_for_nesting = if visual_marker_column == 1 {
130 0
131 } else {
132 visual_marker_column
133 };
134
135 while let Some(&(indent, _, _, _)) = list_stack.last() {
137 if indent >= visual_marker_for_nesting {
138 list_stack.pop();
139 } else {
140 break;
141 }
142 }
143
144 if list_item.is_ordered {
146 list_stack.push((visual_marker_column, line_idx, true, visual_content_column));
149 continue;
150 }
151
152 if !list_item.is_ordered {
154 let nesting_level = list_stack.len();
156
157 let expected_indent = if self.config.start_indented {
159 self.config.start_indent + (nesting_level * self.config.indent)
160 } else {
161 match self.config.style {
162 md007_config::IndentStyle::Fixed => {
163 nesting_level * self.config.indent
165 }
166 md007_config::IndentStyle::TextAligned => {
167 if nesting_level > 0 {
169 if let Some(&(_, _parent_line_idx, _is_ordered, parent_content_visual_col)) =
171 list_stack.get(nesting_level - 1)
172 {
173 parent_content_visual_col
175 } else {
176 nesting_level * 2
179 }
180 } else {
181 0 }
183 }
184 }
185 };
186
187 let expected_content_visual_col = expected_indent + 2; list_stack.push((visual_marker_column, line_idx, false, expected_content_visual_col));
193
194 if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
197 continue;
198 }
199
200 if visual_marker_column != expected_indent {
201 let fix = {
203 let correct_indent = " ".repeat(expected_indent);
204
205 let replacement = if line_info.blockquote.is_some() {
208 let mut blockquote_count = 0;
210 for ch in line_info.content.chars() {
211 if ch == '>' {
212 blockquote_count += 1;
213 } else if ch != ' ' && ch != '\t' {
214 break;
215 }
216 }
217 let blockquote_prefix = if blockquote_count > 1 {
219 (0..blockquote_count)
220 .map(|_| "> ")
221 .collect::<String>()
222 .trim_end()
223 .to_string()
224 } else {
225 ">".to_string()
226 };
227 format!("{blockquote_prefix} {correct_indent}")
230 } else {
231 correct_indent
232 };
233
234 let start_byte = line_info.byte_offset;
237 let mut end_byte = line_info.byte_offset;
238
239 for (i, ch) in line_info.content.chars().enumerate() {
241 if i >= list_item.marker_column {
242 break;
243 }
244 end_byte += ch.len_utf8();
245 }
246
247 Some(crate::rule::Fix {
248 range: start_byte..end_byte,
249 replacement,
250 })
251 };
252
253 warnings.push(LintWarning {
254 rule_name: Some(self.name()),
255 message: format!(
256 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
257 ),
258 line: line_idx + 1, column: 1, end_line: line_idx + 1,
261 end_column: visual_marker_column + 1, severity: Severity::Warning,
263 fix,
264 });
265 }
266 }
267 }
268 }
269 Ok(warnings)
270 }
271
272 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
274 let warnings = self.check(ctx)?;
276
277 if warnings.is_empty() {
279 return Ok(ctx.content.to_string());
280 }
281
282 let mut fixes: Vec<_> = warnings
284 .iter()
285 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
286 .collect();
287 fixes.sort_by(|a, b| b.0.cmp(&a.0));
288
289 let mut result = ctx.content.to_string();
291 for (start, end, replacement) in fixes {
292 if start < result.len() && end <= result.len() && start <= end {
293 result.replace_range(start..end, replacement);
294 }
295 }
296
297 Ok(result)
298 }
299
300 fn category(&self) -> RuleCategory {
302 RuleCategory::List
303 }
304
305 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
307 if ctx.content.is_empty() || !ctx.likely_has_lists() {
309 return true;
310 }
311 !ctx.lines
313 .iter()
314 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
315 }
316
317 fn as_any(&self) -> &dyn std::any::Any {
318 self
319 }
320
321 fn default_config_section(&self) -> Option<(String, toml::Value)> {
322 let default_config = MD007Config::default();
323 let json_value = serde_json::to_value(&default_config).ok()?;
324 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
325
326 if let toml::Value::Table(table) = toml_value {
327 if !table.is_empty() {
328 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
329 } else {
330 None
331 }
332 } else {
333 None
334 }
335 }
336
337 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
338 where
339 Self: Sized,
340 {
341 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
342
343 if let Some(rule_cfg) = config.rules.get("MD007") {
346 let has_explicit_indent = rule_cfg.values.contains_key("indent");
347 let has_explicit_style = rule_cfg.values.contains_key("style");
348
349 if has_explicit_indent && !has_explicit_style && rule_config.indent != 2 {
350 rule_config.style = md007_config::IndentStyle::Fixed;
353 }
354 }
355
356 Box::new(Self::from_config_struct(rule_config))
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::lint_context::LintContext;
364 use crate::rule::Rule;
365
366 #[test]
367 fn test_valid_list_indent() {
368 let rule = MD007ULIndent::default();
369 let content = "* Item 1\n * Item 2\n * Item 3";
370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
371 let result = rule.check(&ctx).unwrap();
372 assert!(
373 result.is_empty(),
374 "Expected no warnings for valid indentation, but got {} warnings",
375 result.len()
376 );
377 }
378
379 #[test]
380 fn test_invalid_list_indent() {
381 let rule = MD007ULIndent::default();
382 let content = "* Item 1\n * Item 2\n * Item 3";
383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
384 let result = rule.check(&ctx).unwrap();
385 assert_eq!(result.len(), 2);
386 assert_eq!(result[0].line, 2);
387 assert_eq!(result[0].column, 1);
388 assert_eq!(result[1].line, 3);
389 assert_eq!(result[1].column, 1);
390 }
391
392 #[test]
393 fn test_mixed_indentation() {
394 let rule = MD007ULIndent::default();
395 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
397 let result = rule.check(&ctx).unwrap();
398 assert_eq!(result.len(), 1);
399 assert_eq!(result[0].line, 3);
400 assert_eq!(result[0].column, 1);
401 }
402
403 #[test]
404 fn test_fix_indentation() {
405 let rule = MD007ULIndent::default();
406 let content = "* Item 1\n * Item 2\n * Item 3";
407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
408 let result = rule.fix(&ctx).unwrap();
409 let expected = "* Item 1\n * Item 2\n * Item 3";
413 assert_eq!(result, expected);
414 }
415
416 #[test]
417 fn test_md007_in_yaml_code_block() {
418 let rule = MD007ULIndent::default();
419 let content = r#"```yaml
420repos:
421- repo: https://github.com/rvben/rumdl
422 rev: v0.5.0
423 hooks:
424 - id: rumdl-check
425```"#;
426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
427 let result = rule.check(&ctx).unwrap();
428 assert!(
429 result.is_empty(),
430 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
431 );
432 }
433
434 #[test]
435 fn test_blockquoted_list_indent() {
436 let rule = MD007ULIndent::default();
437 let content = "> * Item 1\n> * Item 2\n> * Item 3";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
439 let result = rule.check(&ctx).unwrap();
440 assert!(
441 result.is_empty(),
442 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
443 );
444 }
445
446 #[test]
447 fn test_blockquoted_list_invalid_indent() {
448 let rule = MD007ULIndent::default();
449 let content = "> * Item 1\n> * Item 2\n> * Item 3";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
451 let result = rule.check(&ctx).unwrap();
452 assert_eq!(
453 result.len(),
454 2,
455 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
456 );
457 assert_eq!(result[0].line, 2);
458 assert_eq!(result[1].line, 3);
459 }
460
461 #[test]
462 fn test_nested_blockquote_list_indent() {
463 let rule = MD007ULIndent::default();
464 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
466 let result = rule.check(&ctx).unwrap();
467 assert!(
468 result.is_empty(),
469 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
470 );
471 }
472
473 #[test]
474 fn test_blockquote_list_with_code_block() {
475 let rule = MD007ULIndent::default();
476 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
478 let result = rule.check(&ctx).unwrap();
479 assert!(
480 result.is_empty(),
481 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
482 );
483 }
484
485 #[test]
486 fn test_properly_indented_lists() {
487 let rule = MD007ULIndent::default();
488
489 let test_cases = vec![
491 "* Item 1\n* Item 2",
492 "* Item 1\n * Item 1.1\n * Item 1.1.1",
493 "- Item 1\n - Item 1.1",
494 "+ Item 1\n + Item 1.1",
495 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
496 ];
497
498 for content in test_cases {
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
500 let result = rule.check(&ctx).unwrap();
501 assert!(
502 result.is_empty(),
503 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
504 content,
505 result.len()
506 );
507 }
508 }
509
510 #[test]
511 fn test_under_indented_lists() {
512 let rule = MD007ULIndent::default();
513
514 let test_cases = vec![
515 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
518
519 for (content, expected_warnings, line) in test_cases {
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
521 let result = rule.check(&ctx).unwrap();
522 assert_eq!(
523 result.len(),
524 expected_warnings,
525 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
526 );
527 if expected_warnings > 0 {
528 assert_eq!(result[0].line, line);
529 }
530 }
531 }
532
533 #[test]
534 fn test_over_indented_lists() {
535 let rule = MD007ULIndent::default();
536
537 let test_cases = vec![
538 ("* 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), ];
542
543 for (content, expected_warnings, line) in test_cases {
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
545 let result = rule.check(&ctx).unwrap();
546 assert_eq!(
547 result.len(),
548 expected_warnings,
549 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
550 );
551 if expected_warnings > 0 {
552 assert_eq!(result[0].line, line);
553 }
554 }
555 }
556
557 #[test]
558 fn test_custom_indent_2_spaces() {
559 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
562 let result = rule.check(&ctx).unwrap();
563 assert!(result.is_empty());
564 }
565
566 #[test]
567 fn test_custom_indent_3_spaces() {
568 let rule = MD007ULIndent::new(3);
570
571 let content = "* Item 1\n * Item 2\n * Item 3";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
573 let result = rule.check(&ctx).unwrap();
574 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
581 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
582 let result = rule.check(&ctx).unwrap();
583 assert!(result.is_empty());
584 }
585
586 #[test]
587 fn test_custom_indent_4_spaces() {
588 let rule = MD007ULIndent::new(4);
590 let content = "* Item 1\n * Item 2\n * Item 3";
591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
592 let result = rule.check(&ctx).unwrap();
593 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
599 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
600 let result = rule.check(&ctx).unwrap();
601 assert!(result.is_empty());
602 }
603
604 #[test]
605 fn test_tab_indentation() {
606 let rule = MD007ULIndent::default();
607
608 let content = "* Item 1\n\t* Item 2";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
611 let result = rule.check(&ctx).unwrap();
612 assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
613
614 let fixed = rule.fix(&ctx).unwrap();
616 assert_eq!(fixed, "* Item 1\n * Item 2");
617
618 let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
620 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard);
621 let fixed = rule.fix(&ctx).unwrap();
622 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
625
626 let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
628 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard);
629 let fixed = rule.fix(&ctx).unwrap();
630 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
633 }
634
635 #[test]
636 fn test_mixed_ordered_unordered_lists() {
637 let rule = MD007ULIndent::default();
638
639 let content = r#"1. Ordered item
642 * Unordered sub-item (correct - 3 spaces under ordered)
643 2. Ordered sub-item
644* Unordered item
645 1. Ordered sub-item
646 * Unordered sub-item"#;
647
648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
649 let result = rule.check(&ctx).unwrap();
650 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
651
652 let fixed = rule.fix(&ctx).unwrap();
654 assert_eq!(fixed, content);
655 }
656
657 #[test]
658 fn test_list_markers_variety() {
659 let rule = MD007ULIndent::default();
660
661 let content = r#"* Asterisk
663 * Nested asterisk
664- Hyphen
665 - Nested hyphen
666+ Plus
667 + Nested plus"#;
668
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
670 let result = rule.check(&ctx).unwrap();
671 assert!(
672 result.is_empty(),
673 "All unordered list markers should work with proper indentation"
674 );
675
676 let wrong_content = r#"* Asterisk
678 * Wrong asterisk
679- Hyphen
680 - Wrong hyphen
681+ Plus
682 + Wrong plus"#;
683
684 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
685 let result = rule.check(&ctx).unwrap();
686 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
687 }
688
689 #[test]
690 fn test_empty_list_items() {
691 let rule = MD007ULIndent::default();
692 let content = "* Item 1\n* \n * Item 2";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
694 let result = rule.check(&ctx).unwrap();
695 assert!(
696 result.is_empty(),
697 "Empty list items should not affect indentation checks"
698 );
699 }
700
701 #[test]
702 fn test_list_with_code_blocks() {
703 let rule = MD007ULIndent::default();
704 let content = r#"* Item 1
705 ```
706 code
707 ```
708 * Item 2
709 * Item 3"#;
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
711 let result = rule.check(&ctx).unwrap();
712 assert!(result.is_empty());
713 }
714
715 #[test]
716 fn test_list_in_front_matter() {
717 let rule = MD007ULIndent::default();
718 let content = r#"---
719tags:
720 - tag1
721 - tag2
722---
723* Item 1
724 * Item 2"#;
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
726 let result = rule.check(&ctx).unwrap();
727 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
728 }
729
730 #[test]
731 fn test_fix_preserves_content() {
732 let rule = MD007ULIndent::default();
733 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
735 let fixed = rule.fix(&ctx).unwrap();
736 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
739 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
740 }
741
742 #[test]
743 fn test_start_indented_config() {
744 let config = MD007Config {
745 start_indented: true,
746 start_indent: 4,
747 indent: 2,
748 style: md007_config::IndentStyle::TextAligned,
749 };
750 let rule = MD007ULIndent::from_config_struct(config);
751
752 let content = " * Item 1\n * Item 2\n * Item 3";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
758 let result = rule.check(&ctx).unwrap();
759 assert!(result.is_empty(), "Expected no warnings with start_indented config");
760
761 let wrong_content = " * Item 1\n * Item 2";
763 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
764 let result = rule.check(&ctx).unwrap();
765 assert_eq!(result.len(), 2);
766 assert_eq!(result[0].line, 1);
767 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
768 assert_eq!(result[1].line, 2);
769 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
770
771 let fixed = rule.fix(&ctx).unwrap();
773 assert_eq!(fixed, " * Item 1\n * Item 2");
774 }
775
776 #[test]
777 fn test_start_indented_false_allows_any_first_level() {
778 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
783 let result = rule.check(&ctx).unwrap();
784 assert!(
785 result.is_empty(),
786 "First level at any indentation should be allowed when start_indented is false"
787 );
788
789 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
792 let result = rule.check(&ctx).unwrap();
793 assert!(
794 result.is_empty(),
795 "All first-level items should be allowed at any indentation"
796 );
797 }
798
799 #[test]
800 fn test_deeply_nested_lists() {
801 let rule = MD007ULIndent::default();
802 let content = r#"* L1
803 * L2
804 * L3
805 * L4
806 * L5
807 * L6"#;
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
809 let result = rule.check(&ctx).unwrap();
810 assert!(result.is_empty());
811
812 let wrong_content = r#"* L1
814 * L2
815 * L3
816 * L4
817 * L5
818 * L6"#;
819 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
820 let result = rule.check(&ctx).unwrap();
821 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
822 }
823
824 #[test]
825 fn test_excessive_indentation_detected() {
826 let rule = MD007ULIndent::default();
827
828 let content = "- Item 1\n - Item 2 with 5 spaces";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
831 let result = rule.check(&ctx).unwrap();
832 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
833 assert_eq!(result[0].line, 2);
834 assert!(result[0].message.contains("Expected 2 spaces"));
835 assert!(result[0].message.contains("found 5"));
836
837 let content = "- Item 1\n - Item 2 with 3 spaces";
839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
840 let result = rule.check(&ctx).unwrap();
841 assert_eq!(
842 result.len(),
843 1,
844 "Should detect slightly excessive indentation (3 instead of 2)"
845 );
846 assert_eq!(result[0].line, 2);
847 assert!(result[0].message.contains("Expected 2 spaces"));
848 assert!(result[0].message.contains("found 3"));
849
850 let content = "- Item 1\n - Item 2 with 1 space";
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
853 let result = rule.check(&ctx).unwrap();
854 assert_eq!(
855 result.len(),
856 1,
857 "Should detect 1-space indent (insufficient for nesting, expected 0)"
858 );
859 assert_eq!(result[0].line, 2);
860 assert!(result[0].message.contains("Expected 0 spaces"));
861 assert!(result[0].message.contains("found 1"));
862 }
863
864 #[test]
865 fn test_excessive_indentation_with_4_space_config() {
866 let rule = MD007ULIndent::new(4);
867
868 let content = "- Formatter:\n - The stable style changed";
870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
871 let result = rule.check(&ctx).unwrap();
872
873 assert!(
876 !result.is_empty(),
877 "Should detect 5 spaces when expecting proper alignment"
878 );
879
880 let correct_content = "- Formatter:\n - The stable style changed";
882 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
883 let result = rule.check(&ctx).unwrap();
884 assert!(result.is_empty(), "Should accept correct text alignment");
885 }
886
887 #[test]
888 fn test_bullets_nested_under_numbered_items() {
889 let rule = MD007ULIndent::default();
890 let content = "\
8911. **Active Directory/LDAP**
892 - User authentication and directory services
893 - LDAP for user information and validation
894
8952. **Oracle Unified Directory (OUD)**
896 - Extended user directory services";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
898 let result = rule.check(&ctx).unwrap();
899 assert!(
901 result.is_empty(),
902 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
903 );
904 }
905
906 #[test]
907 fn test_bullets_nested_under_numbered_items_wrong_indent() {
908 let rule = MD007ULIndent::default();
909 let content = "\
9101. **Active Directory/LDAP**
911 - Wrong: only 2 spaces";
912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
913 let result = rule.check(&ctx).unwrap();
914 assert_eq!(
916 result.len(),
917 1,
918 "Expected warning for incorrect indentation under numbered items"
919 );
920 assert!(
921 result
922 .iter()
923 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
924 );
925 }
926
927 #[test]
928 fn test_regular_bullet_nesting_still_works() {
929 let rule = MD007ULIndent::default();
930 let content = "\
931* Top level
932 * Nested bullet (2 spaces is correct)
933 * Deeply nested (4 spaces)";
934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
935 let result = rule.check(&ctx).unwrap();
936 assert!(
938 result.is_empty(),
939 "Expected no warnings for standard bullet nesting, got: {result:?}"
940 );
941 }
942}