1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6
7mod md007_config;
8use md007_config::MD007Config;
9
10#[derive(Debug, Clone, Default)]
11pub struct MD007ULIndent {
12 config: MD007Config,
13}
14
15impl MD007ULIndent {
16 pub fn new(indent: usize) -> Self {
17 Self {
18 config: MD007Config {
19 indent: crate::types::IndentSize::from_const(indent as u8),
20 start_indented: false,
21 start_indent: crate::types::IndentSize::from_const(2),
22 style: md007_config::IndentStyle::TextAligned,
23 style_explicit: false, },
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 fn effective_style(&self, ctx: &crate::lint_context::LintContext) -> md007_config::IndentStyle {
55 if self.config.style_explicit {
57 return self.config.style;
58 }
59
60 if self.config.indent.get() == 2 {
63 return self.config.style;
64 }
65
66 if ctx.has_mixed_list_nesting() {
69 md007_config::IndentStyle::TextAligned
70 } else {
71 md007_config::IndentStyle::Fixed
72 }
73 }
74}
75
76impl Rule for MD007ULIndent {
77 fn name(&self) -> &'static str {
78 "MD007"
79 }
80
81 fn description(&self) -> &'static str {
82 "Unordered list indentation"
83 }
84
85 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
86 let mut warnings = Vec::new();
87 let mut list_stack: Vec<(usize, usize, bool, usize)> = Vec::new(); let effective_style = self.effective_style(ctx);
91
92 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
93 if line_info.in_code_block || line_info.in_front_matter || line_info.in_mkdocstrings {
95 continue;
96 }
97
98 if let Some(list_item) = &line_info.list_item {
100 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
104 let line_content = line_info.content(ctx.content);
106 let mut remaining = line_content;
107 let mut content_start = 0;
108
109 loop {
110 let trimmed = remaining.trim_start();
111 if !trimmed.starts_with('>') {
112 break;
113 }
114 content_start += remaining.len() - trimmed.len();
116 content_start += 1;
118 let after_gt = &trimmed[1..];
119 if let Some(stripped) = after_gt.strip_prefix(' ') {
121 content_start += 1;
122 remaining = stripped;
123 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
124 content_start += 1;
125 remaining = stripped;
126 } else {
127 remaining = after_gt;
128 }
129 }
130
131 let content_after_prefix = &line_content[content_start..];
133 let adjusted_col = if list_item.marker_column >= content_start {
135 list_item.marker_column - content_start
136 } else {
137 list_item.marker_column
139 };
140 (content_after_prefix.to_string(), adjusted_col)
141 } else {
142 (line_info.content(ctx.content).to_string(), list_item.marker_column)
143 };
144
145 let visual_marker_column =
147 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
148
149 let visual_content_column = if line_info.blockquote.is_some() {
151 let adjusted_content_col =
153 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
154 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
155 } else {
156 list_item.content_column
157 };
158 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
159 } else {
160 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
161 };
162
163 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
167 0
168 } else {
169 visual_marker_column
170 };
171
172 while let Some(&(indent, _, _, _)) = list_stack.last() {
174 if indent >= visual_marker_for_nesting {
175 list_stack.pop();
176 } else {
177 break;
178 }
179 }
180
181 if list_item.is_ordered {
183 list_stack.push((visual_marker_column, line_idx, true, visual_content_column));
186 continue;
187 }
188
189 let nesting_level = list_stack.len();
192
193 let expected_indent = if self.config.start_indented {
195 self.config.start_indent.get() as usize + (nesting_level * self.config.indent.get() as usize)
196 } else {
197 match effective_style {
198 md007_config::IndentStyle::Fixed => {
199 nesting_level * self.config.indent.get() as usize
201 }
202 md007_config::IndentStyle::TextAligned => {
203 if nesting_level > 0 {
205 if let Some(&(_, _parent_line_idx, _is_ordered, parent_content_visual_col)) =
207 list_stack.get(nesting_level - 1)
208 {
209 parent_content_visual_col
211 } else {
212 nesting_level * 2
215 }
216 } else {
217 0 }
219 }
220 }
221 };
222
223 let expected_content_visual_col = expected_indent + 2; list_stack.push((visual_marker_column, line_idx, false, expected_content_visual_col));
229
230 if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
233 continue;
234 }
235
236 if visual_marker_column != expected_indent {
237 let fix = {
239 let correct_indent = " ".repeat(expected_indent);
240
241 let replacement = if line_info.blockquote.is_some() {
244 let mut blockquote_count = 0;
246 for ch in line_info.content(ctx.content).chars() {
247 if ch == '>' {
248 blockquote_count += 1;
249 } else if ch != ' ' && ch != '\t' {
250 break;
251 }
252 }
253 let blockquote_prefix = if blockquote_count > 1 {
255 (0..blockquote_count)
256 .map(|_| "> ")
257 .collect::<String>()
258 .trim_end()
259 .to_string()
260 } else {
261 ">".to_string()
262 };
263 format!("{blockquote_prefix} {correct_indent}")
266 } else {
267 correct_indent
268 };
269
270 let start_byte = line_info.byte_offset;
273 let mut end_byte = line_info.byte_offset;
274
275 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
277 if i >= list_item.marker_column {
278 break;
279 }
280 end_byte += ch.len_utf8();
281 }
282
283 Some(crate::rule::Fix {
284 range: start_byte..end_byte,
285 replacement,
286 })
287 };
288
289 warnings.push(LintWarning {
290 rule_name: Some(self.name().to_string()),
291 message: format!(
292 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
293 ),
294 line: line_idx + 1, column: 1, end_line: line_idx + 1,
297 end_column: visual_marker_column + 1, severity: Severity::Warning,
299 fix,
300 });
301 }
302 }
303 }
304 Ok(warnings)
305 }
306
307 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
309 let warnings = self.check(ctx)?;
311
312 if warnings.is_empty() {
314 return Ok(ctx.content.to_string());
315 }
316
317 let mut fixes: Vec<_> = warnings
319 .iter()
320 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
321 .collect();
322 fixes.sort_by(|a, b| b.0.cmp(&a.0));
323
324 let mut result = ctx.content.to_string();
326 for (start, end, replacement) in fixes {
327 if start < result.len() && end <= result.len() && start <= end {
328 result.replace_range(start..end, replacement);
329 }
330 }
331
332 Ok(result)
333 }
334
335 fn category(&self) -> RuleCategory {
337 RuleCategory::List
338 }
339
340 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
342 if ctx.content.is_empty() || !ctx.likely_has_lists() {
344 return true;
345 }
346 !ctx.lines
348 .iter()
349 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
350 }
351
352 fn as_any(&self) -> &dyn std::any::Any {
353 self
354 }
355
356 fn default_config_section(&self) -> Option<(String, toml::Value)> {
357 let default_config = MD007Config::default();
358 let json_value = serde_json::to_value(&default_config).ok()?;
359 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
360
361 if let toml::Value::Table(table) = toml_value {
362 if !table.is_empty() {
363 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
364 } else {
365 None
366 }
367 } else {
368 None
369 }
370 }
371
372 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
373 where
374 Self: Sized,
375 {
376 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
377
378 if let Some(rule_cfg) = config.rules.get("MD007") {
383 rule_config.style_explicit = rule_cfg.values.contains_key("style");
384 }
385
386 Box::new(Self::from_config_struct(rule_config))
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use crate::lint_context::LintContext;
394 use crate::rule::Rule;
395
396 #[test]
397 fn test_valid_list_indent() {
398 let rule = MD007ULIndent::default();
399 let content = "* Item 1\n * Item 2\n * Item 3";
400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
401 let result = rule.check(&ctx).unwrap();
402 assert!(
403 result.is_empty(),
404 "Expected no warnings for valid indentation, but got {} warnings",
405 result.len()
406 );
407 }
408
409 #[test]
410 fn test_invalid_list_indent() {
411 let rule = MD007ULIndent::default();
412 let content = "* Item 1\n * Item 2\n * Item 3";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414 let result = rule.check(&ctx).unwrap();
415 assert_eq!(result.len(), 2);
416 assert_eq!(result[0].line, 2);
417 assert_eq!(result[0].column, 1);
418 assert_eq!(result[1].line, 3);
419 assert_eq!(result[1].column, 1);
420 }
421
422 #[test]
423 fn test_mixed_indentation() {
424 let rule = MD007ULIndent::default();
425 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
426 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
427 let result = rule.check(&ctx).unwrap();
428 assert_eq!(result.len(), 1);
429 assert_eq!(result[0].line, 3);
430 assert_eq!(result[0].column, 1);
431 }
432
433 #[test]
434 fn test_fix_indentation() {
435 let rule = MD007ULIndent::default();
436 let content = "* Item 1\n * Item 2\n * Item 3";
437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
438 let result = rule.fix(&ctx).unwrap();
439 let expected = "* Item 1\n * Item 2\n * Item 3";
443 assert_eq!(result, expected);
444 }
445
446 #[test]
447 fn test_md007_in_yaml_code_block() {
448 let rule = MD007ULIndent::default();
449 let content = r#"```yaml
450repos:
451- repo: https://github.com/rvben/rumdl
452 rev: v0.5.0
453 hooks:
454 - id: rumdl-check
455```"#;
456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457 let result = rule.check(&ctx).unwrap();
458 assert!(
459 result.is_empty(),
460 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
461 );
462 }
463
464 #[test]
465 fn test_blockquoted_list_indent() {
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, None);
469 let result = rule.check(&ctx).unwrap();
470 assert!(
471 result.is_empty(),
472 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
473 );
474 }
475
476 #[test]
477 fn test_blockquoted_list_invalid_indent() {
478 let rule = MD007ULIndent::default();
479 let content = "> * Item 1\n> * Item 2\n> * Item 3";
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481 let result = rule.check(&ctx).unwrap();
482 assert_eq!(
483 result.len(),
484 2,
485 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
486 );
487 assert_eq!(result[0].line, 2);
488 assert_eq!(result[1].line, 3);
489 }
490
491 #[test]
492 fn test_nested_blockquote_list_indent() {
493 let rule = MD007ULIndent::default();
494 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496 let result = rule.check(&ctx).unwrap();
497 assert!(
498 result.is_empty(),
499 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
500 );
501 }
502
503 #[test]
504 fn test_blockquote_list_with_code_block() {
505 let rule = MD007ULIndent::default();
506 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
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 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
512 );
513 }
514
515 #[test]
516 fn test_properly_indented_lists() {
517 let rule = MD007ULIndent::default();
518
519 let test_cases = vec![
521 "* Item 1\n* Item 2",
522 "* Item 1\n * Item 1.1\n * Item 1.1.1",
523 "- Item 1\n - Item 1.1",
524 "+ Item 1\n + Item 1.1",
525 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
526 ];
527
528 for content in test_cases {
529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530 let result = rule.check(&ctx).unwrap();
531 assert!(
532 result.is_empty(),
533 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
534 content,
535 result.len()
536 );
537 }
538 }
539
540 #[test]
541 fn test_under_indented_lists() {
542 let rule = MD007ULIndent::default();
543
544 let test_cases = vec![
545 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
548
549 for (content, expected_warnings, line) in test_cases {
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551 let result = rule.check(&ctx).unwrap();
552 assert_eq!(
553 result.len(),
554 expected_warnings,
555 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
556 );
557 if expected_warnings > 0 {
558 assert_eq!(result[0].line, line);
559 }
560 }
561 }
562
563 #[test]
564 fn test_over_indented_lists() {
565 let rule = MD007ULIndent::default();
566
567 let test_cases = vec![
568 ("* 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), ];
572
573 for (content, expected_warnings, line) in test_cases {
574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575 let result = rule.check(&ctx).unwrap();
576 assert_eq!(
577 result.len(),
578 expected_warnings,
579 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
580 );
581 if expected_warnings > 0 {
582 assert_eq!(result[0].line, line);
583 }
584 }
585 }
586
587 #[test]
588 fn test_custom_indent_2_spaces() {
589 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
592 let result = rule.check(&ctx).unwrap();
593 assert!(result.is_empty());
594 }
595
596 #[test]
597 fn test_custom_indent_3_spaces() {
598 let rule = MD007ULIndent::new(3);
601
602 let correct_content = "* Item 1\n * Item 2\n * Item 3";
604 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
605 let result = rule.check(&ctx).unwrap();
606 assert!(
607 result.is_empty(),
608 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
609 );
610
611 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
613 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
614 let result = rule.check(&ctx).unwrap();
615 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
616 }
617
618 #[test]
619 fn test_custom_indent_4_spaces() {
620 let rule = MD007ULIndent::new(4);
623
624 let correct_content = "* Item 1\n * Item 2\n * Item 3";
626 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
627 let result = rule.check(&ctx).unwrap();
628 assert!(
629 result.is_empty(),
630 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
631 );
632
633 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
635 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
638 }
639
640 #[test]
641 fn test_tab_indentation() {
642 let rule = MD007ULIndent::default();
643
644 let content = "* Item 1\n\t* Item 2";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647 let result = rule.check(&ctx).unwrap();
648 assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
649
650 let fixed = rule.fix(&ctx).unwrap();
652 assert_eq!(fixed, "* Item 1\n * Item 2");
653
654 let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
656 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
657 let fixed = rule.fix(&ctx).unwrap();
658 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
661
662 let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
664 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
665 let fixed = rule.fix(&ctx).unwrap();
666 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
669 }
670
671 #[test]
672 fn test_mixed_ordered_unordered_lists() {
673 let rule = MD007ULIndent::default();
674
675 let content = r#"1. Ordered item
678 * Unordered sub-item (correct - 3 spaces under ordered)
679 2. Ordered sub-item
680* Unordered item
681 1. Ordered sub-item
682 * Unordered sub-item"#;
683
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let result = rule.check(&ctx).unwrap();
686 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
687
688 let fixed = rule.fix(&ctx).unwrap();
690 assert_eq!(fixed, content);
691 }
692
693 #[test]
694 fn test_list_markers_variety() {
695 let rule = MD007ULIndent::default();
696
697 let content = r#"* Asterisk
699 * Nested asterisk
700- Hyphen
701 - Nested hyphen
702+ Plus
703 + Nested plus"#;
704
705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
706 let result = rule.check(&ctx).unwrap();
707 assert!(
708 result.is_empty(),
709 "All unordered list markers should work with proper indentation"
710 );
711
712 let wrong_content = r#"* Asterisk
714 * Wrong asterisk
715- Hyphen
716 - Wrong hyphen
717+ Plus
718 + Wrong plus"#;
719
720 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
721 let result = rule.check(&ctx).unwrap();
722 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
723 }
724
725 #[test]
726 fn test_empty_list_items() {
727 let rule = MD007ULIndent::default();
728 let content = "* Item 1\n* \n * Item 2";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let result = rule.check(&ctx).unwrap();
731 assert!(
732 result.is_empty(),
733 "Empty list items should not affect indentation checks"
734 );
735 }
736
737 #[test]
738 fn test_list_with_code_blocks() {
739 let rule = MD007ULIndent::default();
740 let content = r#"* Item 1
741 ```
742 code
743 ```
744 * Item 2
745 * Item 3"#;
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747 let result = rule.check(&ctx).unwrap();
748 assert!(result.is_empty());
749 }
750
751 #[test]
752 fn test_list_in_front_matter() {
753 let rule = MD007ULIndent::default();
754 let content = r#"---
755tags:
756 - tag1
757 - tag2
758---
759* Item 1
760 * Item 2"#;
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
764 }
765
766 #[test]
767 fn test_fix_preserves_content() {
768 let rule = MD007ULIndent::default();
769 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let fixed = rule.fix(&ctx).unwrap();
772 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
775 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
776 }
777
778 #[test]
779 fn test_start_indented_config() {
780 let config = MD007Config {
781 start_indented: true,
782 start_indent: crate::types::IndentSize::from_const(4),
783 indent: crate::types::IndentSize::from_const(2),
784 style: md007_config::IndentStyle::TextAligned,
785 style_explicit: true, };
787 let rule = MD007ULIndent::from_config_struct(config);
788
789 let content = " * Item 1\n * Item 2\n * Item 3";
794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 let result = rule.check(&ctx).unwrap();
796 assert!(result.is_empty(), "Expected no warnings with start_indented config");
797
798 let wrong_content = " * Item 1\n * Item 2";
800 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
801 let result = rule.check(&ctx).unwrap();
802 assert_eq!(result.len(), 2);
803 assert_eq!(result[0].line, 1);
804 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
805 assert_eq!(result[1].line, 2);
806 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
807
808 let fixed = rule.fix(&ctx).unwrap();
810 assert_eq!(fixed, " * Item 1\n * Item 2");
811 }
812
813 #[test]
814 fn test_start_indented_false_allows_any_first_level() {
815 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
820 let result = rule.check(&ctx).unwrap();
821 assert!(
822 result.is_empty(),
823 "First level at any indentation should be allowed when start_indented is false"
824 );
825
826 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829 let result = rule.check(&ctx).unwrap();
830 assert!(
831 result.is_empty(),
832 "All first-level items should be allowed at any indentation"
833 );
834 }
835
836 #[test]
837 fn test_deeply_nested_lists() {
838 let rule = MD007ULIndent::default();
839 let content = r#"* L1
840 * L2
841 * L3
842 * L4
843 * L5
844 * L6"#;
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847 assert!(result.is_empty());
848
849 let wrong_content = r#"* L1
851 * L2
852 * L3
853 * L4
854 * L5
855 * L6"#;
856 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
859 }
860
861 #[test]
862 fn test_excessive_indentation_detected() {
863 let rule = MD007ULIndent::default();
864
865 let content = "- Item 1\n - Item 2 with 5 spaces";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868 let result = rule.check(&ctx).unwrap();
869 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
870 assert_eq!(result[0].line, 2);
871 assert!(result[0].message.contains("Expected 2 spaces"));
872 assert!(result[0].message.contains("found 5"));
873
874 let content = "- Item 1\n - Item 2 with 3 spaces";
876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877 let result = rule.check(&ctx).unwrap();
878 assert_eq!(
879 result.len(),
880 1,
881 "Should detect slightly excessive indentation (3 instead of 2)"
882 );
883 assert_eq!(result[0].line, 2);
884 assert!(result[0].message.contains("Expected 2 spaces"));
885 assert!(result[0].message.contains("found 3"));
886
887 let content = "- Item 1\n - Item 2 with 1 space";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let result = rule.check(&ctx).unwrap();
891 assert_eq!(
892 result.len(),
893 1,
894 "Should detect 1-space indent (insufficient for nesting, expected 0)"
895 );
896 assert_eq!(result[0].line, 2);
897 assert!(result[0].message.contains("Expected 0 spaces"));
898 assert!(result[0].message.contains("found 1"));
899 }
900
901 #[test]
902 fn test_excessive_indentation_with_4_space_config() {
903 let rule = MD007ULIndent::new(4);
906
907 let content = "- Formatter:\n - The stable style changed";
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 let result = rule.check(&ctx).unwrap();
911 assert!(
912 !result.is_empty(),
913 "Should detect 5 spaces when expecting 4 (fixed style)"
914 );
915
916 let correct_content = "- Formatter:\n - The stable style changed";
918 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
919 let result = rule.check(&ctx).unwrap();
920 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
921 }
922
923 #[test]
924 fn test_bullets_nested_under_numbered_items() {
925 let rule = MD007ULIndent::default();
926 let content = "\
9271. **Active Directory/LDAP**
928 - User authentication and directory services
929 - LDAP for user information and validation
930
9312. **Oracle Unified Directory (OUD)**
932 - Extended user directory services";
933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934 let result = rule.check(&ctx).unwrap();
935 assert!(
937 result.is_empty(),
938 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
939 );
940 }
941
942 #[test]
943 fn test_bullets_nested_under_numbered_items_wrong_indent() {
944 let rule = MD007ULIndent::default();
945 let content = "\
9461. **Active Directory/LDAP**
947 - Wrong: only 2 spaces";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.check(&ctx).unwrap();
950 assert_eq!(
952 result.len(),
953 1,
954 "Expected warning for incorrect indentation under numbered items"
955 );
956 assert!(
957 result
958 .iter()
959 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
960 );
961 }
962
963 #[test]
964 fn test_regular_bullet_nesting_still_works() {
965 let rule = MD007ULIndent::default();
966 let content = "\
967* Top level
968 * Nested bullet (2 spaces is correct)
969 * Deeply nested (4 spaces)";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971 let result = rule.check(&ctx).unwrap();
972 assert!(
974 result.is_empty(),
975 "Expected no warnings for standard bullet nesting, got: {result:?}"
976 );
977 }
978
979 #[test]
980 fn test_blockquote_with_tab_after_marker() {
981 let rule = MD007ULIndent::default();
982 let content = ">\t* List item\n>\t * Nested\n";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985 assert!(
986 result.is_empty(),
987 "Tab after blockquote marker should be handled correctly, got: {result:?}"
988 );
989 }
990
991 #[test]
992 fn test_blockquote_with_space_then_tab_after_marker() {
993 let rule = MD007ULIndent::default();
994 let content = "> \t* List item\n";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997 assert!(
999 result.is_empty(),
1000 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1001 );
1002 }
1003
1004 #[test]
1005 fn test_blockquote_with_multiple_tabs() {
1006 let rule = MD007ULIndent::default();
1007 let content = ">\t\t* List item\n";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009 let result = rule.check(&ctx).unwrap();
1010 assert!(
1012 result.is_empty(),
1013 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1014 );
1015 }
1016
1017 #[test]
1018 fn test_nested_blockquote_with_tab() {
1019 let rule = MD007ULIndent::default();
1020 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1022 let result = rule.check(&ctx).unwrap();
1023 assert!(
1024 result.is_empty(),
1025 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1026 );
1027 }
1028
1029 #[test]
1032 fn test_smart_style_pure_unordered_uses_fixed() {
1033 let rule = MD007ULIndent::new(4);
1035
1036 let content = "* Level 0\n * Level 1\n * Level 2";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 let result = rule.check(&ctx).unwrap();
1040 assert!(
1041 result.is_empty(),
1042 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1043 );
1044 }
1045
1046 #[test]
1047 fn test_smart_style_mixed_lists_uses_text_aligned() {
1048 let rule = MD007ULIndent::new(4);
1050
1051 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054 let result = rule.check(&ctx).unwrap();
1055 assert!(
1056 result.is_empty(),
1057 "Mixed lists should use text-aligned style, got: {result:?}"
1058 );
1059 }
1060
1061 #[test]
1062 fn test_smart_style_explicit_fixed_overrides() {
1063 let config = MD007Config {
1065 indent: crate::types::IndentSize::from_const(4),
1066 start_indented: false,
1067 start_indent: crate::types::IndentSize::from_const(2),
1068 style: md007_config::IndentStyle::Fixed,
1069 style_explicit: true, };
1071 let rule = MD007ULIndent::from_config_struct(config);
1072
1073 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1076 let result = rule.check(&ctx).unwrap();
1077 assert!(
1079 result.is_empty(),
1080 "Explicit fixed style should be respected, got: {result:?}"
1081 );
1082 }
1083
1084 #[test]
1085 fn test_smart_style_explicit_text_aligned_overrides() {
1086 let config = MD007Config {
1088 indent: crate::types::IndentSize::from_const(4),
1089 start_indented: false,
1090 start_indent: crate::types::IndentSize::from_const(2),
1091 style: md007_config::IndentStyle::TextAligned,
1092 style_explicit: true, };
1094 let rule = MD007ULIndent::from_config_struct(config);
1095
1096 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099 let result = rule.check(&ctx).unwrap();
1100 assert!(
1101 result.is_empty(),
1102 "Explicit text-aligned should be respected, got: {result:?}"
1103 );
1104
1105 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1107 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1108 let result = rule.check(&ctx).unwrap();
1109 assert!(
1110 !result.is_empty(),
1111 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1112 );
1113 }
1114
1115 #[test]
1116 fn test_smart_style_default_indent_no_autoswitch() {
1117 let rule = MD007ULIndent::new(2);
1119
1120 let content = "* Level 0\n * Level 1\n * Level 2";
1121 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1122 let result = rule.check(&ctx).unwrap();
1123 assert!(
1124 result.is_empty(),
1125 "Default indent should work regardless of style, got: {result:?}"
1126 );
1127 }
1128
1129 #[test]
1130 fn test_has_mixed_list_nesting_detection() {
1131 let content = "* Item 1\n * Item 2\n * Item 3";
1135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1136 assert!(
1137 !ctx.has_mixed_list_nesting(),
1138 "Pure unordered should not be detected as mixed"
1139 );
1140
1141 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1144 assert!(
1145 !ctx.has_mixed_list_nesting(),
1146 "Pure ordered should not be detected as mixed"
1147 );
1148
1149 let content = "1. Ordered\n * Unordered child";
1151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1152 assert!(
1153 ctx.has_mixed_list_nesting(),
1154 "Unordered under ordered should be detected as mixed"
1155 );
1156
1157 let content = "* Unordered\n 1. Ordered child";
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160 assert!(
1161 ctx.has_mixed_list_nesting(),
1162 "Ordered under unordered should be detected as mixed"
1163 );
1164
1165 let content = "* Unordered\n\n1. Ordered (separate list)";
1167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1168 assert!(
1169 !ctx.has_mixed_list_nesting(),
1170 "Separate lists should not be detected as mixed"
1171 );
1172
1173 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1176 assert!(
1177 ctx.has_mixed_list_nesting(),
1178 "Mixed lists in blockquotes should be detected"
1179 );
1180 }
1181
1182 #[test]
1183 fn test_issue_210_exact_reproduction() {
1184 let config = MD007Config {
1186 indent: crate::types::IndentSize::from_const(4),
1187 start_indented: false,
1188 start_indent: crate::types::IndentSize::from_const(2),
1189 style: md007_config::IndentStyle::TextAligned, style_explicit: false, };
1192 let rule = MD007ULIndent::from_config_struct(config);
1193
1194 let content = "# Title\n\n* some\n * list\n * items\n";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 let result = rule.check(&ctx).unwrap();
1197
1198 assert!(
1199 result.is_empty(),
1200 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1201 );
1202 }
1203
1204 #[test]
1205 fn test_issue_209_still_fixed() {
1206 let config = MD007Config {
1208 indent: crate::types::IndentSize::from_const(3),
1209 start_indented: false,
1210 start_indent: crate::types::IndentSize::from_const(2),
1211 style: md007_config::IndentStyle::TextAligned, style_explicit: false, };
1214 let rule = MD007ULIndent::from_config_struct(config);
1215
1216 let content = r#"# Header 1
1218
1219- **Second item**:
1220 - **This is a nested list**:
1221 1. **First point**
1222 - First subpoint
1223"#;
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226
1227 assert!(
1228 result.is_empty(),
1229 "Issue #209: Mixed lists should use text-aligned and have no issues, got: {result:?}"
1230 );
1231 }
1232
1233 #[test]
1236 fn test_multi_level_mixed_detection_grandparent() {
1237 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 assert!(
1243 ctx.has_mixed_list_nesting(),
1244 "Should detect mixed nesting when grandparent differs in type"
1245 );
1246
1247 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 assert!(
1251 ctx.has_mixed_list_nesting(),
1252 "Should detect mixed nesting for ordered descendants under unordered"
1253 );
1254 }
1255
1256 #[test]
1257 fn test_html_comments_skipped_in_detection() {
1258 let content = r#"* Unordered list
1260<!-- This is a comment
1261 1. This ordered list is inside a comment
1262 * This nested bullet is also inside
1263-->
1264 * Another unordered item"#;
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 assert!(
1267 !ctx.has_mixed_list_nesting(),
1268 "Lists in HTML comments should be ignored in mixed detection"
1269 );
1270 }
1271
1272 #[test]
1273 fn test_blank_lines_separate_lists() {
1274 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 assert!(
1278 !ctx.has_mixed_list_nesting(),
1279 "Blank line at root should separate lists"
1280 );
1281
1282 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1285 assert!(
1286 ctx.has_mixed_list_nesting(),
1287 "Indented list after blank is still nested"
1288 );
1289 }
1290
1291 #[test]
1292 fn test_column_1_normalization() {
1293 let content = "* First item\n * Second item with 1 space (sibling)";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297 let rule = MD007ULIndent::default();
1298 let result = rule.check(&ctx).unwrap();
1299 assert!(
1301 result.iter().any(|w| w.line == 2),
1302 "1-space indent should be flagged as incorrect"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_code_blocks_skipped_in_detection() {
1308 let content = r#"* Unordered list
1310```
13111. This ordered list is inside a code block
1312 * This nested bullet is also inside
1313```
1314 * Another unordered item"#;
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316 assert!(
1317 !ctx.has_mixed_list_nesting(),
1318 "Lists in code blocks should be ignored in mixed detection"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_front_matter_skipped_in_detection() {
1324 let content = r#"---
1326items:
1327 - yaml list item
1328 - another item
1329---
1330* Unordered list after front matter"#;
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332 assert!(
1333 !ctx.has_mixed_list_nesting(),
1334 "Lists in front matter should be ignored in mixed detection"
1335 );
1336 }
1337
1338 #[test]
1339 fn test_alternating_types_at_same_level() {
1340 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 assert!(
1345 !ctx.has_mixed_list_nesting(),
1346 "Alternating types at same level should not be detected as mixed"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_five_level_deep_mixed_nesting() {
1352 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1356 }
1357
1358 #[test]
1359 fn test_very_deep_pure_unordered_nesting() {
1360 let mut content = String::from("* L1");
1362 for level in 2..=12 {
1363 let indent = " ".repeat(level - 1);
1364 content.push_str(&format!("\n{indent}* L{level}"));
1365 }
1366
1367 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1368
1369 assert!(
1371 !ctx.has_mixed_list_nesting(),
1372 "Pure unordered deep nesting should not be detected as mixed"
1373 );
1374
1375 let rule = MD007ULIndent::new(4);
1377 let result = rule.check(&ctx).unwrap();
1378 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1381 }
1382
1383 #[test]
1384 fn test_interleaved_content_between_list_items() {
1385 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1388 assert!(
1389 ctx.has_mixed_list_nesting(),
1390 "Should detect mixed nesting even with interleaved paragraphs"
1391 );
1392 }
1393
1394 #[test]
1395 fn test_esm_blocks_skipped_in_detection() {
1396 let content = "* Unordered list\n * Nested unordered";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 assert!(
1401 !ctx.has_mixed_list_nesting(),
1402 "Pure unordered should not be detected as mixed"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_multiple_list_blocks_pure_then_mixed() {
1408 let content = r#"* Pure unordered
1411 * Nested unordered
1412
14131. Mixed section
1414 * Bullet under ordered"#;
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416 assert!(
1417 ctx.has_mixed_list_nesting(),
1418 "Should detect mixed nesting in any part of document"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_multiple_separate_pure_lists() {
1424 let content = r#"* First list
1427 * Nested
1428
1429* Second list
1430 * Also nested
1431
1432* Third list
1433 * Deeply
1434 * Nested"#;
1435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1436 assert!(
1437 !ctx.has_mixed_list_nesting(),
1438 "Multiple separate pure unordered lists should not be mixed"
1439 );
1440 }
1441
1442 #[test]
1443 fn test_code_block_between_list_items() {
1444 let content = r#"1. Ordered
1446 ```
1447 code
1448 ```
1449 * Still a mixed child"#;
1450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451 assert!(
1452 ctx.has_mixed_list_nesting(),
1453 "Code block between items should not prevent mixed detection"
1454 );
1455 }
1456
1457 #[test]
1458 fn test_blockquoted_mixed_detection() {
1459 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 assert!(
1465 ctx.has_mixed_list_nesting(),
1466 "Should detect mixed nesting in blockquotes"
1467 );
1468 }
1469}