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 ctx.content.is_empty()
309 || !ctx
310 .lines
311 .iter()
312 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
313 }
314
315 fn as_any(&self) -> &dyn std::any::Any {
316 self
317 }
318
319 fn default_config_section(&self) -> Option<(String, toml::Value)> {
320 let default_config = MD007Config::default();
321 let json_value = serde_json::to_value(&default_config).ok()?;
322 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
323
324 if let toml::Value::Table(table) = toml_value {
325 if !table.is_empty() {
326 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
327 } else {
328 None
329 }
330 } else {
331 None
332 }
333 }
334
335 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
336 where
337 Self: Sized,
338 {
339 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
340
341 if let Some(rule_cfg) = config.rules.get("MD007") {
344 let has_explicit_indent = rule_cfg.values.contains_key("indent");
345 let has_explicit_style = rule_cfg.values.contains_key("style");
346
347 if has_explicit_indent && !has_explicit_style && rule_config.indent != 2 {
348 rule_config.style = md007_config::IndentStyle::Fixed;
351 }
352 }
353
354 Box::new(Self::from_config_struct(rule_config))
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::lint_context::LintContext;
362 use crate::rule::Rule;
363
364 #[test]
365 fn test_valid_list_indent() {
366 let rule = MD007ULIndent::default();
367 let content = "* Item 1\n * Item 2\n * Item 3";
368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
369 let result = rule.check(&ctx).unwrap();
370 assert!(
371 result.is_empty(),
372 "Expected no warnings for valid indentation, but got {} warnings",
373 result.len()
374 );
375 }
376
377 #[test]
378 fn test_invalid_list_indent() {
379 let rule = MD007ULIndent::default();
380 let content = "* Item 1\n * Item 2\n * Item 3";
381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
382 let result = rule.check(&ctx).unwrap();
383 assert_eq!(result.len(), 2);
384 assert_eq!(result[0].line, 2);
385 assert_eq!(result[0].column, 1);
386 assert_eq!(result[1].line, 3);
387 assert_eq!(result[1].column, 1);
388 }
389
390 #[test]
391 fn test_mixed_indentation() {
392 let rule = MD007ULIndent::default();
393 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
395 let result = rule.check(&ctx).unwrap();
396 assert_eq!(result.len(), 1);
397 assert_eq!(result[0].line, 3);
398 assert_eq!(result[0].column, 1);
399 }
400
401 #[test]
402 fn test_fix_indentation() {
403 let rule = MD007ULIndent::default();
404 let content = "* Item 1\n * Item 2\n * Item 3";
405 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
406 let result = rule.fix(&ctx).unwrap();
407 let expected = "* Item 1\n * Item 2\n * Item 3";
411 assert_eq!(result, expected);
412 }
413
414 #[test]
415 fn test_md007_in_yaml_code_block() {
416 let rule = MD007ULIndent::default();
417 let content = r#"```yaml
418repos:
419- repo: https://github.com/rvben/rumdl
420 rev: v0.5.0
421 hooks:
422 - id: rumdl-check
423```"#;
424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
425 let result = rule.check(&ctx).unwrap();
426 assert!(
427 result.is_empty(),
428 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
429 );
430 }
431
432 #[test]
433 fn test_blockquoted_list_indent() {
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);
437 let result = rule.check(&ctx).unwrap();
438 assert!(
439 result.is_empty(),
440 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
441 );
442 }
443
444 #[test]
445 fn test_blockquoted_list_invalid_indent() {
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);
449 let result = rule.check(&ctx).unwrap();
450 assert_eq!(
451 result.len(),
452 2,
453 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
454 );
455 assert_eq!(result[0].line, 2);
456 assert_eq!(result[1].line, 3);
457 }
458
459 #[test]
460 fn test_nested_blockquote_list_indent() {
461 let rule = MD007ULIndent::default();
462 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
464 let result = rule.check(&ctx).unwrap();
465 assert!(
466 result.is_empty(),
467 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
468 );
469 }
470
471 #[test]
472 fn test_blockquote_list_with_code_block() {
473 let rule = MD007ULIndent::default();
474 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
476 let result = rule.check(&ctx).unwrap();
477 assert!(
478 result.is_empty(),
479 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
480 );
481 }
482
483 #[test]
484 fn test_properly_indented_lists() {
485 let rule = MD007ULIndent::default();
486
487 let test_cases = vec![
489 "* Item 1\n* Item 2",
490 "* Item 1\n * Item 1.1\n * Item 1.1.1",
491 "- Item 1\n - Item 1.1",
492 "+ Item 1\n + Item 1.1",
493 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
494 ];
495
496 for content in test_cases {
497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
498 let result = rule.check(&ctx).unwrap();
499 assert!(
500 result.is_empty(),
501 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
502 content,
503 result.len()
504 );
505 }
506 }
507
508 #[test]
509 fn test_under_indented_lists() {
510 let rule = MD007ULIndent::default();
511
512 let test_cases = vec![
513 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
516
517 for (content, expected_warnings, line) in test_cases {
518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
519 let result = rule.check(&ctx).unwrap();
520 assert_eq!(
521 result.len(),
522 expected_warnings,
523 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
524 );
525 if expected_warnings > 0 {
526 assert_eq!(result[0].line, line);
527 }
528 }
529 }
530
531 #[test]
532 fn test_over_indented_lists() {
533 let rule = MD007ULIndent::default();
534
535 let test_cases = vec![
536 ("* 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), ];
540
541 for (content, expected_warnings, line) in test_cases {
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
543 let result = rule.check(&ctx).unwrap();
544 assert_eq!(
545 result.len(),
546 expected_warnings,
547 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
548 );
549 if expected_warnings > 0 {
550 assert_eq!(result[0].line, line);
551 }
552 }
553 }
554
555 #[test]
556 fn test_custom_indent_2_spaces() {
557 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
560 let result = rule.check(&ctx).unwrap();
561 assert!(result.is_empty());
562 }
563
564 #[test]
565 fn test_custom_indent_3_spaces() {
566 let rule = MD007ULIndent::new(3);
568
569 let content = "* Item 1\n * Item 2\n * Item 3";
570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
571 let result = rule.check(&ctx).unwrap();
572 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
579 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
580 let result = rule.check(&ctx).unwrap();
581 assert!(result.is_empty());
582 }
583
584 #[test]
585 fn test_custom_indent_4_spaces() {
586 let rule = MD007ULIndent::new(4);
588 let content = "* Item 1\n * Item 2\n * Item 3";
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
590 let result = rule.check(&ctx).unwrap();
591 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
597 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
598 let result = rule.check(&ctx).unwrap();
599 assert!(result.is_empty());
600 }
601
602 #[test]
603 fn test_tab_indentation() {
604 let rule = MD007ULIndent::default();
605
606 let content = "* Item 1\n\t* Item 2";
608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
609 let result = rule.check(&ctx).unwrap();
610 assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
611
612 let fixed = rule.fix(&ctx).unwrap();
614 assert_eq!(fixed, "* Item 1\n * Item 2");
615
616 let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
618 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard);
619 let fixed = rule.fix(&ctx).unwrap();
620 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
623
624 let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
626 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard);
627 let fixed = rule.fix(&ctx).unwrap();
628 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
631 }
632
633 #[test]
634 fn test_mixed_ordered_unordered_lists() {
635 let rule = MD007ULIndent::default();
636
637 let content = r#"1. Ordered item
640 * Unordered sub-item (correct - 3 spaces under ordered)
641 2. Ordered sub-item
642* Unordered item
643 1. Ordered sub-item
644 * Unordered sub-item"#;
645
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
647 let result = rule.check(&ctx).unwrap();
648 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
649
650 let fixed = rule.fix(&ctx).unwrap();
652 assert_eq!(fixed, content);
653 }
654
655 #[test]
656 fn test_list_markers_variety() {
657 let rule = MD007ULIndent::default();
658
659 let content = r#"* Asterisk
661 * Nested asterisk
662- Hyphen
663 - Nested hyphen
664+ Plus
665 + Nested plus"#;
666
667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
668 let result = rule.check(&ctx).unwrap();
669 assert!(
670 result.is_empty(),
671 "All unordered list markers should work with proper indentation"
672 );
673
674 let wrong_content = r#"* Asterisk
676 * Wrong asterisk
677- Hyphen
678 - Wrong hyphen
679+ Plus
680 + Wrong plus"#;
681
682 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
683 let result = rule.check(&ctx).unwrap();
684 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
685 }
686
687 #[test]
688 fn test_empty_list_items() {
689 let rule = MD007ULIndent::default();
690 let content = "* Item 1\n* \n * Item 2";
691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
692 let result = rule.check(&ctx).unwrap();
693 assert!(
694 result.is_empty(),
695 "Empty list items should not affect indentation checks"
696 );
697 }
698
699 #[test]
700 fn test_list_with_code_blocks() {
701 let rule = MD007ULIndent::default();
702 let content = r#"* Item 1
703 ```
704 code
705 ```
706 * Item 2
707 * Item 3"#;
708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
709 let result = rule.check(&ctx).unwrap();
710 assert!(result.is_empty());
711 }
712
713 #[test]
714 fn test_list_in_front_matter() {
715 let rule = MD007ULIndent::default();
716 let content = r#"---
717tags:
718 - tag1
719 - tag2
720---
721* Item 1
722 * Item 2"#;
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
724 let result = rule.check(&ctx).unwrap();
725 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
726 }
727
728 #[test]
729 fn test_fix_preserves_content() {
730 let rule = MD007ULIndent::default();
731 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
733 let fixed = rule.fix(&ctx).unwrap();
734 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
737 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
738 }
739
740 #[test]
741 fn test_start_indented_config() {
742 let config = MD007Config {
743 start_indented: true,
744 start_indent: 4,
745 indent: 2,
746 style: md007_config::IndentStyle::TextAligned,
747 };
748 let rule = MD007ULIndent::from_config_struct(config);
749
750 let content = " * Item 1\n * Item 2\n * Item 3";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
756 let result = rule.check(&ctx).unwrap();
757 assert!(result.is_empty(), "Expected no warnings with start_indented config");
758
759 let wrong_content = " * Item 1\n * Item 2";
761 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
762 let result = rule.check(&ctx).unwrap();
763 assert_eq!(result.len(), 2);
764 assert_eq!(result[0].line, 1);
765 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
766 assert_eq!(result[1].line, 2);
767 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
768
769 let fixed = rule.fix(&ctx).unwrap();
771 assert_eq!(fixed, " * Item 1\n * Item 2");
772 }
773
774 #[test]
775 fn test_start_indented_false_allows_any_first_level() {
776 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
781 let result = rule.check(&ctx).unwrap();
782 assert!(
783 result.is_empty(),
784 "First level at any indentation should be allowed when start_indented is false"
785 );
786
787 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
790 let result = rule.check(&ctx).unwrap();
791 assert!(
792 result.is_empty(),
793 "All first-level items should be allowed at any indentation"
794 );
795 }
796
797 #[test]
798 fn test_deeply_nested_lists() {
799 let rule = MD007ULIndent::default();
800 let content = r#"* L1
801 * L2
802 * L3
803 * L4
804 * L5
805 * L6"#;
806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
807 let result = rule.check(&ctx).unwrap();
808 assert!(result.is_empty());
809
810 let wrong_content = r#"* L1
812 * L2
813 * L3
814 * L4
815 * L5
816 * L6"#;
817 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
818 let result = rule.check(&ctx).unwrap();
819 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
820 }
821
822 #[test]
823 fn test_excessive_indentation_detected() {
824 let rule = MD007ULIndent::default();
825
826 let content = "- Item 1\n - Item 2 with 5 spaces";
828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
829 let result = rule.check(&ctx).unwrap();
830 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
831 assert_eq!(result[0].line, 2);
832 assert!(result[0].message.contains("Expected 2 spaces"));
833 assert!(result[0].message.contains("found 5"));
834
835 let content = "- Item 1\n - Item 2 with 3 spaces";
837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
838 let result = rule.check(&ctx).unwrap();
839 assert_eq!(
840 result.len(),
841 1,
842 "Should detect slightly excessive indentation (3 instead of 2)"
843 );
844 assert_eq!(result[0].line, 2);
845 assert!(result[0].message.contains("Expected 2 spaces"));
846 assert!(result[0].message.contains("found 3"));
847
848 let content = "- Item 1\n - Item 2 with 1 space";
850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
851 let result = rule.check(&ctx).unwrap();
852 assert_eq!(
853 result.len(),
854 1,
855 "Should detect 1-space indent (insufficient for nesting, expected 0)"
856 );
857 assert_eq!(result[0].line, 2);
858 assert!(result[0].message.contains("Expected 0 spaces"));
859 assert!(result[0].message.contains("found 1"));
860 }
861
862 #[test]
863 fn test_excessive_indentation_with_4_space_config() {
864 let rule = MD007ULIndent::new(4);
865
866 let content = "- Formatter:\n - The stable style changed";
868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
869 let result = rule.check(&ctx).unwrap();
870
871 assert!(
874 !result.is_empty(),
875 "Should detect 5 spaces when expecting proper alignment"
876 );
877
878 let correct_content = "- Formatter:\n - The stable style changed";
880 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
881 let result = rule.check(&ctx).unwrap();
882 assert!(result.is_empty(), "Should accept correct text alignment");
883 }
884
885 #[test]
886 fn test_bullets_nested_under_numbered_items() {
887 let rule = MD007ULIndent::default();
888 let content = "\
8891. **Active Directory/LDAP**
890 - User authentication and directory services
891 - LDAP for user information and validation
892
8932. **Oracle Unified Directory (OUD)**
894 - Extended user directory services";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
896 let result = rule.check(&ctx).unwrap();
897 assert!(
899 result.is_empty(),
900 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
901 );
902 }
903
904 #[test]
905 fn test_bullets_nested_under_numbered_items_wrong_indent() {
906 let rule = MD007ULIndent::default();
907 let content = "\
9081. **Active Directory/LDAP**
909 - Wrong: only 2 spaces";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
911 let result = rule.check(&ctx).unwrap();
912 assert_eq!(
914 result.len(),
915 1,
916 "Expected warning for incorrect indentation under numbered items"
917 );
918 assert!(
919 result
920 .iter()
921 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
922 );
923 }
924
925 #[test]
926 fn test_regular_bullet_nesting_still_works() {
927 let rule = MD007ULIndent::default();
928 let content = "\
929* Top level
930 * Nested bullet (2 spaces is correct)
931 * Deeply nested (4 spaces)";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
933 let result = rule.check(&ctx).unwrap();
934 assert!(
936 result.is_empty(),
937 "Expected no warnings for standard bullet nesting, got: {result:?}"
938 );
939 }
940}