1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use toml;
7
8mod md007_config;
9use md007_config::MD007Config;
10
11#[derive(Debug, Clone, Default)]
12pub struct MD007ULIndent {
13 config: MD007Config,
14}
15
16impl MD007ULIndent {
17 pub fn new(indent: usize) -> Self {
18 Self {
19 config: MD007Config {
20 indent: crate::types::IndentSize::from_const(indent as u8),
21 start_indented: false,
22 start_indent: crate::types::IndentSize::from_const(2),
23 style: md007_config::IndentStyle::TextAligned,
24 },
25 }
26 }
27
28 pub fn from_config_struct(config: MD007Config) -> Self {
29 Self { config }
30 }
31
32 fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
34 let mut visual_col = 0;
35
36 for (current_pos, ch) in content.chars().enumerate() {
37 if current_pos >= char_pos {
38 break;
39 }
40 if ch == '\t' {
41 visual_col = (visual_col / 4 + 1) * 4;
43 } else {
44 visual_col += 1;
45 }
46 }
47 visual_col
48 }
49}
50
51impl Rule for MD007ULIndent {
52 fn name(&self) -> &'static str {
53 "MD007"
54 }
55
56 fn description(&self) -> &'static str {
57 "Unordered list indentation"
58 }
59
60 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
61 let mut warnings = Vec::new();
62 let mut list_stack: Vec<(usize, usize, bool, usize)> = Vec::new(); for (line_idx, line_info) in ctx.lines.iter().enumerate() {
65 if line_info.in_code_block || line_info.in_front_matter || line_info.in_mkdocstrings {
67 continue;
68 }
69
70 if let Some(list_item) = &line_info.list_item {
72 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
76 let line_content = line_info.content(ctx.content);
78 let mut remaining = line_content;
79 let mut content_start = 0;
80
81 loop {
82 let trimmed = remaining.trim_start();
83 if !trimmed.starts_with('>') {
84 break;
85 }
86 content_start += remaining.len() - trimmed.len();
88 content_start += 1;
90 let after_gt = &trimmed[1..];
91 if let Some(stripped) = after_gt.strip_prefix(' ') {
93 content_start += 1;
94 remaining = stripped;
95 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
96 content_start += 1;
97 remaining = stripped;
98 } else {
99 remaining = after_gt;
100 }
101 }
102
103 let content_after_prefix = &line_content[content_start..];
105 let adjusted_col = if list_item.marker_column >= content_start {
107 list_item.marker_column - content_start
108 } else {
109 list_item.marker_column
111 };
112 (content_after_prefix.to_string(), adjusted_col)
113 } else {
114 (line_info.content(ctx.content).to_string(), list_item.marker_column)
115 };
116
117 let visual_marker_column =
119 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
120
121 let visual_content_column = if line_info.blockquote.is_some() {
123 let adjusted_content_col =
125 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
126 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
127 } else {
128 list_item.content_column
129 };
130 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
131 } else {
132 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
133 };
134
135 let visual_marker_for_nesting = if visual_marker_column == 1 {
138 0
139 } else {
140 visual_marker_column
141 };
142
143 while let Some(&(indent, _, _, _)) = list_stack.last() {
145 if indent >= visual_marker_for_nesting {
146 list_stack.pop();
147 } else {
148 break;
149 }
150 }
151
152 if list_item.is_ordered {
154 list_stack.push((visual_marker_column, line_idx, true, visual_content_column));
157 continue;
158 }
159
160 if !list_item.is_ordered {
162 let nesting_level = list_stack.len();
164
165 let expected_indent = if self.config.start_indented {
167 self.config.start_indent.get() as usize + (nesting_level * self.config.indent.get() as usize)
168 } else {
169 match self.config.style {
170 md007_config::IndentStyle::Fixed => {
171 nesting_level * self.config.indent.get() as usize
173 }
174 md007_config::IndentStyle::TextAligned => {
175 if nesting_level > 0 {
177 if let Some(&(_, _parent_line_idx, _is_ordered, parent_content_visual_col)) =
179 list_stack.get(nesting_level - 1)
180 {
181 parent_content_visual_col
183 } else {
184 nesting_level * 2
187 }
188 } else {
189 0 }
191 }
192 }
193 };
194
195 let expected_content_visual_col = expected_indent + 2; list_stack.push((visual_marker_column, line_idx, false, expected_content_visual_col));
201
202 if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
205 continue;
206 }
207
208 if visual_marker_column != expected_indent {
209 let fix = {
211 let correct_indent = " ".repeat(expected_indent);
212
213 let replacement = if line_info.blockquote.is_some() {
216 let mut blockquote_count = 0;
218 for ch in line_info.content(ctx.content).chars() {
219 if ch == '>' {
220 blockquote_count += 1;
221 } else if ch != ' ' && ch != '\t' {
222 break;
223 }
224 }
225 let blockquote_prefix = if blockquote_count > 1 {
227 (0..blockquote_count)
228 .map(|_| "> ")
229 .collect::<String>()
230 .trim_end()
231 .to_string()
232 } else {
233 ">".to_string()
234 };
235 format!("{blockquote_prefix} {correct_indent}")
238 } else {
239 correct_indent
240 };
241
242 let start_byte = line_info.byte_offset;
245 let mut end_byte = line_info.byte_offset;
246
247 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
249 if i >= list_item.marker_column {
250 break;
251 }
252 end_byte += ch.len_utf8();
253 }
254
255 Some(crate::rule::Fix {
256 range: start_byte..end_byte,
257 replacement,
258 })
259 };
260
261 warnings.push(LintWarning {
262 rule_name: Some(self.name().to_string()),
263 message: format!(
264 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
265 ),
266 line: line_idx + 1, column: 1, end_line: line_idx + 1,
269 end_column: visual_marker_column + 1, severity: Severity::Warning,
271 fix,
272 });
273 }
274 }
275 }
276 }
277 Ok(warnings)
278 }
279
280 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
282 let warnings = self.check(ctx)?;
284
285 if warnings.is_empty() {
287 return Ok(ctx.content.to_string());
288 }
289
290 let mut fixes: Vec<_> = warnings
292 .iter()
293 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
294 .collect();
295 fixes.sort_by(|a, b| b.0.cmp(&a.0));
296
297 let mut result = ctx.content.to_string();
299 for (start, end, replacement) in fixes {
300 if start < result.len() && end <= result.len() && start <= end {
301 result.replace_range(start..end, replacement);
302 }
303 }
304
305 Ok(result)
306 }
307
308 fn category(&self) -> RuleCategory {
310 RuleCategory::List
311 }
312
313 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
315 if ctx.content.is_empty() || !ctx.likely_has_lists() {
317 return true;
318 }
319 !ctx.lines
321 .iter()
322 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
323 }
324
325 fn as_any(&self) -> &dyn std::any::Any {
326 self
327 }
328
329 fn default_config_section(&self) -> Option<(String, toml::Value)> {
330 let default_config = MD007Config::default();
331 let json_value = serde_json::to_value(&default_config).ok()?;
332 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
333
334 if let toml::Value::Table(table) = toml_value {
335 if !table.is_empty() {
336 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
337 } else {
338 None
339 }
340 } else {
341 None
342 }
343 }
344
345 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
346 where
347 Self: Sized,
348 {
349 let rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
350 Box::new(Self::from_config_struct(rule_config))
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::lint_context::LintContext;
358 use crate::rule::Rule;
359
360 #[test]
361 fn test_valid_list_indent() {
362 let rule = MD007ULIndent::default();
363 let content = "* Item 1\n * Item 2\n * Item 3";
364 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
365 let result = rule.check(&ctx).unwrap();
366 assert!(
367 result.is_empty(),
368 "Expected no warnings for valid indentation, but got {} warnings",
369 result.len()
370 );
371 }
372
373 #[test]
374 fn test_invalid_list_indent() {
375 let rule = MD007ULIndent::default();
376 let content = "* Item 1\n * Item 2\n * Item 3";
377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
378 let result = rule.check(&ctx).unwrap();
379 assert_eq!(result.len(), 2);
380 assert_eq!(result[0].line, 2);
381 assert_eq!(result[0].column, 1);
382 assert_eq!(result[1].line, 3);
383 assert_eq!(result[1].column, 1);
384 }
385
386 #[test]
387 fn test_mixed_indentation() {
388 let rule = MD007ULIndent::default();
389 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
391 let result = rule.check(&ctx).unwrap();
392 assert_eq!(result.len(), 1);
393 assert_eq!(result[0].line, 3);
394 assert_eq!(result[0].column, 1);
395 }
396
397 #[test]
398 fn test_fix_indentation() {
399 let rule = MD007ULIndent::default();
400 let content = "* Item 1\n * Item 2\n * Item 3";
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let result = rule.fix(&ctx).unwrap();
403 let expected = "* Item 1\n * Item 2\n * Item 3";
407 assert_eq!(result, expected);
408 }
409
410 #[test]
411 fn test_md007_in_yaml_code_block() {
412 let rule = MD007ULIndent::default();
413 let content = r#"```yaml
414repos:
415- repo: https://github.com/rvben/rumdl
416 rev: v0.5.0
417 hooks:
418 - id: rumdl-check
419```"#;
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421 let result = rule.check(&ctx).unwrap();
422 assert!(
423 result.is_empty(),
424 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
425 );
426 }
427
428 #[test]
429 fn test_blockquoted_list_indent() {
430 let rule = MD007ULIndent::default();
431 let content = "> * Item 1\n> * Item 2\n> * Item 3";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434 assert!(
435 result.is_empty(),
436 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
437 );
438 }
439
440 #[test]
441 fn test_blockquoted_list_invalid_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, None);
445 let result = rule.check(&ctx).unwrap();
446 assert_eq!(
447 result.len(),
448 2,
449 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
450 );
451 assert_eq!(result[0].line, 2);
452 assert_eq!(result[1].line, 3);
453 }
454
455 #[test]
456 fn test_nested_blockquote_list_indent() {
457 let rule = MD007ULIndent::default();
458 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460 let result = rule.check(&ctx).unwrap();
461 assert!(
462 result.is_empty(),
463 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
464 );
465 }
466
467 #[test]
468 fn test_blockquote_list_with_code_block() {
469 let rule = MD007ULIndent::default();
470 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472 let result = rule.check(&ctx).unwrap();
473 assert!(
474 result.is_empty(),
475 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
476 );
477 }
478
479 #[test]
480 fn test_properly_indented_lists() {
481 let rule = MD007ULIndent::default();
482
483 let test_cases = vec![
485 "* Item 1\n* Item 2",
486 "* Item 1\n * Item 1.1\n * Item 1.1.1",
487 "- Item 1\n - Item 1.1",
488 "+ Item 1\n + Item 1.1",
489 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
490 ];
491
492 for content in test_cases {
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let result = rule.check(&ctx).unwrap();
495 assert!(
496 result.is_empty(),
497 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
498 content,
499 result.len()
500 );
501 }
502 }
503
504 #[test]
505 fn test_under_indented_lists() {
506 let rule = MD007ULIndent::default();
507
508 let test_cases = vec![
509 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
512
513 for (content, expected_warnings, line) in test_cases {
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515 let result = rule.check(&ctx).unwrap();
516 assert_eq!(
517 result.len(),
518 expected_warnings,
519 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
520 );
521 if expected_warnings > 0 {
522 assert_eq!(result[0].line, line);
523 }
524 }
525 }
526
527 #[test]
528 fn test_over_indented_lists() {
529 let rule = MD007ULIndent::default();
530
531 let test_cases = vec![
532 ("* 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), ];
536
537 for (content, expected_warnings, line) in test_cases {
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let result = rule.check(&ctx).unwrap();
540 assert_eq!(
541 result.len(),
542 expected_warnings,
543 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
544 );
545 if expected_warnings > 0 {
546 assert_eq!(result[0].line, line);
547 }
548 }
549 }
550
551 #[test]
552 fn test_custom_indent_2_spaces() {
553 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
556 let result = rule.check(&ctx).unwrap();
557 assert!(result.is_empty());
558 }
559
560 #[test]
561 fn test_custom_indent_3_spaces() {
562 let rule = MD007ULIndent::new(3);
564
565 let content = "* Item 1\n * Item 2\n * Item 3";
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
567 let result = rule.check(&ctx).unwrap();
568 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
575 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
576 let result = rule.check(&ctx).unwrap();
577 assert!(result.is_empty());
578 }
579
580 #[test]
581 fn test_custom_indent_4_spaces() {
582 let rule = MD007ULIndent::new(4);
584 let content = "* Item 1\n * Item 2\n * Item 3";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 let result = rule.check(&ctx).unwrap();
587 assert!(!result.is_empty()); let correct_content = "* Item 1\n * Item 2\n * Item 3";
593 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
594 let result = rule.check(&ctx).unwrap();
595 assert!(result.is_empty());
596 }
597
598 #[test]
599 fn test_tab_indentation() {
600 let rule = MD007ULIndent::default();
601
602 let content = "* Item 1\n\t* Item 2";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let result = rule.check(&ctx).unwrap();
606 assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
607
608 let fixed = rule.fix(&ctx).unwrap();
610 assert_eq!(fixed, "* Item 1\n * Item 2");
611
612 let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
614 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
615 let fixed = rule.fix(&ctx).unwrap();
616 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
619
620 let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
622 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
623 let fixed = rule.fix(&ctx).unwrap();
624 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
627 }
628
629 #[test]
630 fn test_mixed_ordered_unordered_lists() {
631 let rule = MD007ULIndent::default();
632
633 let content = r#"1. Ordered item
636 * Unordered sub-item (correct - 3 spaces under ordered)
637 2. Ordered sub-item
638* Unordered item
639 1. Ordered sub-item
640 * Unordered sub-item"#;
641
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
645
646 let fixed = rule.fix(&ctx).unwrap();
648 assert_eq!(fixed, content);
649 }
650
651 #[test]
652 fn test_list_markers_variety() {
653 let rule = MD007ULIndent::default();
654
655 let content = r#"* Asterisk
657 * Nested asterisk
658- Hyphen
659 - Nested hyphen
660+ Plus
661 + Nested plus"#;
662
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665 assert!(
666 result.is_empty(),
667 "All unordered list markers should work with proper indentation"
668 );
669
670 let wrong_content = r#"* Asterisk
672 * Wrong asterisk
673- Hyphen
674 - Wrong hyphen
675+ Plus
676 + Wrong plus"#;
677
678 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.check(&ctx).unwrap();
680 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
681 }
682
683 #[test]
684 fn test_empty_list_items() {
685 let rule = MD007ULIndent::default();
686 let content = "* Item 1\n* \n * Item 2";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688 let result = rule.check(&ctx).unwrap();
689 assert!(
690 result.is_empty(),
691 "Empty list items should not affect indentation checks"
692 );
693 }
694
695 #[test]
696 fn test_list_with_code_blocks() {
697 let rule = MD007ULIndent::default();
698 let content = r#"* Item 1
699 ```
700 code
701 ```
702 * Item 2
703 * Item 3"#;
704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705 let result = rule.check(&ctx).unwrap();
706 assert!(result.is_empty());
707 }
708
709 #[test]
710 fn test_list_in_front_matter() {
711 let rule = MD007ULIndent::default();
712 let content = r#"---
713tags:
714 - tag1
715 - tag2
716---
717* Item 1
718 * Item 2"#;
719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let result = rule.check(&ctx).unwrap();
721 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
722 }
723
724 #[test]
725 fn test_fix_preserves_content() {
726 let rule = MD007ULIndent::default();
727 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let fixed = rule.fix(&ctx).unwrap();
730 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
733 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
734 }
735
736 #[test]
737 fn test_start_indented_config() {
738 let config = MD007Config {
739 start_indented: true,
740 start_indent: crate::types::IndentSize::from_const(4),
741 indent: crate::types::IndentSize::from_const(2),
742 style: md007_config::IndentStyle::TextAligned,
743 };
744 let rule = MD007ULIndent::from_config_struct(config);
745
746 let content = " * Item 1\n * Item 2\n * Item 3";
751 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
752 let result = rule.check(&ctx).unwrap();
753 assert!(result.is_empty(), "Expected no warnings with start_indented config");
754
755 let wrong_content = " * Item 1\n * Item 2";
757 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759 assert_eq!(result.len(), 2);
760 assert_eq!(result[0].line, 1);
761 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
762 assert_eq!(result[1].line, 2);
763 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
764
765 let fixed = rule.fix(&ctx).unwrap();
767 assert_eq!(fixed, " * Item 1\n * Item 2");
768 }
769
770 #[test]
771 fn test_start_indented_false_allows_any_first_level() {
772 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let result = rule.check(&ctx).unwrap();
778 assert!(
779 result.is_empty(),
780 "First level at any indentation should be allowed when start_indented is false"
781 );
782
783 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert!(
788 result.is_empty(),
789 "All first-level items should be allowed at any indentation"
790 );
791 }
792
793 #[test]
794 fn test_deeply_nested_lists() {
795 let rule = MD007ULIndent::default();
796 let content = r#"* L1
797 * L2
798 * L3
799 * L4
800 * L5
801 * L6"#;
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803 let result = rule.check(&ctx).unwrap();
804 assert!(result.is_empty());
805
806 let wrong_content = r#"* L1
808 * L2
809 * L3
810 * L4
811 * L5
812 * L6"#;
813 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
814 let result = rule.check(&ctx).unwrap();
815 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
816 }
817
818 #[test]
819 fn test_excessive_indentation_detected() {
820 let rule = MD007ULIndent::default();
821
822 let content = "- Item 1\n - Item 2 with 5 spaces";
824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let result = rule.check(&ctx).unwrap();
826 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
827 assert_eq!(result[0].line, 2);
828 assert!(result[0].message.contains("Expected 2 spaces"));
829 assert!(result[0].message.contains("found 5"));
830
831 let content = "- Item 1\n - Item 2 with 3 spaces";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834 let result = rule.check(&ctx).unwrap();
835 assert_eq!(
836 result.len(),
837 1,
838 "Should detect slightly excessive indentation (3 instead of 2)"
839 );
840 assert_eq!(result[0].line, 2);
841 assert!(result[0].message.contains("Expected 2 spaces"));
842 assert!(result[0].message.contains("found 3"));
843
844 let content = "- Item 1\n - Item 2 with 1 space";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let result = rule.check(&ctx).unwrap();
848 assert_eq!(
849 result.len(),
850 1,
851 "Should detect 1-space indent (insufficient for nesting, expected 0)"
852 );
853 assert_eq!(result[0].line, 2);
854 assert!(result[0].message.contains("Expected 0 spaces"));
855 assert!(result[0].message.contains("found 1"));
856 }
857
858 #[test]
859 fn test_excessive_indentation_with_4_space_config() {
860 let rule = MD007ULIndent::new(4);
861
862 let content = "- Formatter:\n - The stable style changed";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx).unwrap();
866
867 assert!(
870 !result.is_empty(),
871 "Should detect 5 spaces when expecting proper alignment"
872 );
873
874 let correct_content = "- Formatter:\n - The stable style changed";
876 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
877 let result = rule.check(&ctx).unwrap();
878 assert!(result.is_empty(), "Should accept correct text alignment");
879 }
880
881 #[test]
882 fn test_bullets_nested_under_numbered_items() {
883 let rule = MD007ULIndent::default();
884 let content = "\
8851. **Active Directory/LDAP**
886 - User authentication and directory services
887 - LDAP for user information and validation
888
8892. **Oracle Unified Directory (OUD)**
890 - Extended user directory services";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892 let result = rule.check(&ctx).unwrap();
893 assert!(
895 result.is_empty(),
896 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
897 );
898 }
899
900 #[test]
901 fn test_bullets_nested_under_numbered_items_wrong_indent() {
902 let rule = MD007ULIndent::default();
903 let content = "\
9041. **Active Directory/LDAP**
905 - Wrong: only 2 spaces";
906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907 let result = rule.check(&ctx).unwrap();
908 assert_eq!(
910 result.len(),
911 1,
912 "Expected warning for incorrect indentation under numbered items"
913 );
914 assert!(
915 result
916 .iter()
917 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
918 );
919 }
920
921 #[test]
922 fn test_regular_bullet_nesting_still_works() {
923 let rule = MD007ULIndent::default();
924 let content = "\
925* Top level
926 * Nested bullet (2 spaces is correct)
927 * Deeply nested (4 spaces)";
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929 let result = rule.check(&ctx).unwrap();
930 assert!(
932 result.is_empty(),
933 "Expected no warnings for standard bullet nesting, got: {result:?}"
934 );
935 }
936
937 #[test]
938 fn test_blockquote_with_tab_after_marker() {
939 let rule = MD007ULIndent::default();
940 let content = ">\t* List item\n>\t * Nested\n";
941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942 let result = rule.check(&ctx).unwrap();
943 assert!(
944 result.is_empty(),
945 "Tab after blockquote marker should be handled correctly, got: {result:?}"
946 );
947 }
948
949 #[test]
950 fn test_blockquote_with_space_then_tab_after_marker() {
951 let rule = MD007ULIndent::default();
952 let content = "> \t* List item\n";
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954 let result = rule.check(&ctx).unwrap();
955 assert!(
957 result.is_empty(),
958 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
959 );
960 }
961
962 #[test]
963 fn test_blockquote_with_multiple_tabs() {
964 let rule = MD007ULIndent::default();
965 let content = ">\t\t* List item\n";
966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
967 let result = rule.check(&ctx).unwrap();
968 assert!(
970 result.is_empty(),
971 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
972 );
973 }
974
975 #[test]
976 fn test_nested_blockquote_with_tab() {
977 let rule = MD007ULIndent::default();
978 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
980 let result = rule.check(&ctx).unwrap();
981 assert!(
982 result.is_empty(),
983 "Nested blockquotes with tabs should work correctly, got: {result:?}"
984 );
985 }
986}