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 mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
392
393 if let Some(rule_cfg) = config.rules.get("MD007") {
396 let has_explicit_indent = rule_cfg.values.contains_key("indent");
397 let has_explicit_style = rule_cfg.values.contains_key("style");
398
399 if has_explicit_indent && !has_explicit_style && rule_config.indent != 2 {
400 rule_config.style = md007_config::IndentStyle::Fixed;
403 }
404 }
405
406 Box::new(Self::from_config_struct(rule_config))
407 }
408}
409
410impl DocumentStructureExtensions for MD007ULIndent {
411 fn has_relevant_elements(
412 &self,
413 _ctx: &crate::lint_context::LintContext,
414 doc_structure: &DocumentStructure,
415 ) -> bool {
416 !doc_structure.list_lines.is_empty()
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424 use crate::lint_context::LintContext;
425 use crate::rule::Rule;
426
427 #[test]
428 fn test_valid_list_indent() {
429 let rule = MD007ULIndent::default();
430 let content = "* Item 1\n * Item 2\n * Item 3";
431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
432 let result = rule.check(&ctx).unwrap();
433 assert!(
434 result.is_empty(),
435 "Expected no warnings for valid indentation, but got {} warnings",
436 result.len()
437 );
438 }
439
440 #[test]
441 fn test_invalid_list_indent() {
442 let rule = MD007ULIndent::default();
443 let content = "* Item 1\n * Item 2\n * Item 3";
444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
445 let result = rule.check(&ctx).unwrap();
446 assert_eq!(result.len(), 2);
447 assert_eq!(result[0].line, 2);
448 assert_eq!(result[0].column, 1);
449 assert_eq!(result[1].line, 3);
450 assert_eq!(result[1].column, 1);
451 }
452
453 #[test]
454 fn test_mixed_indentation() {
455 let rule = MD007ULIndent::default();
456 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
458 let result = rule.check(&ctx).unwrap();
459 assert_eq!(result.len(), 1);
460 assert_eq!(result[0].line, 3);
461 assert_eq!(result[0].column, 1);
462 }
463
464 #[test]
465 fn test_fix_indentation() {
466 let rule = MD007ULIndent::default();
467 let content = "* Item 1\n * Item 2\n * Item 3";
468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
469 let result = rule.fix(&ctx).unwrap();
470 let expected = "* Item 1\n * Item 2\n * Item 3";
474 assert_eq!(result, expected);
475 }
476
477 #[test]
478 fn test_md007_in_yaml_code_block() {
479 let rule = MD007ULIndent::default();
480 let content = r#"```yaml
481repos:
482- repo: https://github.com/rvben/rumdl
483 rev: v0.5.0
484 hooks:
485 - id: rumdl-check
486```"#;
487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
488 let result = rule.check(&ctx).unwrap();
489 assert!(
490 result.is_empty(),
491 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
492 );
493 }
494
495 #[test]
496 fn test_blockquoted_list_indent() {
497 let rule = MD007ULIndent::default();
498 let content = "> * Item 1\n> * Item 2\n> * Item 3";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
500 let result = rule.check(&ctx).unwrap();
501 assert!(
502 result.is_empty(),
503 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
504 );
505 }
506
507 #[test]
508 fn test_blockquoted_list_invalid_indent() {
509 let rule = MD007ULIndent::default();
510 let content = "> * Item 1\n> * Item 2\n> * Item 3";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512 let result = rule.check(&ctx).unwrap();
513 assert_eq!(
514 result.len(),
515 2,
516 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
517 );
518 assert_eq!(result[0].line, 2);
519 assert_eq!(result[1].line, 3);
520 }
521
522 #[test]
523 fn test_nested_blockquote_list_indent() {
524 let rule = MD007ULIndent::default();
525 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
527 let result = rule.check(&ctx).unwrap();
528 assert!(
529 result.is_empty(),
530 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
531 );
532 }
533
534 #[test]
535 fn test_blockquote_list_with_code_block() {
536 let rule = MD007ULIndent::default();
537 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
539 let result = rule.check(&ctx).unwrap();
540 assert!(
541 result.is_empty(),
542 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
543 );
544 }
545
546 #[test]
547 fn test_properly_indented_lists() {
548 let rule = MD007ULIndent::default();
549
550 let test_cases = vec![
552 "* Item 1\n* Item 2",
553 "* Item 1\n * Item 1.1\n * Item 1.1.1",
554 "- Item 1\n - Item 1.1",
555 "+ Item 1\n + Item 1.1",
556 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
557 ];
558
559 for content in test_cases {
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
561 let result = rule.check(&ctx).unwrap();
562 assert!(
563 result.is_empty(),
564 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
565 content,
566 result.len()
567 );
568 }
569 }
570
571 #[test]
572 fn test_under_indented_lists() {
573 let rule = MD007ULIndent::default();
574
575 let test_cases = vec![
576 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
579
580 for (content, expected_warnings, line) in test_cases {
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
582 let result = rule.check(&ctx).unwrap();
583 assert_eq!(
584 result.len(),
585 expected_warnings,
586 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
587 );
588 if expected_warnings > 0 {
589 assert_eq!(result[0].line, line);
590 }
591 }
592 }
593
594 #[test]
595 fn test_over_indented_lists() {
596 let rule = MD007ULIndent::default();
597
598 let test_cases = vec![
599 ("* 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), ];
603
604 for (content, expected_warnings, line) in test_cases {
605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
606 let result = rule.check(&ctx).unwrap();
607 assert_eq!(
608 result.len(),
609 expected_warnings,
610 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
611 );
612 if expected_warnings > 0 {
613 assert_eq!(result[0].line, line);
614 }
615 }
616 }
617
618 #[test]
619 fn test_custom_indent_2_spaces() {
620 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
623 let result = rule.check(&ctx).unwrap();
624 assert!(result.is_empty());
625 }
626
627 #[test]
628 fn test_custom_indent_3_spaces() {
629 let rule = MD007ULIndent::new(3);
631
632 let content = "* Item 1\n * Item 2\n * Item 3";
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
634 let result = rule.check(&ctx).unwrap();
635 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
642 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
643 let result = rule.check(&ctx).unwrap();
644 assert!(result.is_empty());
645 }
646
647 #[test]
648 fn test_custom_indent_4_spaces() {
649 let rule = MD007ULIndent::new(4);
651 let content = "* Item 1\n * Item 2\n * Item 3";
652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
653 let result = rule.check(&ctx).unwrap();
654 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
660 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
661 let result = rule.check(&ctx).unwrap();
662 assert!(result.is_empty());
663 }
664
665 #[test]
666 fn test_tab_indentation() {
667 let rule = MD007ULIndent::default();
668
669 let content = "* Item 1\n\t* Item 2";
671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
672 let result = rule.check(&ctx).unwrap();
673 assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
674
675 let fixed = rule.fix(&ctx).unwrap();
677 assert_eq!(fixed, "* Item 1\n * Item 2");
678
679 let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
681 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard);
682 let fixed = rule.fix(&ctx).unwrap();
683 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
685
686 let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
688 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard);
689 let fixed = rule.fix(&ctx).unwrap();
690 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
692 }
693
694 #[test]
695 fn test_mixed_ordered_unordered_lists() {
696 let rule = MD007ULIndent::default();
697
698 let content = r#"1. Ordered item
701 * Unordered sub-item (correct - 3 spaces under ordered)
702 2. Ordered sub-item
703* Unordered item
704 1. Ordered sub-item
705 * Unordered sub-item"#;
706
707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
708 let result = rule.check(&ctx).unwrap();
709 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
710
711 let fixed = rule.fix(&ctx).unwrap();
713 assert_eq!(fixed, content);
714 }
715
716 #[test]
717 fn test_list_markers_variety() {
718 let rule = MD007ULIndent::default();
719
720 let content = r#"* Asterisk
722 * Nested asterisk
723- Hyphen
724 - Nested hyphen
725+ Plus
726 + Nested plus"#;
727
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
729 let result = rule.check(&ctx).unwrap();
730 assert!(
731 result.is_empty(),
732 "All unordered list markers should work with proper indentation"
733 );
734
735 let wrong_content = r#"* Asterisk
737 * Wrong asterisk
738- Hyphen
739 - Wrong hyphen
740+ Plus
741 + Wrong plus"#;
742
743 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
744 let result = rule.check(&ctx).unwrap();
745 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
746 }
747
748 #[test]
749 fn test_empty_list_items() {
750 let rule = MD007ULIndent::default();
751 let content = "* Item 1\n* \n * Item 2";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
753 let result = rule.check(&ctx).unwrap();
754 assert!(
755 result.is_empty(),
756 "Empty list items should not affect indentation checks"
757 );
758 }
759
760 #[test]
761 fn test_list_with_code_blocks() {
762 let rule = MD007ULIndent::default();
763 let content = r#"* Item 1
764 ```
765 code
766 ```
767 * Item 2
768 * Item 3"#;
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
770 let result = rule.check(&ctx).unwrap();
771 assert!(result.is_empty());
772 }
773
774 #[test]
775 fn test_list_in_front_matter() {
776 let rule = MD007ULIndent::default();
777 let content = r#"---
778tags:
779 - tag1
780 - tag2
781---
782* Item 1
783 * Item 2"#;
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
785 let result = rule.check(&ctx).unwrap();
786 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
787 }
788
789 #[test]
790 fn test_fix_preserves_content() {
791 let rule = MD007ULIndent::default();
792 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
794 let fixed = rule.fix(&ctx).unwrap();
795 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
797 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
798 }
799
800 #[test]
801 fn test_start_indented_config() {
802 let config = MD007Config {
803 start_indented: true,
804 start_indent: 4,
805 indent: 2,
806 style: md007_config::IndentStyle::TextAligned,
807 };
808 let rule = MD007ULIndent::from_config_struct(config);
809
810 let content = " * Item 1\n * Item 2\n * Item 3";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
816 let result = rule.check(&ctx).unwrap();
817 assert!(result.is_empty(), "Expected no warnings with start_indented config");
818
819 let wrong_content = " * Item 1\n * Item 2";
821 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
822 let result = rule.check(&ctx).unwrap();
823 assert_eq!(result.len(), 2);
824 assert_eq!(result[0].line, 1);
825 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
826 assert_eq!(result[1].line, 2);
827 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
828
829 let fixed = rule.fix(&ctx).unwrap();
831 assert_eq!(fixed, " * Item 1\n * Item 2");
832 }
833
834 #[test]
835 fn test_start_indented_false_allows_any_first_level() {
836 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
841 let result = rule.check(&ctx).unwrap();
842 assert!(
843 result.is_empty(),
844 "First level at any indentation should be allowed when start_indented is false"
845 );
846
847 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
850 let result = rule.check(&ctx).unwrap();
851 assert!(
852 result.is_empty(),
853 "All first-level items should be allowed at any indentation"
854 );
855 }
856
857 #[test]
858 fn test_deeply_nested_lists() {
859 let rule = MD007ULIndent::default();
860 let content = r#"* L1
861 * L2
862 * L3
863 * L4
864 * L5
865 * L6"#;
866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
867 let result = rule.check(&ctx).unwrap();
868 assert!(result.is_empty());
869
870 let wrong_content = r#"* L1
872 * L2
873 * L3
874 * L4
875 * L5
876 * L6"#;
877 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
878 let result = rule.check(&ctx).unwrap();
879 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
880 }
881
882 #[test]
883 fn test_excessive_indentation_detected() {
884 let rule = MD007ULIndent::default();
885
886 let content = "- Item 1\n - Item 2 with 5 spaces";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
889 let result = rule.check(&ctx).unwrap();
890 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
891 assert_eq!(result[0].line, 2);
892 assert!(result[0].message.contains("Expected 2 spaces"));
893 assert!(result[0].message.contains("found 5"));
894
895 let content = "- Item 1\n - Item 2 with 3 spaces";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
898 let result = rule.check(&ctx).unwrap();
899 assert_eq!(
900 result.len(),
901 1,
902 "Should detect slightly excessive indentation (3 instead of 2)"
903 );
904 assert_eq!(result[0].line, 2);
905 assert!(result[0].message.contains("Expected 2 spaces"));
906 assert!(result[0].message.contains("found 3"));
907
908 let content = "- Item 1\n - Item 2 with 1 space";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
911 let result = rule.check(&ctx).unwrap();
912 assert_eq!(
913 result.len(),
914 1,
915 "Should detect insufficient indentation (1 instead of 2)"
916 );
917 assert_eq!(result[0].line, 2);
918 assert!(result[0].message.contains("Expected 2 spaces"));
919 assert!(result[0].message.contains("found 1"));
920 }
921
922 #[test]
923 fn test_excessive_indentation_with_4_space_config() {
924 let rule = MD007ULIndent::new(4);
925
926 let content = "- Formatter:\n - The stable style changed";
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
929 let result = rule.check(&ctx).unwrap();
930
931 assert!(
934 !result.is_empty(),
935 "Should detect 5 spaces when expecting proper alignment"
936 );
937
938 let correct_content = "- Formatter:\n - The stable style changed";
940 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
941 let result = rule.check(&ctx).unwrap();
942 assert!(result.is_empty(), "Should accept correct text alignment");
943 }
944
945 #[test]
946 fn test_bullets_nested_under_numbered_items() {
947 let rule = MD007ULIndent::default();
948 let content = "\
9491. **Active Directory/LDAP**
950 - User authentication and directory services
951 - LDAP for user information and validation
952
9532. **Oracle Unified Directory (OUD)**
954 - Extended user directory services";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
956 let result = rule.check(&ctx).unwrap();
957 assert!(
959 result.is_empty(),
960 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
961 );
962 }
963
964 #[test]
965 fn test_bullets_nested_under_numbered_items_wrong_indent() {
966 let rule = MD007ULIndent::default();
967 let content = "\
9681. **Active Directory/LDAP**
969 - Wrong: only 2 spaces";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
971 let result = rule.check(&ctx).unwrap();
972 assert_eq!(
974 result.len(),
975 1,
976 "Expected warning for incorrect indentation under numbered items"
977 );
978 assert!(
979 result
980 .iter()
981 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
982 );
983 }
984
985 #[test]
986 fn test_regular_bullet_nesting_still_works() {
987 let rule = MD007ULIndent::default();
988 let content = "\
989* Top level
990 * Nested bullet (2 spaces is correct)
991 * Deeply nested (4 spaces)";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
993 let result = rule.check(&ctx).unwrap();
994 assert!(
996 result.is_empty(),
997 "Expected no warnings for standard bullet nesting, got: {result:?}"
998 );
999 }
1000}