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, indent_explicit: false, },
26 }
27 }
28
29 pub fn from_config_struct(config: MD007Config) -> Self {
30 Self { config }
31 }
32
33 fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
35 let mut visual_col = 0;
36
37 for (current_pos, ch) in content.chars().enumerate() {
38 if current_pos >= char_pos {
39 break;
40 }
41 if ch == '\t' {
42 visual_col = (visual_col / 4 + 1) * 4;
44 } else {
45 visual_col += 1;
46 }
47 }
48 visual_col
49 }
50
51 fn calculate_expected_indent(
60 &self,
61 nesting_level: usize,
62 parent_info: Option<(bool, usize)>, ) -> usize {
64 if nesting_level == 0 {
65 return 0;
66 }
67
68 if self.config.style_explicit {
70 return match self.config.style {
71 md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
72 md007_config::IndentStyle::TextAligned => {
73 parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
74 }
75 };
76 }
77
78 if self.config.indent_explicit {
82 match parent_info {
83 Some((true, parent_content_col)) => {
84 return parent_content_col;
87 }
88 _ => {
89 return nesting_level * self.config.indent.get() as usize;
91 }
92 }
93 }
94
95 match parent_info {
97 Some((true, parent_content_col)) => {
98 parent_content_col
101 }
102 Some((false, parent_content_col)) => {
103 let parent_level = nesting_level.saturating_sub(1);
107 let expected_parent_marker = parent_level * self.config.indent.get() as usize;
108 let parent_marker_col = parent_content_col.saturating_sub(2);
110
111 if parent_marker_col == expected_parent_marker {
112 nesting_level * self.config.indent.get() as usize
114 } else {
115 parent_content_col
117 }
118 }
119 None => {
120 nesting_level * self.config.indent.get() as usize
122 }
123 }
124 }
125}
126
127impl Rule for MD007ULIndent {
128 fn name(&self) -> &'static str {
129 "MD007"
130 }
131
132 fn description(&self) -> &'static str {
133 "Unordered list indentation"
134 }
135
136 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
137 let mut warnings = Vec::new();
138 let mut list_stack: Vec<(usize, usize, bool, usize)> = Vec::new(); for (line_idx, line_info) in ctx.lines.iter().enumerate() {
141 if line_info.in_code_block || line_info.in_front_matter || line_info.in_mkdocstrings {
143 continue;
144 }
145
146 if let Some(list_item) = &line_info.list_item {
148 let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
152 let line_content = line_info.content(ctx.content);
154 let mut remaining = line_content;
155 let mut content_start = 0;
156
157 loop {
158 let trimmed = remaining.trim_start();
159 if !trimmed.starts_with('>') {
160 break;
161 }
162 content_start += remaining.len() - trimmed.len();
164 content_start += 1;
166 let after_gt = &trimmed[1..];
167 if let Some(stripped) = after_gt.strip_prefix(' ') {
169 content_start += 1;
170 remaining = stripped;
171 } else if let Some(stripped) = after_gt.strip_prefix('\t') {
172 content_start += 1;
173 remaining = stripped;
174 } else {
175 remaining = after_gt;
176 }
177 }
178
179 let content_after_prefix = &line_content[content_start..];
181 let adjusted_col = if list_item.marker_column >= content_start {
183 list_item.marker_column - content_start
184 } else {
185 list_item.marker_column
187 };
188 (content_after_prefix.to_string(), adjusted_col)
189 } else {
190 (line_info.content(ctx.content).to_string(), list_item.marker_column)
191 };
192
193 let visual_marker_column =
195 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
196
197 let visual_content_column = if line_info.blockquote.is_some() {
199 let adjusted_content_col =
201 if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
202 list_item.content_column - (line_info.byte_len - content_for_calculation.len())
203 } else {
204 list_item.content_column
205 };
206 Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
207 } else {
208 Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
209 };
210
211 let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
215 0
216 } else {
217 visual_marker_column
218 };
219
220 while let Some(&(indent, _, _, _)) = list_stack.last() {
222 if indent >= visual_marker_for_nesting {
223 list_stack.pop();
224 } else {
225 break;
226 }
227 }
228
229 if list_item.is_ordered {
231 list_stack.push((visual_marker_column, line_idx, true, visual_content_column));
234 continue;
235 }
236
237 let nesting_level = list_stack.len();
240
241 let parent_info = list_stack
243 .get(nesting_level.wrapping_sub(1))
244 .map(|&(_, _, is_ordered, content_col)| (is_ordered, content_col));
245
246 let expected_indent = if self.config.start_indented {
248 self.config.start_indent.get() as usize + (nesting_level * self.config.indent.get() as usize)
249 } else {
250 self.calculate_expected_indent(nesting_level, parent_info)
251 };
252
253 let expected_content_visual_col = expected_indent + 2; list_stack.push((visual_marker_column, line_idx, false, expected_content_visual_col));
259
260 if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
263 continue;
264 }
265
266 if visual_marker_column != expected_indent {
267 let fix = {
269 let correct_indent = " ".repeat(expected_indent);
270
271 let replacement = if line_info.blockquote.is_some() {
274 let mut blockquote_count = 0;
276 for ch in line_info.content(ctx.content).chars() {
277 if ch == '>' {
278 blockquote_count += 1;
279 } else if ch != ' ' && ch != '\t' {
280 break;
281 }
282 }
283 let blockquote_prefix = if blockquote_count > 1 {
285 (0..blockquote_count)
286 .map(|_| "> ")
287 .collect::<String>()
288 .trim_end()
289 .to_string()
290 } else {
291 ">".to_string()
292 };
293 format!("{blockquote_prefix} {correct_indent}")
296 } else {
297 correct_indent
298 };
299
300 let start_byte = line_info.byte_offset;
303 let mut end_byte = line_info.byte_offset;
304
305 for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
307 if i >= list_item.marker_column {
308 break;
309 }
310 end_byte += ch.len_utf8();
311 }
312
313 Some(crate::rule::Fix {
314 range: start_byte..end_byte,
315 replacement,
316 })
317 };
318
319 warnings.push(LintWarning {
320 rule_name: Some(self.name().to_string()),
321 message: format!(
322 "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
323 ),
324 line: line_idx + 1, column: 1, end_line: line_idx + 1,
327 end_column: visual_marker_column + 1, severity: Severity::Warning,
329 fix,
330 });
331 }
332 }
333 }
334 Ok(warnings)
335 }
336
337 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
339 let warnings = self.check(ctx)?;
341
342 if warnings.is_empty() {
344 return Ok(ctx.content.to_string());
345 }
346
347 let mut fixes: Vec<_> = warnings
349 .iter()
350 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
351 .collect();
352 fixes.sort_by(|a, b| b.0.cmp(&a.0));
353
354 let mut result = ctx.content.to_string();
356 for (start, end, replacement) in fixes {
357 if start < result.len() && end <= result.len() && start <= end {
358 result.replace_range(start..end, replacement);
359 }
360 }
361
362 Ok(result)
363 }
364
365 fn category(&self) -> RuleCategory {
367 RuleCategory::List
368 }
369
370 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
372 if ctx.content.is_empty() || !ctx.likely_has_lists() {
374 return true;
375 }
376 !ctx.lines
378 .iter()
379 .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
380 }
381
382 fn as_any(&self) -> &dyn std::any::Any {
383 self
384 }
385
386 fn default_config_section(&self) -> Option<(String, toml::Value)> {
387 let default_config = MD007Config::default();
388 let json_value = serde_json::to_value(&default_config).ok()?;
389 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
390
391 if let toml::Value::Table(table) = toml_value {
392 if !table.is_empty() {
393 Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
394 } else {
395 None
396 }
397 } else {
398 None
399 }
400 }
401
402 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
403 where
404 Self: Sized,
405 {
406 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
407
408 if let Some(rule_cfg) = config.rules.get("MD007") {
410 rule_config.style_explicit = rule_cfg.values.contains_key("style");
411 rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
412
413 if rule_config.indent_explicit
417 && rule_config.style_explicit
418 && rule_config.style == md007_config::IndentStyle::TextAligned
419 {
420 eprintln!(
421 "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
422 Text-aligned style ignores indent and aligns nested items with parent text. \
423 To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
424 rule_config.indent.get()
425 );
426 }
427 }
428
429 Box::new(Self::from_config_struct(rule_config))
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use crate::lint_context::LintContext;
437 use crate::rule::Rule;
438
439 #[test]
440 fn test_valid_list_indent() {
441 let rule = MD007ULIndent::default();
442 let content = "* Item 1\n * Item 2\n * Item 3";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
444 let result = rule.check(&ctx).unwrap();
445 assert!(
446 result.is_empty(),
447 "Expected no warnings for valid indentation, but got {} warnings",
448 result.len()
449 );
450 }
451
452 #[test]
453 fn test_invalid_list_indent() {
454 let rule = MD007ULIndent::default();
455 let content = "* Item 1\n * Item 2\n * Item 3";
456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457 let result = rule.check(&ctx).unwrap();
458 assert_eq!(result.len(), 2);
459 assert_eq!(result[0].line, 2);
460 assert_eq!(result[0].column, 1);
461 assert_eq!(result[1].line, 3);
462 assert_eq!(result[1].column, 1);
463 }
464
465 #[test]
466 fn test_mixed_indentation() {
467 let rule = MD007ULIndent::default();
468 let content = "* Item 1\n * Item 2\n * Item 3\n * Item 4";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470 let result = rule.check(&ctx).unwrap();
471 assert_eq!(result.len(), 1);
472 assert_eq!(result[0].line, 3);
473 assert_eq!(result[0].column, 1);
474 }
475
476 #[test]
477 fn test_fix_indentation() {
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.fix(&ctx).unwrap();
482 let expected = "* Item 1\n * Item 2\n * Item 3";
486 assert_eq!(result, expected);
487 }
488
489 #[test]
490 fn test_md007_in_yaml_code_block() {
491 let rule = MD007ULIndent::default();
492 let content = r#"```yaml
493repos:
494- repo: https://github.com/rvben/rumdl
495 rev: v0.5.0
496 hooks:
497 - id: rumdl-check
498```"#;
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500 let result = rule.check(&ctx).unwrap();
501 assert!(
502 result.is_empty(),
503 "MD007 should not trigger inside a code block, but got warnings: {result:?}"
504 );
505 }
506
507 #[test]
508 fn test_blockquoted_list_indent() {
509 let rule = MD007ULIndent::default();
510 let content = "> * Item 1\n> * Item 2\n> * Item 3";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512 let result = rule.check(&ctx).unwrap();
513 assert!(
514 result.is_empty(),
515 "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
516 );
517 }
518
519 #[test]
520 fn test_blockquoted_list_invalid_indent() {
521 let rule = MD007ULIndent::default();
522 let content = "> * Item 1\n> * Item 2\n> * Item 3";
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let result = rule.check(&ctx).unwrap();
525 assert_eq!(
526 result.len(),
527 2,
528 "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
529 );
530 assert_eq!(result[0].line, 2);
531 assert_eq!(result[1].line, 3);
532 }
533
534 #[test]
535 fn test_nested_blockquote_list_indent() {
536 let rule = MD007ULIndent::default();
537 let content = "> > * Item 1\n> > * Item 2\n> > * Item 3";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let result = rule.check(&ctx).unwrap();
540 assert!(
541 result.is_empty(),
542 "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
543 );
544 }
545
546 #[test]
547 fn test_blockquote_list_with_code_block() {
548 let rule = MD007ULIndent::default();
549 let content = "> * Item 1\n> * Item 2\n> ```\n> code\n> ```\n> * Item 3";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551 let result = rule.check(&ctx).unwrap();
552 assert!(
553 result.is_empty(),
554 "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
555 );
556 }
557
558 #[test]
559 fn test_properly_indented_lists() {
560 let rule = MD007ULIndent::default();
561
562 let test_cases = vec![
564 "* Item 1\n* Item 2",
565 "* Item 1\n * Item 1.1\n * Item 1.1.1",
566 "- Item 1\n - Item 1.1",
567 "+ Item 1\n + Item 1.1",
568 "* Item 1\n * Item 1.1\n* Item 2\n * Item 2.1",
569 ];
570
571 for content in test_cases {
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let result = rule.check(&ctx).unwrap();
574 assert!(
575 result.is_empty(),
576 "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
577 content,
578 result.len()
579 );
580 }
581 }
582
583 #[test]
584 fn test_under_indented_lists() {
585 let rule = MD007ULIndent::default();
586
587 let test_cases = vec![
588 ("* Item 1\n * Item 1.1", 1, 2), ("* Item 1\n * Item 1.1\n * Item 1.1.1", 1, 3), ];
591
592 for (content, expected_warnings, line) in test_cases {
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let result = rule.check(&ctx).unwrap();
595 assert_eq!(
596 result.len(),
597 expected_warnings,
598 "Expected {expected_warnings} warnings for under-indented list:\n{content}"
599 );
600 if expected_warnings > 0 {
601 assert_eq!(result[0].line, line);
602 }
603 }
604 }
605
606 #[test]
607 fn test_over_indented_lists() {
608 let rule = MD007ULIndent::default();
609
610 let test_cases = vec![
611 ("* 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), ];
615
616 for (content, expected_warnings, line) in test_cases {
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618 let result = rule.check(&ctx).unwrap();
619 assert_eq!(
620 result.len(),
621 expected_warnings,
622 "Expected {expected_warnings} warnings for over-indented list:\n{content}"
623 );
624 if expected_warnings > 0 {
625 assert_eq!(result[0].line, line);
626 }
627 }
628 }
629
630 #[test]
631 fn test_custom_indent_2_spaces() {
632 let rule = MD007ULIndent::new(2); let content = "* Item 1\n * Item 2\n * Item 3";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635 let result = rule.check(&ctx).unwrap();
636 assert!(result.is_empty());
637 }
638
639 #[test]
640 fn test_custom_indent_3_spaces() {
641 let rule = MD007ULIndent::new(3);
644
645 let correct_content = "* Item 1\n * Item 2\n * Item 3";
647 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
648 let result = rule.check(&ctx).unwrap();
649 assert!(
650 result.is_empty(),
651 "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
652 );
653
654 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
656 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
657 let result = rule.check(&ctx).unwrap();
658 assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
659 }
660
661 #[test]
662 fn test_custom_indent_4_spaces() {
663 let rule = MD007ULIndent::new(4);
666
667 let correct_content = "* Item 1\n * Item 2\n * Item 3";
669 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
670 let result = rule.check(&ctx).unwrap();
671 assert!(
672 result.is_empty(),
673 "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
674 );
675
676 let wrong_content = "* Item 1\n * Item 2\n * Item 3";
678 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.check(&ctx).unwrap();
680 assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
681 }
682
683 #[test]
684 fn test_tab_indentation() {
685 let rule = MD007ULIndent::default();
686
687 let content = "* Item 1\n * Item 2";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let result = rule.check(&ctx).unwrap();
695 assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
696
697 let fixed = rule.fix(&ctx).unwrap();
699 assert_eq!(fixed, "* Item 1\n * Item 2");
700
701 let content_multi = "* Item 1\n * Item 2\n * Item 3";
703 let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
704 let fixed = rule.fix(&ctx).unwrap();
705 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
708
709 let content_mixed = "* Item 1\n * Item 2\n * Item 3";
711 let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
712 let fixed = rule.fix(&ctx).unwrap();
713 assert_eq!(fixed, "* Item 1\n * Item 2\n * Item 3");
716 }
717
718 #[test]
719 fn test_mixed_ordered_unordered_lists() {
720 let rule = MD007ULIndent::default();
721
722 let content = r#"1. Ordered item
725 * Unordered sub-item (correct - 3 spaces under ordered)
726 2. Ordered sub-item
727* Unordered item
728 1. Ordered sub-item
729 * Unordered sub-item"#;
730
731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 let result = rule.check(&ctx).unwrap();
733 assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
734
735 let fixed = rule.fix(&ctx).unwrap();
737 assert_eq!(fixed, content);
738 }
739
740 #[test]
741 fn test_list_markers_variety() {
742 let rule = MD007ULIndent::default();
743
744 let content = r#"* Asterisk
746 * Nested asterisk
747- Hyphen
748 - Nested hyphen
749+ Plus
750 + Nested plus"#;
751
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753 let result = rule.check(&ctx).unwrap();
754 assert!(
755 result.is_empty(),
756 "All unordered list markers should work with proper indentation"
757 );
758
759 let wrong_content = r#"* Asterisk
761 * Wrong asterisk
762- Hyphen
763 - Wrong hyphen
764+ Plus
765 + Wrong plus"#;
766
767 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
768 let result = rule.check(&ctx).unwrap();
769 assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
770 }
771
772 #[test]
773 fn test_empty_list_items() {
774 let rule = MD007ULIndent::default();
775 let content = "* Item 1\n* \n * Item 2";
776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
777 let result = rule.check(&ctx).unwrap();
778 assert!(
779 result.is_empty(),
780 "Empty list items should not affect indentation checks"
781 );
782 }
783
784 #[test]
785 fn test_list_with_code_blocks() {
786 let rule = MD007ULIndent::default();
787 let content = r#"* Item 1
788 ```
789 code
790 ```
791 * Item 2
792 * Item 3"#;
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794 let result = rule.check(&ctx).unwrap();
795 assert!(result.is_empty());
796 }
797
798 #[test]
799 fn test_list_in_front_matter() {
800 let rule = MD007ULIndent::default();
801 let content = r#"---
802tags:
803 - tag1
804 - tag2
805---
806* Item 1
807 * Item 2"#;
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let result = rule.check(&ctx).unwrap();
810 assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
811 }
812
813 #[test]
814 fn test_fix_preserves_content() {
815 let rule = MD007ULIndent::default();
816 let content = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
818 let fixed = rule.fix(&ctx).unwrap();
819 let expected = "* Item 1 with **bold** and *italic*\n * Item 2 with `code`\n * Item 3 with [link](url)";
822 assert_eq!(fixed, expected, "Fix should only change indentation, not content");
823 }
824
825 #[test]
826 fn test_start_indented_config() {
827 let config = MD007Config {
828 start_indented: true,
829 start_indent: crate::types::IndentSize::from_const(4),
830 indent: crate::types::IndentSize::from_const(2),
831 style: md007_config::IndentStyle::TextAligned,
832 style_explicit: true, indent_explicit: false,
834 };
835 let rule = MD007ULIndent::from_config_struct(config);
836
837 let content = " * Item 1\n * Item 2\n * Item 3";
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843 let result = rule.check(&ctx).unwrap();
844 assert!(result.is_empty(), "Expected no warnings with start_indented config");
845
846 let wrong_content = " * Item 1\n * Item 2";
848 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
849 let result = rule.check(&ctx).unwrap();
850 assert_eq!(result.len(), 2);
851 assert_eq!(result[0].line, 1);
852 assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
853 assert_eq!(result[1].line, 2);
854 assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
855
856 let fixed = rule.fix(&ctx).unwrap();
858 assert_eq!(fixed, " * Item 1\n * Item 2");
859 }
860
861 #[test]
862 fn test_start_indented_false_allows_any_first_level() {
863 let rule = MD007ULIndent::default(); let content = " * Item 1"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868 let result = rule.check(&ctx).unwrap();
869 assert!(
870 result.is_empty(),
871 "First level at any indentation should be allowed when start_indented is false"
872 );
873
874 let content = "* Item 1\n * Item 2\n * Item 3"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877 let result = rule.check(&ctx).unwrap();
878 assert!(
879 result.is_empty(),
880 "All first-level items should be allowed at any indentation"
881 );
882 }
883
884 #[test]
885 fn test_deeply_nested_lists() {
886 let rule = MD007ULIndent::default();
887 let content = r#"* L1
888 * L2
889 * L3
890 * L4
891 * L5
892 * L6"#;
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let result = rule.check(&ctx).unwrap();
895 assert!(result.is_empty());
896
897 let wrong_content = r#"* L1
899 * L2
900 * L3
901 * L4
902 * L5
903 * L6"#;
904 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
905 let result = rule.check(&ctx).unwrap();
906 assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
907 }
908
909 #[test]
910 fn test_excessive_indentation_detected() {
911 let rule = MD007ULIndent::default();
912
913 let content = "- Item 1\n - Item 2 with 5 spaces";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let result = rule.check(&ctx).unwrap();
917 assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
918 assert_eq!(result[0].line, 2);
919 assert!(result[0].message.contains("Expected 2 spaces"));
920 assert!(result[0].message.contains("found 5"));
921
922 let content = "- Item 1\n - Item 2 with 3 spaces";
924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925 let result = rule.check(&ctx).unwrap();
926 assert_eq!(
927 result.len(),
928 1,
929 "Should detect slightly excessive indentation (3 instead of 2)"
930 );
931 assert_eq!(result[0].line, 2);
932 assert!(result[0].message.contains("Expected 2 spaces"));
933 assert!(result[0].message.contains("found 3"));
934
935 let content = "- Item 1\n - Item 2 with 1 space";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let result = rule.check(&ctx).unwrap();
939 assert_eq!(
940 result.len(),
941 1,
942 "Should detect 1-space indent (insufficient for nesting, expected 0)"
943 );
944 assert_eq!(result[0].line, 2);
945 assert!(result[0].message.contains("Expected 0 spaces"));
946 assert!(result[0].message.contains("found 1"));
947 }
948
949 #[test]
950 fn test_excessive_indentation_with_4_space_config() {
951 let rule = MD007ULIndent::new(4);
954
955 let content = "- Formatter:\n - The stable style changed";
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958 let result = rule.check(&ctx).unwrap();
959 assert!(
960 !result.is_empty(),
961 "Should detect 5 spaces when expecting 4 (fixed style)"
962 );
963
964 let correct_content = "- Formatter:\n - The stable style changed";
966 let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
967 let result = rule.check(&ctx).unwrap();
968 assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
969 }
970
971 #[test]
972 fn test_bullets_nested_under_numbered_items() {
973 let rule = MD007ULIndent::default();
974 let content = "\
9751. **Active Directory/LDAP**
976 - User authentication and directory services
977 - LDAP for user information and validation
978
9792. **Oracle Unified Directory (OUD)**
980 - Extended user directory services";
981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982 let result = rule.check(&ctx).unwrap();
983 assert!(
985 result.is_empty(),
986 "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
987 );
988 }
989
990 #[test]
991 fn test_bullets_nested_under_numbered_items_wrong_indent() {
992 let rule = MD007ULIndent::default();
993 let content = "\
9941. **Active Directory/LDAP**
995 - Wrong: only 2 spaces";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let result = rule.check(&ctx).unwrap();
998 assert_eq!(
1000 result.len(),
1001 1,
1002 "Expected warning for incorrect indentation under numbered items"
1003 );
1004 assert!(
1005 result
1006 .iter()
1007 .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1008 );
1009 }
1010
1011 #[test]
1012 fn test_regular_bullet_nesting_still_works() {
1013 let rule = MD007ULIndent::default();
1014 let content = "\
1015* Top level
1016 * Nested bullet (2 spaces is correct)
1017 * Deeply nested (4 spaces)";
1018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1019 let result = rule.check(&ctx).unwrap();
1020 assert!(
1022 result.is_empty(),
1023 "Expected no warnings for standard bullet nesting, got: {result:?}"
1024 );
1025 }
1026
1027 #[test]
1028 fn test_blockquote_with_tab_after_marker() {
1029 let rule = MD007ULIndent::default();
1030 let content = ">\t* List item\n>\t * Nested\n";
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032 let result = rule.check(&ctx).unwrap();
1033 assert!(
1034 result.is_empty(),
1035 "Tab after blockquote marker should be handled correctly, got: {result:?}"
1036 );
1037 }
1038
1039 #[test]
1040 fn test_blockquote_with_space_then_tab_after_marker() {
1041 let rule = MD007ULIndent::default();
1042 let content = "> \t* List item\n";
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044 let result = rule.check(&ctx).unwrap();
1045 assert!(
1047 result.is_empty(),
1048 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1049 );
1050 }
1051
1052 #[test]
1053 fn test_blockquote_with_multiple_tabs() {
1054 let rule = MD007ULIndent::default();
1055 let content = ">\t\t* List item\n";
1056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1057 let result = rule.check(&ctx).unwrap();
1058 assert!(
1060 result.is_empty(),
1061 "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_nested_blockquote_with_tab() {
1067 let rule = MD007ULIndent::default();
1068 let content = ">\t>\t* List item\n>\t>\t * Nested\n";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1070 let result = rule.check(&ctx).unwrap();
1071 assert!(
1072 result.is_empty(),
1073 "Nested blockquotes with tabs should work correctly, got: {result:?}"
1074 );
1075 }
1076
1077 #[test]
1080 fn test_smart_style_pure_unordered_uses_fixed() {
1081 let rule = MD007ULIndent::new(4);
1083
1084 let content = "* Level 0\n * Level 1\n * Level 2";
1086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087 let result = rule.check(&ctx).unwrap();
1088 assert!(
1089 result.is_empty(),
1090 "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1091 );
1092 }
1093
1094 #[test]
1095 fn test_smart_style_mixed_lists_uses_text_aligned() {
1096 let rule = MD007ULIndent::new(4);
1098
1099 let content = "1. Ordered\n * Bullet aligns with 'Ordered' text (3 spaces)";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102 let result = rule.check(&ctx).unwrap();
1103 assert!(
1104 result.is_empty(),
1105 "Mixed lists should use text-aligned style, got: {result:?}"
1106 );
1107 }
1108
1109 #[test]
1110 fn test_smart_style_explicit_fixed_overrides() {
1111 let config = MD007Config {
1113 indent: crate::types::IndentSize::from_const(4),
1114 start_indented: false,
1115 start_indent: crate::types::IndentSize::from_const(2),
1116 style: md007_config::IndentStyle::Fixed,
1117 style_explicit: true, indent_explicit: false,
1119 };
1120 let rule = MD007ULIndent::from_config_struct(config);
1121
1122 let content = "1. Ordered\n * Should be at 4 spaces (fixed)";
1124 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1125 let result = rule.check(&ctx).unwrap();
1126 assert!(
1128 result.is_empty(),
1129 "Explicit fixed style should be respected, got: {result:?}"
1130 );
1131 }
1132
1133 #[test]
1134 fn test_smart_style_explicit_text_aligned_overrides() {
1135 let config = MD007Config {
1137 indent: crate::types::IndentSize::from_const(4),
1138 start_indented: false,
1139 start_indent: crate::types::IndentSize::from_const(2),
1140 style: md007_config::IndentStyle::TextAligned,
1141 style_explicit: true, indent_explicit: false,
1143 };
1144 let rule = MD007ULIndent::from_config_struct(config);
1145
1146 let content = "* Level 0\n * Level 1 (aligned with 'Level 0' text)";
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149 let result = rule.check(&ctx).unwrap();
1150 assert!(
1151 result.is_empty(),
1152 "Explicit text-aligned should be respected, got: {result:?}"
1153 );
1154
1155 let fixed_style_content = "* Level 0\n * Level 1 (4 spaces - fixed style)";
1157 let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1158 let result = rule.check(&ctx).unwrap();
1159 assert!(
1160 !result.is_empty(),
1161 "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1162 );
1163 }
1164
1165 #[test]
1166 fn test_smart_style_default_indent_no_autoswitch() {
1167 let rule = MD007ULIndent::new(2);
1169
1170 let content = "* Level 0\n * Level 1\n * Level 2";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let result = rule.check(&ctx).unwrap();
1173 assert!(
1174 result.is_empty(),
1175 "Default indent should work regardless of style, got: {result:?}"
1176 );
1177 }
1178
1179 #[test]
1180 fn test_has_mixed_list_nesting_detection() {
1181 let content = "* Item 1\n * Item 2\n * Item 3";
1185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1186 assert!(
1187 !ctx.has_mixed_list_nesting(),
1188 "Pure unordered should not be detected as mixed"
1189 );
1190
1191 let content = "1. Item 1\n 2. Item 2\n 3. Item 3";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194 assert!(
1195 !ctx.has_mixed_list_nesting(),
1196 "Pure ordered should not be detected as mixed"
1197 );
1198
1199 let content = "1. Ordered\n * Unordered child";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202 assert!(
1203 ctx.has_mixed_list_nesting(),
1204 "Unordered under ordered should be detected as mixed"
1205 );
1206
1207 let content = "* Unordered\n 1. Ordered child";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 assert!(
1211 ctx.has_mixed_list_nesting(),
1212 "Ordered under unordered should be detected as mixed"
1213 );
1214
1215 let content = "* Unordered\n\n1. Ordered (separate list)";
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218 assert!(
1219 !ctx.has_mixed_list_nesting(),
1220 "Separate lists should not be detected as mixed"
1221 );
1222
1223 let content = "> 1. Ordered in blockquote\n> * Unordered child";
1225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1226 assert!(
1227 ctx.has_mixed_list_nesting(),
1228 "Mixed lists in blockquotes should be detected"
1229 );
1230 }
1231
1232 #[test]
1233 fn test_issue_210_exact_reproduction() {
1234 let config = MD007Config {
1236 indent: crate::types::IndentSize::from_const(4),
1237 start_indented: false,
1238 start_indent: crate::types::IndentSize::from_const(2),
1239 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: false, };
1243 let rule = MD007ULIndent::from_config_struct(config);
1244
1245 let content = "# Title\n\n* some\n * list\n * items\n";
1246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1247 let result = rule.check(&ctx).unwrap();
1248
1249 assert!(
1250 result.is_empty(),
1251 "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1252 );
1253 }
1254
1255 #[test]
1256 fn test_issue_209_still_fixed() {
1257 let config = MD007Config {
1260 indent: crate::types::IndentSize::from_const(3),
1261 start_indented: false,
1262 start_indent: crate::types::IndentSize::from_const(2),
1263 style: md007_config::IndentStyle::TextAligned,
1264 style_explicit: true, indent_explicit: false,
1266 };
1267 let rule = MD007ULIndent::from_config_struct(config);
1268
1269 let content = r#"# Header 1
1271
1272- **Second item**:
1273 - **This is a nested list**:
1274 1. **First point**
1275 - First subpoint
1276"#;
1277 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278 let result = rule.check(&ctx).unwrap();
1279
1280 assert!(
1281 result.is_empty(),
1282 "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1283 );
1284 }
1285
1286 #[test]
1289 fn test_multi_level_mixed_detection_grandparent() {
1290 let content = "1. Ordered grandparent\n * Unordered child\n * Unordered grandchild";
1294 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295 assert!(
1296 ctx.has_mixed_list_nesting(),
1297 "Should detect mixed nesting when grandparent differs in type"
1298 );
1299
1300 let content = "* Unordered grandparent\n 1. Ordered child\n 2. Ordered grandchild";
1302 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1303 assert!(
1304 ctx.has_mixed_list_nesting(),
1305 "Should detect mixed nesting for ordered descendants under unordered"
1306 );
1307 }
1308
1309 #[test]
1310 fn test_html_comments_skipped_in_detection() {
1311 let content = r#"* Unordered list
1313<!-- This is a comment
1314 1. This ordered list is inside a comment
1315 * This nested bullet is also inside
1316-->
1317 * Another unordered item"#;
1318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1319 assert!(
1320 !ctx.has_mixed_list_nesting(),
1321 "Lists in HTML comments should be ignored in mixed detection"
1322 );
1323 }
1324
1325 #[test]
1326 fn test_blank_lines_separate_lists() {
1327 let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330 assert!(
1331 !ctx.has_mixed_list_nesting(),
1332 "Blank line at root should separate lists"
1333 );
1334
1335 let content = "1. Ordered parent\n\n * Still a child due to indentation";
1337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1338 assert!(
1339 ctx.has_mixed_list_nesting(),
1340 "Indented list after blank is still nested"
1341 );
1342 }
1343
1344 #[test]
1345 fn test_column_1_normalization() {
1346 let content = "* First item\n * Second item with 1 space (sibling)";
1349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1350 let rule = MD007ULIndent::default();
1351 let result = rule.check(&ctx).unwrap();
1352 assert!(
1354 result.iter().any(|w| w.line == 2),
1355 "1-space indent should be flagged as incorrect"
1356 );
1357 }
1358
1359 #[test]
1360 fn test_code_blocks_skipped_in_detection() {
1361 let content = r#"* Unordered list
1363```
13641. This ordered list is inside a code block
1365 * This nested bullet is also inside
1366```
1367 * Another unordered item"#;
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369 assert!(
1370 !ctx.has_mixed_list_nesting(),
1371 "Lists in code blocks should be ignored in mixed detection"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_front_matter_skipped_in_detection() {
1377 let content = r#"---
1379items:
1380 - yaml list item
1381 - another item
1382---
1383* Unordered list after front matter"#;
1384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1385 assert!(
1386 !ctx.has_mixed_list_nesting(),
1387 "Lists in front matter should be ignored in mixed detection"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_alternating_types_at_same_level() {
1393 let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1397 assert!(
1398 !ctx.has_mixed_list_nesting(),
1399 "Alternating types at same level should not be detected as mixed"
1400 );
1401 }
1402
1403 #[test]
1404 fn test_five_level_deep_mixed_nesting() {
1405 let content = "* L0\n 1. L1\n * L2\n 1. L3\n * L4\n 1. L5";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1409 }
1410
1411 #[test]
1412 fn test_very_deep_pure_unordered_nesting() {
1413 let mut content = String::from("* L1");
1415 for level in 2..=12 {
1416 let indent = " ".repeat(level - 1);
1417 content.push_str(&format!("\n{indent}* L{level}"));
1418 }
1419
1420 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1421
1422 assert!(
1424 !ctx.has_mixed_list_nesting(),
1425 "Pure unordered deep nesting should not be detected as mixed"
1426 );
1427
1428 let rule = MD007ULIndent::new(4);
1430 let result = rule.check(&ctx).unwrap();
1431 assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1434 }
1435
1436 #[test]
1437 fn test_interleaved_content_between_list_items() {
1438 let content = "1. Ordered parent\n\n Paragraph continuation\n\n * Unordered child";
1440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1441 assert!(
1442 ctx.has_mixed_list_nesting(),
1443 "Should detect mixed nesting even with interleaved paragraphs"
1444 );
1445 }
1446
1447 #[test]
1448 fn test_esm_blocks_skipped_in_detection() {
1449 let content = "* Unordered list\n * Nested unordered";
1452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453 assert!(
1454 !ctx.has_mixed_list_nesting(),
1455 "Pure unordered should not be detected as mixed"
1456 );
1457 }
1458
1459 #[test]
1460 fn test_multiple_list_blocks_pure_then_mixed() {
1461 let content = r#"* Pure unordered
1464 * Nested unordered
1465
14661. Mixed section
1467 * Bullet under ordered"#;
1468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1469 assert!(
1470 ctx.has_mixed_list_nesting(),
1471 "Should detect mixed nesting in any part of document"
1472 );
1473 }
1474
1475 #[test]
1476 fn test_multiple_separate_pure_lists() {
1477 let content = r#"* First list
1480 * Nested
1481
1482* Second list
1483 * Also nested
1484
1485* Third list
1486 * Deeply
1487 * Nested"#;
1488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1489 assert!(
1490 !ctx.has_mixed_list_nesting(),
1491 "Multiple separate pure unordered lists should not be mixed"
1492 );
1493 }
1494
1495 #[test]
1496 fn test_code_block_between_list_items() {
1497 let content = r#"1. Ordered
1499 ```
1500 code
1501 ```
1502 * Still a mixed child"#;
1503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1504 assert!(
1505 ctx.has_mixed_list_nesting(),
1506 "Code block between items should not prevent mixed detection"
1507 );
1508 }
1509
1510 #[test]
1511 fn test_blockquoted_mixed_detection() {
1512 let content = "> 1. Ordered in blockquote\n> * Mixed child";
1514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1515 assert!(
1518 ctx.has_mixed_list_nesting(),
1519 "Should detect mixed nesting in blockquotes"
1520 );
1521 }
1522
1523 #[test]
1526 fn test_indent_explicit_uses_fixed_style() {
1527 let config = MD007Config {
1530 indent: crate::types::IndentSize::from_const(4),
1531 start_indented: false,
1532 start_indent: crate::types::IndentSize::from_const(2),
1533 style: md007_config::IndentStyle::TextAligned, style_explicit: false, indent_explicit: true, };
1537 let rule = MD007ULIndent::from_config_struct(config);
1538
1539 let content = "* Level 0\n * Level 1\n * Level 2";
1542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1543 let result = rule.check(&ctx).unwrap();
1544 assert!(
1545 result.is_empty(),
1546 "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1547 );
1548
1549 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1551 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1552 let result = rule.check(&ctx).unwrap();
1553 assert!(
1554 !result.is_empty(),
1555 "Should flag text-aligned spacing when indent_explicit=true"
1556 );
1557 }
1558
1559 #[test]
1560 fn test_explicit_style_overrides_indent_explicit() {
1561 let config = MD007Config {
1564 indent: crate::types::IndentSize::from_const(4),
1565 start_indented: false,
1566 start_indent: crate::types::IndentSize::from_const(2),
1567 style: md007_config::IndentStyle::TextAligned,
1568 style_explicit: true, indent_explicit: true, };
1571 let rule = MD007ULIndent::from_config_struct(config);
1572
1573 let content = "* Level 0\n * Level 1\n * Level 2";
1575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1576 let result = rule.check(&ctx).unwrap();
1577 assert!(
1578 result.is_empty(),
1579 "Explicit text-aligned style should be respected, got: {result:?}"
1580 );
1581 }
1582
1583 #[test]
1584 fn test_no_indent_explicit_uses_smart_detection() {
1585 let config = MD007Config {
1587 indent: crate::types::IndentSize::from_const(4),
1588 start_indented: false,
1589 start_indent: crate::types::IndentSize::from_const(2),
1590 style: md007_config::IndentStyle::TextAligned,
1591 style_explicit: false,
1592 indent_explicit: false, };
1594 let rule = MD007ULIndent::from_config_struct(config);
1595
1596 let content = "* Level 0\n * Level 1";
1599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1600 let result = rule.check(&ctx).unwrap();
1601 assert!(
1603 result.is_empty(),
1604 "Smart detection should accept 4-space indent, got: {result:?}"
1605 );
1606 }
1607
1608 #[test]
1609 fn test_issue_273_exact_reproduction() {
1610 let config = MD007Config {
1613 indent: crate::types::IndentSize::from_const(4),
1614 start_indented: false,
1615 start_indent: crate::types::IndentSize::from_const(2),
1616 style: md007_config::IndentStyle::TextAligned, style_explicit: false,
1618 indent_explicit: true, };
1620 let rule = MD007ULIndent::from_config_struct(config);
1621
1622 let content = r#"* Item 1
1623 * Item 2
1624 * Item 3"#;
1625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626 let result = rule.check(&ctx).unwrap();
1627 assert!(
1628 result.is_empty(),
1629 "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
1630 );
1631 }
1632
1633 #[test]
1634 fn test_indent_explicit_with_ordered_parent() {
1635 let config = MD007Config {
1639 indent: crate::types::IndentSize::from_const(4),
1640 start_indented: false,
1641 start_indent: crate::types::IndentSize::from_const(2),
1642 style: md007_config::IndentStyle::TextAligned,
1643 style_explicit: false,
1644 indent_explicit: true, };
1646 let rule = MD007ULIndent::from_config_struct(config);
1647
1648 let content = "1. Ordered\n * Bullet aligned with ordered text";
1651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1652 let result = rule.check(&ctx).unwrap();
1653 assert!(
1654 result.is_empty(),
1655 "Bullet under ordered must use text-aligned (3 spaces) even with indent=4: {result:?}"
1656 );
1657
1658 let wrong_content = "1. Ordered\n * Bullet with 4-space fixed indent";
1660 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1661 let result = rule.check(&ctx).unwrap();
1662 assert!(
1663 !result.is_empty(),
1664 "4-space indent under ordered list should be flagged"
1665 );
1666 }
1667
1668 #[test]
1669 fn test_indent_explicit_mixed_list_deep_nesting() {
1670 let config = MD007Config {
1675 indent: crate::types::IndentSize::from_const(4),
1676 start_indented: false,
1677 start_indent: crate::types::IndentSize::from_const(2),
1678 style: md007_config::IndentStyle::TextAligned,
1679 style_explicit: false,
1680 indent_explicit: true,
1681 };
1682 let rule = MD007ULIndent::from_config_struct(config);
1683
1684 let content = r#"* Level 0
1689 * Level 1 (4-space indent from bullet parent)
1690 1. Level 2 ordered
1691 * Level 3 bullet (text-aligned under ordered)"#;
1692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1693 let result = rule.check(&ctx).unwrap();
1694 assert!(
1695 result.is_empty(),
1696 "Mixed nesting should handle each parent type correctly: {result:?}"
1697 );
1698 }
1699
1700 #[test]
1701 fn test_ordered_list_double_digit_markers() {
1702 let config = MD007Config {
1705 indent: crate::types::IndentSize::from_const(4),
1706 start_indented: false,
1707 start_indent: crate::types::IndentSize::from_const(2),
1708 style: md007_config::IndentStyle::TextAligned,
1709 style_explicit: false,
1710 indent_explicit: true,
1711 };
1712 let rule = MD007ULIndent::from_config_struct(config);
1713
1714 let content = "10. Double digit\n * Bullet at col 4";
1716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1717 let result = rule.check(&ctx).unwrap();
1718 assert!(
1719 result.is_empty(),
1720 "Bullet under '10.' should align at column 4: {result:?}"
1721 );
1722
1723 let content_single = "1. Single digit\n * Bullet at col 3";
1725 let ctx = LintContext::new(content_single, crate::config::MarkdownFlavor::Standard, None);
1726 let result = rule.check(&ctx).unwrap();
1727 assert!(
1728 result.is_empty(),
1729 "Bullet under '1.' should align at column 3: {result:?}"
1730 );
1731 }
1732
1733 #[test]
1734 fn test_indent_explicit_pure_unordered_uses_fixed() {
1735 let config = MD007Config {
1738 indent: crate::types::IndentSize::from_const(4),
1739 start_indented: false,
1740 start_indent: crate::types::IndentSize::from_const(2),
1741 style: md007_config::IndentStyle::TextAligned,
1742 style_explicit: false,
1743 indent_explicit: true,
1744 };
1745 let rule = MD007ULIndent::from_config_struct(config);
1746
1747 let content = "* Level 0\n * Level 1\n * Level 2";
1749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1750 let result = rule.check(&ctx).unwrap();
1751 assert!(
1752 result.is_empty(),
1753 "Pure unordered with indent=4 should use 4-space increments: {result:?}"
1754 );
1755
1756 let wrong_content = "* Level 0\n * Level 1\n * Level 2";
1758 let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1759 let result = rule.check(&ctx).unwrap();
1760 assert!(
1761 !result.is_empty(),
1762 "2-space indent should be flagged when indent=4 is configured"
1763 );
1764 }
1765}