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 while let Some(&(indent, _, _, _)) = list_stack.last() {
129 if indent >= visual_marker_column {
130 list_stack.pop();
131 } else {
132 break;
133 }
134 }
135
136 if list_item.is_ordered {
138 list_stack.push((visual_marker_column, line_idx, true, visual_content_column));
141 continue;
142 }
143
144 if !list_item.is_ordered {
146 let nesting_level = list_stack.len();
148
149 let expected_indent = if self.config.start_indented {
151 self.config.start_indent + (nesting_level * self.config.indent)
152 } else {
153 match self.config.style {
154 md007_config::IndentStyle::Fixed => {
155 nesting_level * self.config.indent
157 }
158 md007_config::IndentStyle::TextAligned => {
159 if nesting_level > 0 {
161 if let Some(&(_, _parent_line_idx, _is_ordered, parent_content_visual_col)) =
163 list_stack.get(nesting_level - 1)
164 {
165 parent_content_visual_col
167 } else {
168 nesting_level * 2
171 }
172 } else {
173 0 }
175 }
176 }
177 };
178
179 let actual_content_visual_col = visual_marker_column + 2; list_stack.push((visual_marker_column, line_idx, false, actual_content_visual_col));
185
186 if !self.config.start_indented && nesting_level == 0 {
188 continue;
189 }
190
191 if visual_marker_column != expected_indent {
192 let fix = {
194 let correct_indent = " ".repeat(expected_indent);
195
196 let replacement = if line_info.blockquote.is_some() {
199 let mut blockquote_count = 0;
201 for ch in line_info.content.chars() {
202 if ch == '>' {
203 blockquote_count += 1;
204 } else if ch != ' ' && ch != '\t' {
205 break;
206 }
207 }
208 let blockquote_prefix = if blockquote_count > 1 {
210 (0..blockquote_count)
211 .map(|_| "> ")
212 .collect::<String>()
213 .trim_end()
214 .to_string()
215 } else {
216 ">".to_string()
217 };
218 format!("{blockquote_prefix} {correct_indent}")
221 } else {
222 correct_indent
223 };
224
225 let start_byte = line_info.byte_offset;
228 let mut end_byte = line_info.byte_offset;
229
230 for (i, ch) in line_info.content.chars().enumerate() {
232 if i >= list_item.marker_column {
233 break;
234 }
235 end_byte += ch.len_utf8();
236 }
237
238 Some(crate::rule::Fix {
239 range: start_byte..end_byte,
240 replacement,
241 })
242 };
243
244 warnings.push(LintWarning {
245 rule_name: Some(self.name()),
246 message: format!(
247 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
248 ),
249 line: line_idx + 1, column: 1, end_line: line_idx + 1,
252 end_column: visual_marker_column + 1, severity: Severity::Warning,
254 fix,
255 });
256 }
257 }
258 }
259 }
260 Ok(warnings)
261 }
262
263 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
265 let warnings = self.check(ctx)?;
267
268 if warnings.is_empty() {
270 return Ok(ctx.content.to_string());
271 }
272
273 let mut fixes: Vec<_> = warnings
275 .iter()
276 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
277 .collect();
278 fixes.sort_by(|a, b| b.0.cmp(&a.0));
279
280 let mut result = ctx.content.to_string();
282 for (start, end, replacement) in fixes {
283 if start < result.len() && end <= result.len() && start <= end {
284 result.replace_range(start..end, replacement);
285 }
286 }
287
288 Ok(result)
289 }
290
291 fn category(&self) -> RuleCategory {
293 RuleCategory::List
294 }
295
296 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
298 ctx.content.is_empty()
300 || !ctx
301 .lines
302 .iter()
303 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
304 }
305
306 fn as_any(&self) -> &dyn std::any::Any {
307 self
308 }
309
310 fn default_config_section(&self) -> Option<(String, toml::Value)> {
311 let default_config = MD007Config::default();
312 let json_value = serde_json::to_value(&default_config).ok()?;
313 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
314
315 if let toml::Value::Table(table) = toml_value {
316 if !table.is_empty() {
317 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
318 } else {
319 None
320 }
321 } else {
322 None
323 }
324 }
325
326 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
327 where
328 Self: Sized,
329 {
330 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
331
332 if let Some(rule_cfg) = config.rules.get("MD007") {
335 let has_explicit_indent = rule_cfg.values.contains_key("indent");
336 let has_explicit_style = rule_cfg.values.contains_key("style");
337
338 if has_explicit_indent && !has_explicit_style && rule_config.indent != 2 {
339 rule_config.style = md007_config::IndentStyle::Fixed;
342 }
343 }
344
345 Box::new(Self::from_config_struct(rule_config))
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::lint_context::LintContext;
353 use crate::rule::Rule;
354
355 #[test]
356 fn test_valid_list_indent() {
357 let rule = MD007ULIndent::default();
358 let content = "* Item 1\n * Item 2\n * Item 3";
359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
360 let result = rule.check(&ctx).unwrap();
361 assert!(
362 result.is_empty(),
363 "Expected no warnings for valid indentation, but got {} warnings",
364 result.len()
365 );
366 }
367
368 #[test]
369 fn test_invalid_list_indent() {
370 let rule = MD007ULIndent::default();
371 let content = "* Item 1\n * Item 2\n * Item 3";
372 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
373 let result = rule.check(&ctx).unwrap();
374 assert_eq!(result.len(), 2);
375 assert_eq!(result[0].line, 2);
376 assert_eq!(result[0].column, 1);
377 assert_eq!(result[1].line, 3);
378 assert_eq!(result[1].column, 1);
379 }
380
381 #[test]
382 fn test_mixed_indentation() {
383 let rule = MD007ULIndent::default();
384 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
386 let result = rule.check(&ctx).unwrap();
387 assert_eq!(result.len(), 1);
388 assert_eq!(result[0].line, 3);
389 assert_eq!(result[0].column, 1);
390 }
391
392 #[test]
393 fn test_fix_indentation() {
394 let rule = MD007ULIndent::default();
395 let content = "* Item 1\n * Item 2\n * Item 3";
396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
397 let result = rule.fix(&ctx).unwrap();
398 let expected = "* Item 1\n * Item 2\n * Item 3";
402 assert_eq!(result, expected);
403 }
404
405 #[test]
406 fn test_md007_in_yaml_code_block() {
407 let rule = MD007ULIndent::default();
408 let content = r#"```yaml
409repos:
410- repo: https://github.com/rvben/rumdl
411 rev: v0.5.0
412 hooks:
413 - id: rumdl-check
414```"#;
415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
416 let result = rule.check(&ctx).unwrap();
417 assert!(
418 result.is_empty(),
419 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
420 );
421 }
422
423 #[test]
424 fn test_blockquoted_list_indent() {
425 let rule = MD007ULIndent::default();
426 let content = "> * Item 1\n> * Item 2\n> * Item 3";
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428 let result = rule.check(&ctx).unwrap();
429 assert!(
430 result.is_empty(),
431 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
432 );
433 }
434
435 #[test]
436 fn test_blockquoted_list_invalid_indent() {
437 let rule = MD007ULIndent::default();
438 let content = "> * Item 1\n> * Item 2\n> * Item 3";
439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
440 let result = rule.check(&ctx).unwrap();
441 assert_eq!(
442 result.len(),
443 2,
444 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
445 );
446 assert_eq!(result[0].line, 2);
447 assert_eq!(result[1].line, 3);
448 }
449
450 #[test]
451 fn test_nested_blockquote_list_indent() {
452 let rule = MD007ULIndent::default();
453 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
455 let result = rule.check(&ctx).unwrap();
456 assert!(
457 result.is_empty(),
458 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
459 );
460 }
461
462 #[test]
463 fn test_blockquote_list_with_code_block() {
464 let rule = MD007ULIndent::default();
465 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
467 let result = rule.check(&ctx).unwrap();
468 assert!(
469 result.is_empty(),
470 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
471 );
472 }
473
474 #[test]
475 fn test_properly_indented_lists() {
476 let rule = MD007ULIndent::default();
477
478 let test_cases = vec![
480 "* Item 1\n* Item 2",
481 "* Item 1\n * Item 1.1\n * Item 1.1.1",
482 "- Item 1\n - Item 1.1",
483 "+ Item 1\n + Item 1.1",
484 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
485 ];
486
487 for content in test_cases {
488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
489 let result = rule.check(&ctx).unwrap();
490 assert!(
491 result.is_empty(),
492 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
493 content,
494 result.len()
495 );
496 }
497 }
498
499 #[test]
500 fn test_under_indented_lists() {
501 let rule = MD007ULIndent::default();
502
503 let test_cases = vec![
504 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
507
508 for (content, expected_warnings, line) in test_cases {
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
510 let result = rule.check(&ctx).unwrap();
511 assert_eq!(
512 result.len(),
513 expected_warnings,
514 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
515 );
516 if expected_warnings > 0 {
517 assert_eq!(result[0].line, line);
518 }
519 }
520 }
521
522 #[test]
523 fn test_over_indented_lists() {
524 let rule = MD007ULIndent::default();
525
526 let test_cases = vec![
527 ("* 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), ];
531
532 for (content, expected_warnings, line) in test_cases {
533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
534 let result = rule.check(&ctx).unwrap();
535 assert_eq!(
536 result.len(),
537 expected_warnings,
538 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
539 );
540 if expected_warnings > 0 {
541 assert_eq!(result[0].line, line);
542 }
543 }
544 }
545
546 #[test]
547 fn test_custom_indent_2_spaces() {
548 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
551 let result = rule.check(&ctx).unwrap();
552 assert!(result.is_empty());
553 }
554
555 #[test]
556 fn test_custom_indent_3_spaces() {
557 let rule = MD007ULIndent::new(3);
559
560 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()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
570 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
571 let result = rule.check(&ctx).unwrap();
572 assert!(result.is_empty());
573 }
574
575 #[test]
576 fn test_custom_indent_4_spaces() {
577 let rule = MD007ULIndent::new(4);
579 let content = "* Item 1\n * Item 2\n * Item 3";
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
581 let result = rule.check(&ctx).unwrap();
582 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
588 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
589 let result = rule.check(&ctx).unwrap();
590 assert!(result.is_empty());
591 }
592
593 #[test]
594 fn test_tab_indentation() {
595 let rule = MD007ULIndent::default();
596
597 let content = "* Item 1\n\t* Item 2";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
600 let result = rule.check(&ctx).unwrap();
601 assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
602
603 let fixed = rule.fix(&ctx).unwrap();
605 assert_eq!(fixed, "* Item 1\n * Item 2");
606
607 let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
609 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard);
610 let fixed = rule.fix(&ctx).unwrap();
611 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
614
615 let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
617 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard);
618 let fixed = rule.fix(&ctx).unwrap();
619 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
622 }
623
624 #[test]
625 fn test_mixed_ordered_unordered_lists() {
626 let rule = MD007ULIndent::default();
627
628 let content = r#"1. Ordered item
631 * Unordered sub-item (correct - 3 spaces under ordered)
632 2. Ordered sub-item
633* Unordered item
634 1. Ordered sub-item
635 * Unordered sub-item"#;
636
637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
638 let result = rule.check(&ctx).unwrap();
639 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
640
641 let fixed = rule.fix(&ctx).unwrap();
643 assert_eq!(fixed, content);
644 }
645
646 #[test]
647 fn test_list_markers_variety() {
648 let rule = MD007ULIndent::default();
649
650 let content = r#"* Asterisk
652 * Nested asterisk
653- Hyphen
654 - Nested hyphen
655+ Plus
656 + Nested plus"#;
657
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
659 let result = rule.check(&ctx).unwrap();
660 assert!(
661 result.is_empty(),
662 "All unordered list markers should work with proper indentation"
663 );
664
665 let wrong_content = r#"* Asterisk
667 * Wrong asterisk
668- Hyphen
669 - Wrong hyphen
670+ Plus
671 + Wrong plus"#;
672
673 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
674 let result = rule.check(&ctx).unwrap();
675 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
676 }
677
678 #[test]
679 fn test_empty_list_items() {
680 let rule = MD007ULIndent::default();
681 let content = "* Item 1\n* \n * Item 2";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
683 let result = rule.check(&ctx).unwrap();
684 assert!(
685 result.is_empty(),
686 "Empty list items should not affect indentation checks"
687 );
688 }
689
690 #[test]
691 fn test_list_with_code_blocks() {
692 let rule = MD007ULIndent::default();
693 let content = r#"* Item 1
694 ```
695 code
696 ```
697 * Item 2
698 * Item 3"#;
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
700 let result = rule.check(&ctx).unwrap();
701 assert!(result.is_empty());
702 }
703
704 #[test]
705 fn test_list_in_front_matter() {
706 let rule = MD007ULIndent::default();
707 let content = r#"---
708tags:
709 - tag1
710 - tag2
711---
712* Item 1
713 * Item 2"#;
714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
715 let result = rule.check(&ctx).unwrap();
716 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
717 }
718
719 #[test]
720 fn test_fix_preserves_content() {
721 let rule = MD007ULIndent::default();
722 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
724 let fixed = rule.fix(&ctx).unwrap();
725 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
728 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
729 }
730
731 #[test]
732 fn test_start_indented_config() {
733 let config = MD007Config {
734 start_indented: true,
735 start_indent: 4,
736 indent: 2,
737 style: md007_config::IndentStyle::TextAligned,
738 };
739 let rule = MD007ULIndent::from_config_struct(config);
740
741 let content = " * Item 1\n * Item 2\n * Item 3";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
747 let result = rule.check(&ctx).unwrap();
748 assert!(result.is_empty(), "Expected no warnings with start_indented config");
749
750 let wrong_content = " * Item 1\n * Item 2";
752 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
753 let result = rule.check(&ctx).unwrap();
754 assert_eq!(result.len(), 2);
755 assert_eq!(result[0].line, 1);
756 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
757 assert_eq!(result[1].line, 2);
758 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
759
760 let fixed = rule.fix(&ctx).unwrap();
762 assert_eq!(fixed, " * Item 1\n * Item 2");
763 }
764
765 #[test]
766 fn test_start_indented_false_allows_any_first_level() {
767 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
772 let result = rule.check(&ctx).unwrap();
773 assert!(
774 result.is_empty(),
775 "First level at any indentation should be allowed when start_indented is false"
776 );
777
778 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
781 let result = rule.check(&ctx).unwrap();
782 assert!(
783 result.is_empty(),
784 "All first-level items should be allowed at any indentation"
785 );
786 }
787
788 #[test]
789 fn test_deeply_nested_lists() {
790 let rule = MD007ULIndent::default();
791 let content = r#"* L1
792 * L2
793 * L3
794 * L4
795 * L5
796 * L6"#;
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
798 let result = rule.check(&ctx).unwrap();
799 assert!(result.is_empty());
800
801 let wrong_content = r#"* L1
803 * L2
804 * L3
805 * L4
806 * L5
807 * L6"#;
808 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
809 let result = rule.check(&ctx).unwrap();
810 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
811 }
812
813 #[test]
814 fn test_excessive_indentation_detected() {
815 let rule = MD007ULIndent::default();
816
817 let content = "- Item 1\n - Item 2 with 5 spaces";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
820 let result = rule.check(&ctx).unwrap();
821 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
822 assert_eq!(result[0].line, 2);
823 assert!(result[0].message.contains("Expected 2 spaces"));
824 assert!(result[0].message.contains("found 5"));
825
826 let content = "- Item 1\n - Item 2 with 3 spaces";
828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
829 let result = rule.check(&ctx).unwrap();
830 assert_eq!(
831 result.len(),
832 1,
833 "Should detect slightly excessive indentation (3 instead of 2)"
834 );
835 assert_eq!(result[0].line, 2);
836 assert!(result[0].message.contains("Expected 2 spaces"));
837 assert!(result[0].message.contains("found 3"));
838
839 let content = "- Item 1\n - Item 2 with 1 space";
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
842 let result = rule.check(&ctx).unwrap();
843 assert_eq!(
844 result.len(),
845 1,
846 "Should detect insufficient indentation (1 instead of 2)"
847 );
848 assert_eq!(result[0].line, 2);
849 assert!(result[0].message.contains("Expected 2 spaces"));
850 assert!(result[0].message.contains("found 1"));
851 }
852
853 #[test]
854 fn test_excessive_indentation_with_4_space_config() {
855 let rule = MD007ULIndent::new(4);
856
857 let content = "- Formatter:\n - The stable style changed";
859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
860 let result = rule.check(&ctx).unwrap();
861
862 assert!(
865 !result.is_empty(),
866 "Should detect 5 spaces when expecting proper alignment"
867 );
868
869 let correct_content = "- Formatter:\n - The stable style changed";
871 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
872 let result = rule.check(&ctx).unwrap();
873 assert!(result.is_empty(), "Should accept correct text alignment");
874 }
875
876 #[test]
877 fn test_bullets_nested_under_numbered_items() {
878 let rule = MD007ULIndent::default();
879 let content = "\
8801. **Active Directory/LDAP**
881 - User authentication and directory services
882 - LDAP for user information and validation
883
8842. **Oracle Unified Directory (OUD)**
885 - Extended user directory services";
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
887 let result = rule.check(&ctx).unwrap();
888 assert!(
890 result.is_empty(),
891 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
892 );
893 }
894
895 #[test]
896 fn test_bullets_nested_under_numbered_items_wrong_indent() {
897 let rule = MD007ULIndent::default();
898 let content = "\
8991. **Active Directory/LDAP**
900 - Wrong: only 2 spaces";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
902 let result = rule.check(&ctx).unwrap();
903 assert_eq!(
905 result.len(),
906 1,
907 "Expected warning for incorrect indentation under numbered items"
908 );
909 assert!(
910 result
911 .iter()
912 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
913 );
914 }
915
916 #[test]
917 fn test_regular_bullet_nesting_still_works() {
918 let rule = MD007ULIndent::default();
919 let content = "\
920* Top level
921 * Nested bullet (2 spaces is correct)
922 * Deeply nested (4 spaces)";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
924 let result = rule.check(&ctx).unwrap();
925 assert!(
927 result.is_empty(),
928 "Expected no warnings for standard bullet nesting, got: {result:?}"
929 );
930 }
931}