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