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