1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
7use crate::utils::element_cache::{ElementCache, ListMarkerType};
8use crate::utils::regex_cache::UNORDERED_LIST_MARKER_REGEX;
9use toml;
10
11mod md007_config;
12use md007_config::MD007Config;
13
14#[derive(Debug, Clone, Default)]
15pub struct MD007ULIndent {
16 config: MD007Config,
17}
18
19impl MD007ULIndent {
20 pub fn new(indent: usize) -> Self {
21 Self {
22 config: MD007Config {
23 indent,
24 start_indented: false,
25 start_indent: 2,
26 style: md007_config::IndentStyle::TextAligned,
27 },
28 }
29 }
30
31 pub fn from_config_struct(config: MD007Config) -> Self {
32 Self { config }
33 }
34
35 fn get_parent_info(
38 &self,
39 ctx: &crate::lint_context::LintContext,
40 line_number: usize,
41 indentation: usize,
42 ) -> (bool, Option<usize>) {
43 for line_idx in (1..line_number).rev() {
45 if let Some(line_info) = ctx.line_info(line_idx) {
46 if let Some(list_item) = &line_info.list_item {
47 if list_item.marker_column < indentation {
49 if list_item.is_ordered {
51 let text_start_pos = list_item.marker_column + list_item.marker.len() + 1; return (true, Some(text_start_pos));
57 } else {
58 let text_start_pos = list_item.marker_column + 2; return (true, Some(text_start_pos));
62 }
63 }
64 }
65 else if !line_info.is_blank && line_info.indent == 0 {
67 break;
68 }
69 }
70 }
71 (false, None)
72 }
73}
74
75impl Rule for MD007ULIndent {
76 fn name(&self) -> &'static str {
77 "MD007"
78 }
79
80 fn description(&self) -> &'static str {
81 "Unordered list indentation"
82 }
83
84 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
86 let content = ctx.content;
87
88 if content.is_empty() {
90 return Ok(Vec::new());
91 }
92
93 if !content.contains('*') && !content.contains('-') && !content.contains('+') {
95 return Ok(Vec::new());
96 }
97
98 let element_cache = ElementCache::new(content);
99 let mut warnings = Vec::new();
100
101 for item in element_cache.get_list_items() {
102 if element_cache.is_in_code_block(item.line_number) {
105 continue;
106 }
107 if matches!(
108 item.marker_type,
109 ListMarkerType::Asterisk | ListMarkerType::Plus | ListMarkerType::Minus
110 ) {
111 if !self.config.start_indented && item.nesting_level == 0 {
113 continue;
114 }
115
116 let expected_indent = if self.config.start_indented {
117 self.config.start_indent + (item.nesting_level * self.config.indent)
118 } else {
119 match self.config.style {
120 md007_config::IndentStyle::Fixed => {
121 item.nesting_level * self.config.indent
123 }
124 md007_config::IndentStyle::TextAligned => {
125 if item.nesting_level > 0 {
127 let (has_parent, expected_pos) =
128 self.get_parent_info(ctx, item.line_number, item.indentation);
129 if has_parent {
130 if let Some(pos) = expected_pos {
131 pos
133 } else {
134 item.nesting_level * self.config.indent
136 }
137 } else {
138 item.nesting_level * self.config.indent
139 }
140 } else {
141 item.nesting_level * self.config.indent
142 }
143 }
144 }
145 };
146
147 if item.indentation != expected_indent {
148 let fix = {
150 let lines: Vec<&str> = content.lines().collect();
151 if let Some(line) = lines.get(item.line_number - 1) {
152 if UNORDERED_LIST_MARKER_REGEX.captures(line).is_some() {
154 let correct_indent = " ".repeat(expected_indent);
155
156 let line_index = crate::utils::range_utils::LineIndex::new(content.to_string());
158
159 let start_col = item.blockquote_prefix.len() + 1; let end_col = item.blockquote_prefix.len() + item.indent_str.len() + 1; let start_byte = line_index.line_col_to_byte_range(item.line_number, start_col).start;
164 let end_byte = line_index.line_col_to_byte_range(item.line_number, end_col).start;
165
166 let replacement = correct_indent;
168
169 Some(crate::rule::Fix {
170 range: start_byte..end_byte,
171 replacement,
172 })
173 } else {
174 None
175 }
176 } else {
177 None
178 }
179 };
180
181 warnings.push(LintWarning {
182 rule_name: Some(self.name()),
183 message: format!(
184 "Expected {} spaces for indent depth {}, found {}",
185 expected_indent, item.nesting_level, item.indentation
186 ),
187 line: item.line_number,
188 column: item.blockquote_prefix.len() + 1, end_line: item.line_number,
190 end_column: item.blockquote_prefix.len() + item.indent_str.len() + 1, severity: Severity::Warning,
192 fix,
193 });
194 }
195 }
196 }
197 Ok(warnings)
198 }
199
200 fn check_with_structure(
202 &self,
203 ctx: &crate::lint_context::LintContext,
204 doc_structure: &DocumentStructure,
205 ) -> LintResult {
206 let content = ctx.content;
207
208 if doc_structure.list_lines.is_empty() {
210 return Ok(Vec::new());
211 }
212
213 let element_cache = ElementCache::new(content);
215 let mut warnings = Vec::new();
216
217 for item in element_cache.get_list_items() {
218 if !doc_structure.list_lines.contains(&item.line_number) {
220 continue;
221 }
222
223 if doc_structure.is_in_code_block(item.line_number) {
225 continue;
226 }
227
228 if matches!(
229 item.marker_type,
230 ListMarkerType::Asterisk | ListMarkerType::Plus | ListMarkerType::Minus
231 ) {
232 if !self.config.start_indented && item.nesting_level == 0 {
234 continue;
235 }
236
237 let expected_indent = if self.config.start_indented {
238 self.config.start_indent + (item.nesting_level * self.config.indent)
239 } else {
240 match self.config.style {
241 md007_config::IndentStyle::Fixed => {
242 item.nesting_level * self.config.indent
244 }
245 md007_config::IndentStyle::TextAligned => {
246 if item.nesting_level > 0 {
248 let (has_parent, expected_pos) =
249 self.get_parent_info(ctx, item.line_number, item.indentation);
250 if has_parent {
251 if let Some(pos) = expected_pos {
252 pos
254 } else {
255 item.nesting_level * self.config.indent
257 }
258 } else {
259 item.nesting_level * self.config.indent
260 }
261 } else {
262 item.nesting_level * self.config.indent
263 }
264 }
265 }
266 };
267
268 if item.indentation != expected_indent {
269 let fix = {
271 let lines: Vec<&str> = content.lines().collect();
272 if let Some(line) = lines.get(item.line_number - 1) {
273 if UNORDERED_LIST_MARKER_REGEX.captures(line).is_some() {
275 let correct_indent = " ".repeat(expected_indent);
276
277 let line_index = crate::utils::range_utils::LineIndex::new(content.to_string());
279
280 let start_col = item.blockquote_prefix.len() + 1; let end_col = item.blockquote_prefix.len() + item.indent_str.len() + 1; let start_byte = line_index.line_col_to_byte_range(item.line_number, start_col).start;
285 let end_byte = line_index.line_col_to_byte_range(item.line_number, end_col).start;
286
287 let replacement = correct_indent;
289
290 Some(crate::rule::Fix {
291 range: start_byte..end_byte,
292 replacement,
293 })
294 } else {
295 None
296 }
297 } else {
298 None
299 }
300 };
301
302 warnings.push(LintWarning {
303 rule_name: Some(self.name()),
304 message: format!(
305 "Expected {} spaces for indent depth {}, found {}",
306 expected_indent, item.nesting_level, item.indentation
307 ),
308 line: item.line_number,
309 column: item.blockquote_prefix.len() + 1, end_line: item.line_number,
311 end_column: item.blockquote_prefix.len() + item.indent_str.len() + 1, severity: Severity::Warning,
313 fix,
314 });
315 }
316 }
317 }
318 Ok(warnings)
319 }
320
321 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
322 let warnings = self.check(ctx)?;
324
325 if warnings.is_empty() {
327 return Ok(ctx.content.to_string());
328 }
329
330 let mut fixes: Vec<_> = warnings
332 .iter()
333 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
334 .collect();
335 fixes.sort_by(|a, b| b.0.cmp(&a.0));
336
337 let mut result = ctx.content.to_string();
339 for (start, end, replacement) in fixes {
340 if start < result.len() && end <= result.len() && start <= end {
341 result.replace_range(start..end, replacement);
342 }
343 }
344
345 Ok(result)
346 }
347
348 fn category(&self) -> RuleCategory {
350 RuleCategory::List
351 }
352
353 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
355 ctx.content.is_empty()
357 || !ctx
358 .lines
359 .iter()
360 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
361 }
362
363 fn as_any(&self) -> &dyn std::any::Any {
364 self
365 }
366
367 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
368 Some(self)
369 }
370
371 fn default_config_section(&self) -> Option<(String, toml::Value)> {
372 let default_config = MD007Config::default();
373 let json_value = serde_json::to_value(&default_config).ok()?;
374 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
375
376 if let toml::Value::Table(table) = toml_value {
377 if !table.is_empty() {
378 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
379 } else {
380 None
381 }
382 } else {
383 None
384 }
385 }
386
387 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
388 where
389 Self: Sized,
390 {
391 let rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
392 Box::new(Self::from_config_struct(rule_config))
393 }
394}
395
396impl DocumentStructureExtensions for MD007ULIndent {
397 fn has_relevant_elements(
398 &self,
399 _ctx: &crate::lint_context::LintContext,
400 doc_structure: &DocumentStructure,
401 ) -> bool {
402 !doc_structure.list_lines.is_empty()
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::lint_context::LintContext;
411 use crate::rule::Rule;
412
413 #[test]
414 fn test_valid_list_indent() {
415 let rule = MD007ULIndent::default();
416 let content = "* Item 1\n * Item 2\n * Item 3";
417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
418 let result = rule.check(&ctx).unwrap();
419 assert!(
420 result.is_empty(),
421 "Expected no warnings for valid indentation, but got {} warnings",
422 result.len()
423 );
424 }
425
426 #[test]
427 fn test_invalid_list_indent() {
428 let rule = MD007ULIndent::default();
429 let content = "* Item 1\n * Item 2\n * Item 3";
430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
431 let result = rule.check(&ctx).unwrap();
432 assert_eq!(result.len(), 2);
433 assert_eq!(result[0].line, 2);
434 assert_eq!(result[0].column, 1);
435 assert_eq!(result[1].line, 3);
436 assert_eq!(result[1].column, 1);
437 }
438
439 #[test]
440 fn test_mixed_indentation() {
441 let rule = MD007ULIndent::default();
442 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
444 let result = rule.check(&ctx).unwrap();
445 assert_eq!(result.len(), 1);
446 assert_eq!(result[0].line, 3);
447 assert_eq!(result[0].column, 1);
448 }
449
450 #[test]
451 fn test_fix_indentation() {
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.fix(&ctx).unwrap();
456 let expected = "* Item 1\n * Item 2\n * Item 3";
460 assert_eq!(result, expected);
461 }
462
463 #[test]
464 fn test_md007_in_yaml_code_block() {
465 let rule = MD007ULIndent::default();
466 let content = r#"```yaml
467repos:
468- repo: https://github.com/rvben/rumdl
469 rev: v0.5.0
470 hooks:
471 - id: rumdl-check
472```"#;
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
474 let result = rule.check(&ctx).unwrap();
475 assert!(
476 result.is_empty(),
477 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
478 );
479 }
480
481 #[test]
482 fn test_blockquoted_list_indent() {
483 let rule = MD007ULIndent::default();
484 let content = "> * Item 1\n> * Item 2\n> * Item 3";
485 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
486 let result = rule.check(&ctx).unwrap();
487 assert!(
488 result.is_empty(),
489 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
490 );
491 }
492
493 #[test]
494 fn test_blockquoted_list_invalid_indent() {
495 let rule = MD007ULIndent::default();
496 let content = "> * Item 1\n> * Item 2\n> * Item 3";
497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
498 let result = rule.check(&ctx).unwrap();
499 assert_eq!(
500 result.len(),
501 2,
502 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
503 );
504 assert_eq!(result[0].line, 2);
505 assert_eq!(result[1].line, 3);
506 }
507
508 #[test]
509 fn test_nested_blockquote_list_indent() {
510 let rule = MD007ULIndent::default();
511 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
513 let result = rule.check(&ctx).unwrap();
514 assert!(
515 result.is_empty(),
516 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
517 );
518 }
519
520 #[test]
521 fn test_blockquote_list_with_code_block() {
522 let rule = MD007ULIndent::default();
523 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
524 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
525 let result = rule.check(&ctx).unwrap();
526 assert!(
527 result.is_empty(),
528 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
529 );
530 }
531
532 #[test]
533 fn test_properly_indented_lists() {
534 let rule = MD007ULIndent::default();
535
536 let test_cases = vec![
538 "* Item 1\n* Item 2",
539 "* Item 1\n * Item 1.1\n * Item 1.1.1",
540 "- Item 1\n - Item 1.1",
541 "+ Item 1\n + Item 1.1",
542 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
543 ];
544
545 for content in test_cases {
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
547 let result = rule.check(&ctx).unwrap();
548 assert!(
549 result.is_empty(),
550 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
551 content,
552 result.len()
553 );
554 }
555 }
556
557 #[test]
558 fn test_under_indented_lists() {
559 let rule = MD007ULIndent::default();
560
561 let test_cases = vec![
562 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
565
566 for (content, expected_warnings, line) in test_cases {
567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
568 let result = rule.check(&ctx).unwrap();
569 assert_eq!(
570 result.len(),
571 expected_warnings,
572 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
573 );
574 if expected_warnings > 0 {
575 assert_eq!(result[0].line, line);
576 }
577 }
578 }
579
580 #[test]
581 fn test_over_indented_lists() {
582 let rule = MD007ULIndent::default();
583
584 let test_cases = vec![
585 ("* 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), ];
589
590 for (content, expected_warnings, line) in test_cases {
591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
592 let result = rule.check(&ctx).unwrap();
593 assert_eq!(
594 result.len(),
595 expected_warnings,
596 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
597 );
598 if expected_warnings > 0 {
599 assert_eq!(result[0].line, line);
600 }
601 }
602 }
603
604 #[test]
605 fn test_custom_indent_2_spaces() {
606 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
609 let result = rule.check(&ctx).unwrap();
610 assert!(result.is_empty());
611 }
612
613 #[test]
614 fn test_custom_indent_3_spaces() {
615 let rule = MD007ULIndent::new(3);
617
618 let content = "* Item 1\n * Item 2\n * Item 3";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
620 let result = rule.check(&ctx).unwrap();
621 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
628 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
629 let result = rule.check(&ctx).unwrap();
630 assert!(result.is_empty());
631 }
632
633 #[test]
634 fn test_custom_indent_4_spaces() {
635 let rule = MD007ULIndent::new(4);
637 let content = "* Item 1\n * Item 2\n * Item 3";
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
639 let result = rule.check(&ctx).unwrap();
640 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
646 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
647 let result = rule.check(&ctx).unwrap();
648 assert!(result.is_empty());
649 }
650
651 #[test]
652 fn test_tab_indentation() {
653 let rule = MD007ULIndent::default();
654
655 let content = "* Item 1\n\t* Item 2";
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
658 let result = rule.check(&ctx).unwrap();
659 assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
660
661 let fixed = rule.fix(&ctx).unwrap();
663 assert_eq!(fixed, "* Item 1\n * Item 2");
664
665 let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
667 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard);
668 let fixed = rule.fix(&ctx).unwrap();
669 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
671
672 let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
674 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard);
675 let fixed = rule.fix(&ctx).unwrap();
676 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
678 }
679
680 #[test]
681 fn test_mixed_ordered_unordered_lists() {
682 let rule = MD007ULIndent::default();
683
684 let content = r#"1. Ordered item
687 * Unordered sub-item (correct - 3 spaces under ordered)
688 2. Ordered sub-item
689* Unordered item
690 1. Ordered sub-item
691 * Unordered sub-item"#;
692
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
694 let result = rule.check(&ctx).unwrap();
695 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
696
697 let fixed = rule.fix(&ctx).unwrap();
699 assert_eq!(fixed, content);
700 }
701
702 #[test]
703 fn test_list_markers_variety() {
704 let rule = MD007ULIndent::default();
705
706 let content = r#"* Asterisk
708 * Nested asterisk
709- Hyphen
710 - Nested hyphen
711+ Plus
712 + Nested plus"#;
713
714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
715 let result = rule.check(&ctx).unwrap();
716 assert!(
717 result.is_empty(),
718 "All unordered list markers should work with proper indentation"
719 );
720
721 let wrong_content = r#"* Asterisk
723 * Wrong asterisk
724- Hyphen
725 - Wrong hyphen
726+ Plus
727 + Wrong plus"#;
728
729 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
730 let result = rule.check(&ctx).unwrap();
731 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
732 }
733
734 #[test]
735 fn test_empty_list_items() {
736 let rule = MD007ULIndent::default();
737 let content = "* Item 1\n* \n * Item 2";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
739 let result = rule.check(&ctx).unwrap();
740 assert!(
741 result.is_empty(),
742 "Empty list items should not affect indentation checks"
743 );
744 }
745
746 #[test]
747 fn test_list_with_code_blocks() {
748 let rule = MD007ULIndent::default();
749 let content = r#"* Item 1
750 ```
751 code
752 ```
753 * Item 2
754 * Item 3"#;
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
756 let result = rule.check(&ctx).unwrap();
757 assert!(result.is_empty());
758 }
759
760 #[test]
761 fn test_list_in_front_matter() {
762 let rule = MD007ULIndent::default();
763 let content = r#"---
764tags:
765 - tag1
766 - tag2
767---
768* Item 1
769 * Item 2"#;
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
771 let result = rule.check(&ctx).unwrap();
772 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
773 }
774
775 #[test]
776 fn test_fix_preserves_content() {
777 let rule = MD007ULIndent::default();
778 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
780 let fixed = rule.fix(&ctx).unwrap();
781 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
783 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
784 }
785
786 #[test]
787 fn test_start_indented_config() {
788 let config = MD007Config {
789 start_indented: true,
790 start_indent: 4,
791 indent: 2,
792 style: md007_config::IndentStyle::TextAligned,
793 };
794 let rule = MD007ULIndent::from_config_struct(config);
795
796 let content = " * Item 1\n * Item 2\n * Item 3";
801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
802 let result = rule.check(&ctx).unwrap();
803 assert!(result.is_empty(), "Expected no warnings with start_indented config");
804
805 let wrong_content = " * Item 1\n * Item 2";
807 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
808 let result = rule.check(&ctx).unwrap();
809 assert_eq!(result.len(), 2);
810 assert_eq!(result[0].line, 1);
811 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
812 assert_eq!(result[1].line, 2);
813 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
814
815 let fixed = rule.fix(&ctx).unwrap();
817 assert_eq!(fixed, " * Item 1\n * Item 2");
818 }
819
820 #[test]
821 fn test_start_indented_false_allows_any_first_level() {
822 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
827 let result = rule.check(&ctx).unwrap();
828 assert!(
829 result.is_empty(),
830 "First level at any indentation should be allowed when start_indented is false"
831 );
832
833 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
836 let result = rule.check(&ctx).unwrap();
837 assert!(
838 result.is_empty(),
839 "All first-level items should be allowed at any indentation"
840 );
841 }
842
843 #[test]
844 fn test_deeply_nested_lists() {
845 let rule = MD007ULIndent::default();
846 let content = r#"* L1
847 * L2
848 * L3
849 * L4
850 * L5
851 * L6"#;
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
853 let result = rule.check(&ctx).unwrap();
854 assert!(result.is_empty());
855
856 let wrong_content = r#"* L1
858 * L2
859 * L3
860 * L4
861 * L5
862 * L6"#;
863 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
864 let result = rule.check(&ctx).unwrap();
865 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
866 }
867
868 #[test]
869 fn test_bullets_nested_under_numbered_items() {
870 let rule = MD007ULIndent::default();
871 let content = "\
8721. **Active Directory/LDAP**
873 - User authentication and directory services
874 - LDAP for user information and validation
875
8762. **Oracle Unified Directory (OUD)**
877 - Extended user directory services";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
879 let result = rule.check(&ctx).unwrap();
880 assert!(
882 result.is_empty(),
883 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
884 );
885 }
886
887 #[test]
888 fn test_bullets_nested_under_numbered_items_wrong_indent() {
889 let rule = MD007ULIndent::default();
890 let content = "\
8911. **Active Directory/LDAP**
892 - Wrong: only 2 spaces";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
894 let result = rule.check(&ctx).unwrap();
895 assert_eq!(
897 result.len(),
898 1,
899 "Expected warning for incorrect indentation under numbered items"
900 );
901 assert!(
902 result
903 .iter()
904 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
905 );
906 }
907
908 #[test]
909 fn test_regular_bullet_nesting_still_works() {
910 let rule = MD007ULIndent::default();
911 let content = "\
912* Top level
913 * Nested bullet (2 spaces is correct)
914 * Deeply nested (4 spaces)";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
916 let result = rule.check(&ctx).unwrap();
917 assert!(
919 result.is_empty(),
920 "Expected no warnings for standard bullet nesting, got: {result:?}"
921 );
922 }
923}