1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::element_cache::{ElementCache, ListMarkerType};
7use crate::utils::regex_cache::UNORDERED_LIST_MARKER_REGEX;
8use toml;
9
10mod md007_config;
11use md007_config::MD007Config;
12
13#[derive(Debug, Clone, Default)]
14pub struct MD007ULIndent {
15 config: MD007Config,
16}
17
18impl MD007ULIndent {
19 pub fn new(indent: usize) -> Self {
20 Self {
21 config: MD007Config {
22 indent,
23 start_indented: false,
24 start_indent: 2,
25 style: md007_config::IndentStyle::TextAligned,
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 match self.config.style {
119 md007_config::IndentStyle::Fixed => {
120 item.nesting_level * self.config.indent
122 }
123 md007_config::IndentStyle::TextAligned => {
124 if item.nesting_level > 0 {
126 let (has_parent, expected_pos) =
127 self.get_parent_info(ctx, item.line_number, item.indentation);
128 if has_parent {
129 if let Some(pos) = expected_pos {
130 pos
132 } else {
133 item.nesting_level * self.config.indent
135 }
136 } else {
137 item.nesting_level * self.config.indent
138 }
139 } else {
140 item.nesting_level * self.config.indent
141 }
142 }
143 }
144 };
145
146 if item.indentation != expected_indent {
147 let fix = {
149 let lines: Vec<&str> = content.lines().collect();
150 if let Some(line) = lines.get(item.line_number - 1) {
151 if UNORDERED_LIST_MARKER_REGEX.captures(line).is_some() {
153 let correct_indent = " ".repeat(expected_indent);
154
155 let line_index = crate::utils::range_utils::LineIndex::new(content.to_string());
157
158 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;
163 let end_byte = line_index.line_col_to_byte_range(item.line_number, end_col).start;
164
165 let replacement = correct_indent;
167
168 Some(crate::rule::Fix {
169 range: start_byte..end_byte,
170 replacement,
171 })
172 } else {
173 None
174 }
175 } else {
176 None
177 }
178 };
179
180 warnings.push(LintWarning {
181 rule_name: Some(self.name()),
182 message: format!(
183 "Expected {} spaces for indent depth {}, found {}",
184 expected_indent, item.nesting_level, item.indentation
185 ),
186 line: item.line_number,
187 column: item.blockquote_prefix.len() + 1, end_line: item.line_number,
189 end_column: item.blockquote_prefix.len() + item.indent_str.len() + 1, severity: Severity::Warning,
191 fix,
192 });
193 }
194 }
195 }
196 Ok(warnings)
197 }
198
199 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
201 let warnings = self.check(ctx)?;
203
204 if warnings.is_empty() {
206 return Ok(ctx.content.to_string());
207 }
208
209 let mut fixes: Vec<_> = warnings
211 .iter()
212 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
213 .collect();
214 fixes.sort_by(|a, b| b.0.cmp(&a.0));
215
216 let mut result = ctx.content.to_string();
218 for (start, end, replacement) in fixes {
219 if start < result.len() && end <= result.len() && start <= end {
220 result.replace_range(start..end, replacement);
221 }
222 }
223
224 Ok(result)
225 }
226
227 fn category(&self) -> RuleCategory {
229 RuleCategory::List
230 }
231
232 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
234 ctx.content.is_empty()
236 || !ctx
237 .lines
238 .iter()
239 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
240 }
241
242 fn as_any(&self) -> &dyn std::any::Any {
243 self
244 }
245
246 fn default_config_section(&self) -> Option<(String, toml::Value)> {
247 let default_config = MD007Config::default();
248 let json_value = serde_json::to_value(&default_config).ok()?;
249 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
250
251 if let toml::Value::Table(table) = toml_value {
252 if !table.is_empty() {
253 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
254 } else {
255 None
256 }
257 } else {
258 None
259 }
260 }
261
262 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
263 where
264 Self: Sized,
265 {
266 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
267
268 if let Some(rule_cfg) = config.rules.get("MD007") {
271 let has_explicit_indent = rule_cfg.values.contains_key("indent");
272 let has_explicit_style = rule_cfg.values.contains_key("style");
273
274 if has_explicit_indent && !has_explicit_style && rule_config.indent != 2 {
275 rule_config.style = md007_config::IndentStyle::Fixed;
278 }
279 }
280
281 Box::new(Self::from_config_struct(rule_config))
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::lint_context::LintContext;
289 use crate::rule::Rule;
290
291 #[test]
292 fn test_valid_list_indent() {
293 let rule = MD007ULIndent::default();
294 let content = "* Item 1\n * Item 2\n * Item 3";
295 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
296 let result = rule.check(&ctx).unwrap();
297 assert!(
298 result.is_empty(),
299 "Expected no warnings for valid indentation, but got {} warnings",
300 result.len()
301 );
302 }
303
304 #[test]
305 fn test_invalid_list_indent() {
306 let rule = MD007ULIndent::default();
307 let content = "* Item 1\n * Item 2\n * Item 3";
308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
309 let result = rule.check(&ctx).unwrap();
310 assert_eq!(result.len(), 2);
311 assert_eq!(result[0].line, 2);
312 assert_eq!(result[0].column, 1);
313 assert_eq!(result[1].line, 3);
314 assert_eq!(result[1].column, 1);
315 }
316
317 #[test]
318 fn test_mixed_indentation() {
319 let rule = MD007ULIndent::default();
320 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
322 let result = rule.check(&ctx).unwrap();
323 assert_eq!(result.len(), 1);
324 assert_eq!(result[0].line, 3);
325 assert_eq!(result[0].column, 1);
326 }
327
328 #[test]
329 fn test_fix_indentation() {
330 let rule = MD007ULIndent::default();
331 let content = "* Item 1\n * Item 2\n * Item 3";
332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
333 let result = rule.fix(&ctx).unwrap();
334 let expected = "* Item 1\n * Item 2\n * Item 3";
338 assert_eq!(result, expected);
339 }
340
341 #[test]
342 fn test_md007_in_yaml_code_block() {
343 let rule = MD007ULIndent::default();
344 let content = r#"```yaml
345repos:
346- repo: https://github.com/rvben/rumdl
347 rev: v0.5.0
348 hooks:
349 - id: rumdl-check
350```"#;
351 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
352 let result = rule.check(&ctx).unwrap();
353 assert!(
354 result.is_empty(),
355 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
356 );
357 }
358
359 #[test]
360 fn test_blockquoted_list_indent() {
361 let rule = MD007ULIndent::default();
362 let content = "> * Item 1\n> * Item 2\n> * Item 3";
363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
364 let result = rule.check(&ctx).unwrap();
365 assert!(
366 result.is_empty(),
367 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
368 );
369 }
370
371 #[test]
372 fn test_blockquoted_list_invalid_indent() {
373 let rule = MD007ULIndent::default();
374 let content = "> * Item 1\n> * Item 2\n> * Item 3";
375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
376 let result = rule.check(&ctx).unwrap();
377 assert_eq!(
378 result.len(),
379 2,
380 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
381 );
382 assert_eq!(result[0].line, 2);
383 assert_eq!(result[1].line, 3);
384 }
385
386 #[test]
387 fn test_nested_blockquote_list_indent() {
388 let rule = MD007ULIndent::default();
389 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
391 let result = rule.check(&ctx).unwrap();
392 assert!(
393 result.is_empty(),
394 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
395 );
396 }
397
398 #[test]
399 fn test_blockquote_list_with_code_block() {
400 let rule = MD007ULIndent::default();
401 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
403 let result = rule.check(&ctx).unwrap();
404 assert!(
405 result.is_empty(),
406 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
407 );
408 }
409
410 #[test]
411 fn test_properly_indented_lists() {
412 let rule = MD007ULIndent::default();
413
414 let test_cases = vec![
416 "* Item 1\n* Item 2",
417 "* Item 1\n * Item 1.1\n * Item 1.1.1",
418 "- Item 1\n - Item 1.1",
419 "+ Item 1\n + Item 1.1",
420 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
421 ];
422
423 for content in test_cases {
424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
425 let result = rule.check(&ctx).unwrap();
426 assert!(
427 result.is_empty(),
428 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
429 content,
430 result.len()
431 );
432 }
433 }
434
435 #[test]
436 fn test_under_indented_lists() {
437 let rule = MD007ULIndent::default();
438
439 let test_cases = vec![
440 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
443
444 for (content, expected_warnings, line) in test_cases {
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
446 let result = rule.check(&ctx).unwrap();
447 assert_eq!(
448 result.len(),
449 expected_warnings,
450 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
451 );
452 if expected_warnings > 0 {
453 assert_eq!(result[0].line, line);
454 }
455 }
456 }
457
458 #[test]
459 fn test_over_indented_lists() {
460 let rule = MD007ULIndent::default();
461
462 let test_cases = vec![
463 ("* 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), ];
467
468 for (content, expected_warnings, line) in test_cases {
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
470 let result = rule.check(&ctx).unwrap();
471 assert_eq!(
472 result.len(),
473 expected_warnings,
474 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
475 );
476 if expected_warnings > 0 {
477 assert_eq!(result[0].line, line);
478 }
479 }
480 }
481
482 #[test]
483 fn test_custom_indent_2_spaces() {
484 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
487 let result = rule.check(&ctx).unwrap();
488 assert!(result.is_empty());
489 }
490
491 #[test]
492 fn test_custom_indent_3_spaces() {
493 let rule = MD007ULIndent::new(3);
495
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!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
506 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
507 let result = rule.check(&ctx).unwrap();
508 assert!(result.is_empty());
509 }
510
511 #[test]
512 fn test_custom_indent_4_spaces() {
513 let rule = MD007ULIndent::new(4);
515 let content = "* Item 1\n * Item 2\n * Item 3";
516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
517 let result = rule.check(&ctx).unwrap();
518 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
524 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
525 let result = rule.check(&ctx).unwrap();
526 assert!(result.is_empty());
527 }
528
529 #[test]
530 fn test_tab_indentation() {
531 let rule = MD007ULIndent::default();
532
533 let content = "* Item 1\n\t* Item 2";
535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
536 let result = rule.check(&ctx).unwrap();
537 assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
538
539 let fixed = rule.fix(&ctx).unwrap();
541 assert_eq!(fixed, "* Item 1\n * Item 2");
542
543 let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
545 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard);
546 let fixed = rule.fix(&ctx).unwrap();
547 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
549
550 let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
552 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard);
553 let fixed = rule.fix(&ctx).unwrap();
554 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
556 }
557
558 #[test]
559 fn test_mixed_ordered_unordered_lists() {
560 let rule = MD007ULIndent::default();
561
562 let content = r#"1. Ordered item
565 * Unordered sub-item (correct - 3 spaces under ordered)
566 2. Ordered sub-item
567* Unordered item
568 1. Ordered sub-item
569 * Unordered sub-item"#;
570
571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
572 let result = rule.check(&ctx).unwrap();
573 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
574
575 let fixed = rule.fix(&ctx).unwrap();
577 assert_eq!(fixed, content);
578 }
579
580 #[test]
581 fn test_list_markers_variety() {
582 let rule = MD007ULIndent::default();
583
584 let content = r#"* Asterisk
586 * Nested asterisk
587- Hyphen
588 - Nested hyphen
589+ Plus
590 + Nested plus"#;
591
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
593 let result = rule.check(&ctx).unwrap();
594 assert!(
595 result.is_empty(),
596 "All unordered list markers should work with proper indentation"
597 );
598
599 let wrong_content = r#"* Asterisk
601 * Wrong asterisk
602- Hyphen
603 - Wrong hyphen
604+ Plus
605 + Wrong plus"#;
606
607 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
608 let result = rule.check(&ctx).unwrap();
609 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
610 }
611
612 #[test]
613 fn test_empty_list_items() {
614 let rule = MD007ULIndent::default();
615 let content = "* Item 1\n* \n * Item 2";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
617 let result = rule.check(&ctx).unwrap();
618 assert!(
619 result.is_empty(),
620 "Empty list items should not affect indentation checks"
621 );
622 }
623
624 #[test]
625 fn test_list_with_code_blocks() {
626 let rule = MD007ULIndent::default();
627 let content = r#"* Item 1
628 ```
629 code
630 ```
631 * Item 2
632 * Item 3"#;
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
634 let result = rule.check(&ctx).unwrap();
635 assert!(result.is_empty());
636 }
637
638 #[test]
639 fn test_list_in_front_matter() {
640 let rule = MD007ULIndent::default();
641 let content = r#"---
642tags:
643 - tag1
644 - tag2
645---
646* Item 1
647 * Item 2"#;
648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
649 let result = rule.check(&ctx).unwrap();
650 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
651 }
652
653 #[test]
654 fn test_fix_preserves_content() {
655 let rule = MD007ULIndent::default();
656 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
658 let fixed = rule.fix(&ctx).unwrap();
659 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
661 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
662 }
663
664 #[test]
665 fn test_start_indented_config() {
666 let config = MD007Config {
667 start_indented: true,
668 start_indent: 4,
669 indent: 2,
670 style: md007_config::IndentStyle::TextAligned,
671 };
672 let rule = MD007ULIndent::from_config_struct(config);
673
674 let content = " * Item 1\n * Item 2\n * Item 3";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
680 let result = rule.check(&ctx).unwrap();
681 assert!(result.is_empty(), "Expected no warnings with start_indented config");
682
683 let wrong_content = " * Item 1\n * Item 2";
685 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
686 let result = rule.check(&ctx).unwrap();
687 assert_eq!(result.len(), 2);
688 assert_eq!(result[0].line, 1);
689 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
690 assert_eq!(result[1].line, 2);
691 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
692
693 let fixed = rule.fix(&ctx).unwrap();
695 assert_eq!(fixed, " * Item 1\n * Item 2");
696 }
697
698 #[test]
699 fn test_start_indented_false_allows_any_first_level() {
700 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
705 let result = rule.check(&ctx).unwrap();
706 assert!(
707 result.is_empty(),
708 "First level at any indentation should be allowed when start_indented is false"
709 );
710
711 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
714 let result = rule.check(&ctx).unwrap();
715 assert!(
716 result.is_empty(),
717 "All first-level items should be allowed at any indentation"
718 );
719 }
720
721 #[test]
722 fn test_deeply_nested_lists() {
723 let rule = MD007ULIndent::default();
724 let content = r#"* L1
725 * L2
726 * L3
727 * L4
728 * L5
729 * L6"#;
730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
731 let result = rule.check(&ctx).unwrap();
732 assert!(result.is_empty());
733
734 let wrong_content = r#"* L1
736 * L2
737 * L3
738 * L4
739 * L5
740 * L6"#;
741 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
742 let result = rule.check(&ctx).unwrap();
743 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
744 }
745
746 #[test]
747 fn test_excessive_indentation_detected() {
748 let rule = MD007ULIndent::default();
749
750 let content = "- Item 1\n - Item 2 with 5 spaces";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
753 let result = rule.check(&ctx).unwrap();
754 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
755 assert_eq!(result[0].line, 2);
756 assert!(result[0].message.contains("Expected 2 spaces"));
757 assert!(result[0].message.contains("found 5"));
758
759 let content = "- Item 1\n - Item 2 with 3 spaces";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
762 let result = rule.check(&ctx).unwrap();
763 assert_eq!(
764 result.len(),
765 1,
766 "Should detect slightly excessive indentation (3 instead of 2)"
767 );
768 assert_eq!(result[0].line, 2);
769 assert!(result[0].message.contains("Expected 2 spaces"));
770 assert!(result[0].message.contains("found 3"));
771
772 let content = "- Item 1\n - Item 2 with 1 space";
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
775 let result = rule.check(&ctx).unwrap();
776 assert_eq!(
777 result.len(),
778 1,
779 "Should detect insufficient indentation (1 instead of 2)"
780 );
781 assert_eq!(result[0].line, 2);
782 assert!(result[0].message.contains("Expected 2 spaces"));
783 assert!(result[0].message.contains("found 1"));
784 }
785
786 #[test]
787 fn test_excessive_indentation_with_4_space_config() {
788 let rule = MD007ULIndent::new(4);
789
790 let content = "- Formatter:\n - The stable style changed";
792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
793 let result = rule.check(&ctx).unwrap();
794
795 assert!(
798 !result.is_empty(),
799 "Should detect 5 spaces when expecting proper alignment"
800 );
801
802 let correct_content = "- Formatter:\n - The stable style changed";
804 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
805 let result = rule.check(&ctx).unwrap();
806 assert!(result.is_empty(), "Should accept correct text alignment");
807 }
808
809 #[test]
810 fn test_bullets_nested_under_numbered_items() {
811 let rule = MD007ULIndent::default();
812 let content = "\
8131. **Active Directory/LDAP**
814 - User authentication and directory services
815 - LDAP for user information and validation
816
8172. **Oracle Unified Directory (OUD)**
818 - Extended user directory services";
819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
820 let result = rule.check(&ctx).unwrap();
821 assert!(
823 result.is_empty(),
824 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
825 );
826 }
827
828 #[test]
829 fn test_bullets_nested_under_numbered_items_wrong_indent() {
830 let rule = MD007ULIndent::default();
831 let content = "\
8321. **Active Directory/LDAP**
833 - Wrong: only 2 spaces";
834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
835 let result = rule.check(&ctx).unwrap();
836 assert_eq!(
838 result.len(),
839 1,
840 "Expected warning for incorrect indentation under numbered items"
841 );
842 assert!(
843 result
844 .iter()
845 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
846 );
847 }
848
849 #[test]
850 fn test_regular_bullet_nesting_still_works() {
851 let rule = MD007ULIndent::default();
852 let content = "\
853* Top level
854 * Nested bullet (2 spaces is correct)
855 * Deeply nested (4 spaces)";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
857 let result = rule.check(&ctx).unwrap();
858 assert!(
860 result.is_empty(),
861 "Expected no warnings for standard bullet nesting, got: {result:?}"
862 );
863 }
864}