rumdl_lib/rules/
md007_ul_indent.rs

1/// Rule MD007: Unordered list indentation
2///
3/// See [docs/md007.md](../../docs/md007.md) for full documentation, configuration, and examples.
4use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
7use crate::utils::element_cache::{ElementCache, ListMarkerType};
8use crate::utils::regex_cache::UNORDERED_LIST_MARKER_REGEX;
9use toml;
10
11mod md007_config;
12use md007_config::MD007Config;
13
14#[derive(Debug, Clone, Default)]
15pub struct MD007ULIndent {
16    config: MD007Config,
17}
18
19impl MD007ULIndent {
20    pub fn new(indent: usize) -> Self {
21        Self {
22            config: MD007Config {
23                indent,
24                start_indented: false,
25                start_indent: 2,
26            },
27        }
28    }
29
30    pub fn from_config_struct(config: MD007Config) -> Self {
31        Self { config }
32    }
33
34    /// Get parent info for any list item to determine proper text alignment
35    /// Returns (has_parent, expected_indent_position)
36    fn get_parent_info(
37        &self,
38        ctx: &crate::lint_context::LintContext,
39        line_number: usize,
40        indentation: usize,
41    ) -> (bool, Option<usize>) {
42        // Look backward from current line to find parent item
43        for line_idx in (1..line_number).rev() {
44            if let Some(line_info) = ctx.line_info(line_idx) {
45                if let Some(list_item) = &line_info.list_item {
46                    // Found a list item - check if it's at a lower indentation (parent level)
47                    if list_item.marker_column < indentation {
48                        // This is a parent item - calculate where child content should align
49                        if list_item.is_ordered {
50                            // For ordered lists, calculate the position where text starts
51                            // e.g., "1. Text" -> text starts at position 3
52                            // e.g., "10. Text" -> text starts at position 4
53                            // e.g., "100. Text" -> text starts at position 5
54                            let text_start_pos = list_item.marker_column + list_item.marker.len() + 1; // +1 for space after marker
55                            return (true, Some(text_start_pos));
56                        } else {
57                            // For unordered lists, calculate where text starts
58                            // e.g., "  * Text" -> text starts at position 4 (2 spaces + "* ")
59                            let text_start_pos = list_item.marker_column + 2; // "* " or "- " or "+ "
60                            return (true, Some(text_start_pos));
61                        }
62                    }
63                }
64                // If we encounter non-blank, non-list content at column 0, stop looking
65                else if !line_info.is_blank && line_info.indent == 0 {
66                    break;
67                }
68            }
69        }
70        (false, None)
71    }
72}
73
74impl Rule for MD007ULIndent {
75    fn name(&self) -> &'static str {
76        "MD007"
77    }
78
79    fn description(&self) -> &'static str {
80        "Unordered list indentation"
81    }
82
83    // TODO: Consider migrating to centralized list blocks once ElementCache is deprecated
84    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
85        let content = ctx.content;
86
87        // Early returns for performance
88        if content.is_empty() {
89            return Ok(Vec::new());
90        }
91
92        // Quick check for any list markers before expensive processing
93        if !content.contains('*') && !content.contains('-') && !content.contains('+') {
94            return Ok(Vec::new());
95        }
96
97        let element_cache = ElementCache::new(content);
98        let mut warnings = Vec::new();
99
100        for item in element_cache.get_list_items() {
101            // Only unordered list items
102            // Skip list items inside code blocks (including YAML/front matter)
103            if element_cache.is_in_code_block(item.line_number) {
104                continue;
105            }
106            if matches!(
107                item.marker_type,
108                ListMarkerType::Asterisk | ListMarkerType::Plus | ListMarkerType::Minus
109            ) {
110                // Skip first level check if start_indented is false
111                if !self.config.start_indented && item.nesting_level == 0 {
112                    continue;
113                }
114
115                let expected_indent = if self.config.start_indented {
116                    self.config.start_indent + (item.nesting_level * self.config.indent)
117                } else {
118                    // For any nested item, check if it should align with parent's text content
119                    if item.nesting_level > 0 {
120                        let (has_parent, expected_pos) = self.get_parent_info(ctx, item.line_number, item.indentation);
121                        if has_parent {
122                            if let Some(pos) = expected_pos {
123                                // Align with parent's text content
124                                pos
125                            } else {
126                                // Fallback to standard indentation
127                                item.nesting_level * self.config.indent
128                            }
129                        } else {
130                            item.nesting_level * self.config.indent
131                        }
132                    } else {
133                        item.nesting_level * self.config.indent
134                    }
135                };
136
137                if item.indentation != expected_indent {
138                    // Generate fix for this list item
139                    let fix = {
140                        let lines: Vec<&str> = content.lines().collect();
141                        if let Some(line) = lines.get(item.line_number - 1) {
142                            // Extract the marker and content
143                            if UNORDERED_LIST_MARKER_REGEX.captures(line).is_some() {
144                                let correct_indent = " ".repeat(expected_indent);
145
146                                // Fix range should match warning range - only the problematic indentation
147                                let line_index = crate::utils::range_utils::LineIndex::new(content.to_string());
148
149                                // Warning will cover the indentation area that needs to be fixed
150                                let start_col = item.blockquote_prefix.len() + 1; // Start of indentation
151                                let end_col = item.blockquote_prefix.len() + item.indent_str.len() + 1; // End of actual indentation string
152
153                                let start_byte = line_index.line_col_to_byte_range(item.line_number, start_col).start;
154                                let end_byte = line_index.line_col_to_byte_range(item.line_number, end_col).start;
155
156                                // Replacement should be just the correct indentation
157                                let replacement = correct_indent;
158
159                                Some(crate::rule::Fix {
160                                    range: start_byte..end_byte,
161                                    replacement,
162                                })
163                            } else {
164                                None
165                            }
166                        } else {
167                            None
168                        }
169                    };
170
171                    warnings.push(LintWarning {
172                        rule_name: Some(self.name()),
173                        message: format!(
174                            "Expected {} spaces for indent depth {}, found {}",
175                            expected_indent, item.nesting_level, item.indentation
176                        ),
177                        line: item.line_number,
178                        column: item.blockquote_prefix.len() + 1, // Start of indentation
179                        end_line: item.line_number,
180                        end_column: item.blockquote_prefix.len() + item.indent_str.len() + 1, // End of actual indentation string
181                        severity: Severity::Warning,
182                        fix,
183                    });
184                }
185            }
186        }
187        Ok(warnings)
188    }
189
190    /// Optimized check using document structure
191    fn check_with_structure(
192        &self,
193        ctx: &crate::lint_context::LintContext,
194        doc_structure: &DocumentStructure,
195    ) -> LintResult {
196        let content = ctx.content;
197
198        // Early return if no list items
199        if doc_structure.list_lines.is_empty() {
200            return Ok(Vec::new());
201        }
202
203        // Use ElementCache for detailed list analysis (still needed for nesting levels)
204        let element_cache = ElementCache::new(content);
205        let mut warnings = Vec::new();
206
207        for item in element_cache.get_list_items() {
208            // Only process unordered list items that are in our structure
209            if !doc_structure.list_lines.contains(&item.line_number) {
210                continue;
211            }
212
213            // Skip list items inside code blocks
214            if doc_structure.is_in_code_block(item.line_number) {
215                continue;
216            }
217
218            if matches!(
219                item.marker_type,
220                ListMarkerType::Asterisk | ListMarkerType::Plus | ListMarkerType::Minus
221            ) {
222                // Skip first level check if start_indented is false
223                if !self.config.start_indented && item.nesting_level == 0 {
224                    continue;
225                }
226
227                let expected_indent = if self.config.start_indented {
228                    self.config.start_indent + (item.nesting_level * self.config.indent)
229                } else {
230                    // For any nested item, check if it should align with parent's text content
231                    if item.nesting_level > 0 {
232                        let (has_parent, expected_pos) = self.get_parent_info(ctx, item.line_number, item.indentation);
233                        if has_parent {
234                            if let Some(pos) = expected_pos {
235                                // Align with parent's text content
236                                pos
237                            } else {
238                                // Fallback to standard indentation
239                                item.nesting_level * self.config.indent
240                            }
241                        } else {
242                            item.nesting_level * self.config.indent
243                        }
244                    } else {
245                        item.nesting_level * self.config.indent
246                    }
247                };
248
249                if item.indentation != expected_indent {
250                    // Generate fix for this list item
251                    let fix = {
252                        let lines: Vec<&str> = content.lines().collect();
253                        if let Some(line) = lines.get(item.line_number - 1) {
254                            // Extract the marker and content
255                            if UNORDERED_LIST_MARKER_REGEX.captures(line).is_some() {
256                                let correct_indent = " ".repeat(expected_indent);
257
258                                // Fix range should match warning range - only the problematic indentation
259                                let line_index = crate::utils::range_utils::LineIndex::new(content.to_string());
260
261                                // Warning will cover the indentation area that needs to be fixed
262                                let start_col = item.blockquote_prefix.len() + 1; // Start of indentation
263                                let end_col = item.blockquote_prefix.len() + item.indent_str.len() + 1; // End of actual indentation string
264
265                                let start_byte = line_index.line_col_to_byte_range(item.line_number, start_col).start;
266                                let end_byte = line_index.line_col_to_byte_range(item.line_number, end_col).start;
267
268                                // Replacement should be just the correct indentation
269                                let replacement = correct_indent;
270
271                                Some(crate::rule::Fix {
272                                    range: start_byte..end_byte,
273                                    replacement,
274                                })
275                            } else {
276                                None
277                            }
278                        } else {
279                            None
280                        }
281                    };
282
283                    warnings.push(LintWarning {
284                        rule_name: Some(self.name()),
285                        message: format!(
286                            "Expected {} spaces for indent depth {}, found {}",
287                            expected_indent, item.nesting_level, item.indentation
288                        ),
289                        line: item.line_number,
290                        column: item.blockquote_prefix.len() + 1, // Start of indentation
291                        end_line: item.line_number,
292                        end_column: item.blockquote_prefix.len() + item.indent_str.len() + 1, // End of actual indentation string
293                        severity: Severity::Warning,
294                        fix,
295                    });
296                }
297            }
298        }
299        Ok(warnings)
300    }
301
302    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
303        // Get all warnings with their fixes
304        let warnings = self.check(ctx)?;
305
306        // If no warnings, return original content
307        if warnings.is_empty() {
308            return Ok(ctx.content.to_string());
309        }
310
311        // Collect all fixes and sort by range start (descending) to apply from end to beginning
312        let mut fixes: Vec<_> = warnings
313            .iter()
314            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
315            .collect();
316        fixes.sort_by(|a, b| b.0.cmp(&a.0));
317
318        // Apply fixes from end to beginning to preserve byte offsets
319        let mut result = ctx.content.to_string();
320        for (start, end, replacement) in fixes {
321            if start < result.len() && end <= result.len() && start <= end {
322                result.replace_range(start..end, replacement);
323            }
324        }
325
326        Ok(result)
327    }
328
329    /// Get the category of this rule for selective processing
330    fn category(&self) -> RuleCategory {
331        RuleCategory::List
332    }
333
334    /// Check if this rule should be skipped
335    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
336        // Skip if content is empty or has no unordered list items
337        ctx.content.is_empty()
338            || !ctx
339                .lines
340                .iter()
341                .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
342    }
343
344    fn as_any(&self) -> &dyn std::any::Any {
345        self
346    }
347
348    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
349        Some(self)
350    }
351
352    fn default_config_section(&self) -> Option<(String, toml::Value)> {
353        let default_config = MD007Config::default();
354        let json_value = serde_json::to_value(&default_config).ok()?;
355        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
356
357        if let toml::Value::Table(table) = toml_value {
358            if !table.is_empty() {
359                Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
360            } else {
361                None
362            }
363        } else {
364            None
365        }
366    }
367
368    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
369    where
370        Self: Sized,
371    {
372        let rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
373        Box::new(Self::from_config_struct(rule_config))
374    }
375}
376
377impl DocumentStructureExtensions for MD007ULIndent {
378    fn has_relevant_elements(
379        &self,
380        _ctx: &crate::lint_context::LintContext,
381        doc_structure: &DocumentStructure,
382    ) -> bool {
383        // Use the document structure to check if there are any unordered list elements
384        !doc_structure.list_lines.is_empty()
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::lint_context::LintContext;
392    use crate::rule::Rule;
393
394    #[test]
395    fn test_valid_list_indent() {
396        let rule = MD007ULIndent::default();
397        let content = "* Item 1\n  * Item 2\n    * Item 3";
398        let ctx = LintContext::new(content);
399        let result = rule.check(&ctx).unwrap();
400        assert!(
401            result.is_empty(),
402            "Expected no warnings for valid indentation, but got {} warnings",
403            result.len()
404        );
405    }
406
407    #[test]
408    fn test_invalid_list_indent() {
409        let rule = MD007ULIndent::default();
410        let content = "* Item 1\n   * Item 2\n      * Item 3";
411        let ctx = LintContext::new(content);
412        let result = rule.check(&ctx).unwrap();
413        assert_eq!(result.len(), 2);
414        assert_eq!(result[0].line, 2);
415        assert_eq!(result[0].column, 1);
416        assert_eq!(result[1].line, 3);
417        assert_eq!(result[1].column, 1);
418    }
419
420    #[test]
421    fn test_mixed_indentation() {
422        let rule = MD007ULIndent::default();
423        let content = "* Item 1\n  * Item 2\n   * Item 3\n  * Item 4";
424        let ctx = LintContext::new(content);
425        let result = rule.check(&ctx).unwrap();
426        assert_eq!(result.len(), 1);
427        assert_eq!(result[0].line, 3);
428        assert_eq!(result[0].column, 1);
429    }
430
431    #[test]
432    fn test_fix_indentation() {
433        let rule = MD007ULIndent::default();
434        let content = "* Item 1\n   * Item 2\n      * Item 3";
435        let ctx = LintContext::new(content);
436        let result = rule.fix(&ctx).unwrap();
437        // With dynamic alignment:
438        // Item 2 aligns with Item 1's text (2 spaces)
439        // Item 3 aligns with Item 2's text (4 + 1 = 5 spaces)
440        let expected = "* Item 1\n  * Item 2\n     * Item 3";
441        assert_eq!(result, expected);
442    }
443
444    #[test]
445    fn test_md007_in_yaml_code_block() {
446        let rule = MD007ULIndent::default();
447        let content = r#"```yaml
448repos:
449-   repo: https://github.com/rvben/rumdl
450    rev: v0.5.0
451    hooks:
452    -   id: rumdl-check
453```"#;
454        let ctx = LintContext::new(content);
455        let result = rule.check(&ctx).unwrap();
456        assert!(
457            result.is_empty(),
458            "MD007 should not trigger inside a code block, but got warnings: {result:?}"
459        );
460    }
461
462    #[test]
463    fn test_blockquoted_list_indent() {
464        let rule = MD007ULIndent::default();
465        let content = "> * Item 1\n>   * Item 2\n>     * Item 3";
466        let ctx = LintContext::new(content);
467        let result = rule.check(&ctx).unwrap();
468        assert!(
469            result.is_empty(),
470            "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
471        );
472    }
473
474    #[test]
475    fn test_blockquoted_list_invalid_indent() {
476        let rule = MD007ULIndent::default();
477        let content = "> * Item 1\n>    * Item 2\n>       * Item 3";
478        let ctx = LintContext::new(content);
479        let result = rule.check(&ctx).unwrap();
480        assert_eq!(
481            result.len(),
482            2,
483            "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
484        );
485        assert_eq!(result[0].line, 2);
486        assert_eq!(result[1].line, 3);
487    }
488
489    #[test]
490    fn test_nested_blockquote_list_indent() {
491        let rule = MD007ULIndent::default();
492        let content = "> > * Item 1\n> >   * Item 2\n> >     * Item 3";
493        let ctx = LintContext::new(content);
494        let result = rule.check(&ctx).unwrap();
495        assert!(
496            result.is_empty(),
497            "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
498        );
499    }
500
501    #[test]
502    fn test_blockquote_list_with_code_block() {
503        let rule = MD007ULIndent::default();
504        let content = "> * Item 1\n>   * Item 2\n>   ```\n>   code\n>   ```\n>   * Item 3";
505        let ctx = LintContext::new(content);
506        let result = rule.check(&ctx).unwrap();
507        assert!(
508            result.is_empty(),
509            "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
510        );
511    }
512
513    #[test]
514    fn test_properly_indented_lists() {
515        let rule = MD007ULIndent::default();
516
517        // Test various properly indented lists
518        let test_cases = vec![
519            "* Item 1\n* Item 2",
520            "* Item 1\n  * Item 1.1\n    * Item 1.1.1",
521            "- Item 1\n  - Item 1.1",
522            "+ Item 1\n  + Item 1.1",
523            "* Item 1\n  * Item 1.1\n* Item 2\n  * Item 2.1",
524        ];
525
526        for content in test_cases {
527            let ctx = LintContext::new(content);
528            let result = rule.check(&ctx).unwrap();
529            assert!(
530                result.is_empty(),
531                "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
532                content,
533                result.len()
534            );
535        }
536    }
537
538    #[test]
539    fn test_under_indented_lists() {
540        let rule = MD007ULIndent::default();
541
542        let test_cases = vec![
543            ("* Item 1\n * Item 1.1", 1, 2),                   // Expected 2 spaces, got 1
544            ("* Item 1\n  * Item 1.1\n   * Item 1.1.1", 1, 3), // Expected 4 spaces, got 3
545        ];
546
547        for (content, expected_warnings, line) in test_cases {
548            let ctx = LintContext::new(content);
549            let result = rule.check(&ctx).unwrap();
550            assert_eq!(
551                result.len(),
552                expected_warnings,
553                "Expected {expected_warnings} warnings for under-indented list:\n{content}"
554            );
555            if expected_warnings > 0 {
556                assert_eq!(result[0].line, line);
557            }
558        }
559    }
560
561    #[test]
562    fn test_over_indented_lists() {
563        let rule = MD007ULIndent::default();
564
565        let test_cases = vec![
566            ("* Item 1\n   * Item 1.1", 1, 2),                   // Expected 2 spaces, got 3
567            ("* Item 1\n    * Item 1.1", 1, 2),                  // Expected 2 spaces, got 4
568            ("* Item 1\n  * Item 1.1\n     * Item 1.1.1", 1, 3), // Expected 4 spaces, got 5
569        ];
570
571        for (content, expected_warnings, line) in test_cases {
572            let ctx = LintContext::new(content);
573            let result = rule.check(&ctx).unwrap();
574            assert_eq!(
575                result.len(),
576                expected_warnings,
577                "Expected {expected_warnings} warnings for over-indented list:\n{content}"
578            );
579            if expected_warnings > 0 {
580                assert_eq!(result[0].line, line);
581            }
582        }
583    }
584
585    #[test]
586    fn test_custom_indent_2_spaces() {
587        let rule = MD007ULIndent::new(2); // Default
588        let content = "* Item 1\n  * Item 2\n    * Item 3";
589        let ctx = LintContext::new(content);
590        let result = rule.check(&ctx).unwrap();
591        assert!(result.is_empty());
592    }
593
594    #[test]
595    fn test_custom_indent_3_spaces() {
596        // Test dynamic alignment behavior (default start_indented=false)
597        let rule = MD007ULIndent::new(3);
598
599        let content = "* Item 1\n   * Item 2\n      * Item 3";
600        let ctx = LintContext::new(content);
601        let result = rule.check(&ctx).unwrap();
602        // With dynamic alignment, Item 2 should align with Item 1's text (2 spaces)
603        // and Item 3 should align with Item 2's text (4 spaces), not fixed increments
604        assert!(!result.is_empty()); // Should have warnings due to alignment
605
606        // Test that dynamic alignment works correctly
607        // Item 3 should align with Item 2's text content (4 spaces)
608        let correct_content = "* Item 1\n  * Item 2\n    * Item 3";
609        let ctx = LintContext::new(correct_content);
610        let result = rule.check(&ctx).unwrap();
611        assert!(result.is_empty());
612    }
613
614    #[test]
615    fn test_custom_indent_4_spaces() {
616        // Test dynamic alignment behavior (default start_indented=false)
617        let rule = MD007ULIndent::new(4);
618        let content = "* Item 1\n    * Item 2\n        * Item 3";
619        let ctx = LintContext::new(content);
620        let result = rule.check(&ctx).unwrap();
621        // With dynamic alignment, should expect 2 spaces and 6 spaces, not 4 and 8
622        assert!(!result.is_empty()); // Should have warnings due to alignment
623
624        // Test correct dynamic alignment
625        // Item 3 should align with Item 2's text content (4 spaces)
626        let correct_content = "* Item 1\n  * Item 2\n    * Item 3";
627        let ctx = LintContext::new(correct_content);
628        let result = rule.check(&ctx).unwrap();
629        assert!(result.is_empty());
630    }
631
632    #[test]
633    fn test_tab_indentation() {
634        let rule = MD007ULIndent::default();
635
636        // Single tab
637        let content = "* Item 1\n\t* Item 2";
638        let ctx = LintContext::new(content);
639        let result = rule.check(&ctx).unwrap();
640        assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
641
642        // Fix should convert tab to spaces
643        let fixed = rule.fix(&ctx).unwrap();
644        assert_eq!(fixed, "* Item 1\n  * Item 2");
645
646        // Multiple tabs
647        let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
648        let ctx = LintContext::new(content_multi);
649        let fixed = rule.fix(&ctx).unwrap();
650        // With dynamic alignment: Item 3 aligns with Item 2 at correct position
651        assert_eq!(fixed, "* Item 1\n  * Item 2\n   * Item 3");
652
653        // Mixed tabs and spaces
654        let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
655        let ctx = LintContext::new(content_mixed);
656        let fixed = rule.fix(&ctx).unwrap();
657        // With dynamic alignment: Item 3 aligns with Item 2 at correct position
658        assert_eq!(fixed, "* Item 1\n  * Item 2\n    * Item 3");
659    }
660
661    #[test]
662    fn test_mixed_ordered_unordered_lists() {
663        let rule = MD007ULIndent::default();
664
665        // MD007 only checks unordered lists, so ordered lists should be ignored
666        // Note: 3 spaces is now correct for bullets under ordered items
667        let content = r#"1. Ordered item
668   * Unordered sub-item (correct - 3 spaces under ordered)
669   2. Ordered sub-item
670* Unordered item
671  1. Ordered sub-item
672  * Unordered sub-item"#;
673
674        let ctx = LintContext::new(content);
675        let result = rule.check(&ctx).unwrap();
676        assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
677
678        // No fix needed as all indentation is correct
679        let fixed = rule.fix(&ctx).unwrap();
680        assert_eq!(fixed, content);
681    }
682
683    #[test]
684    fn test_list_markers_variety() {
685        let rule = MD007ULIndent::default();
686
687        // Test all three unordered list markers
688        let content = r#"* Asterisk
689  * Nested asterisk
690- Hyphen
691  - Nested hyphen
692+ Plus
693  + Nested plus"#;
694
695        let ctx = LintContext::new(content);
696        let result = rule.check(&ctx).unwrap();
697        assert!(
698            result.is_empty(),
699            "All unordered list markers should work with proper indentation"
700        );
701
702        // Test with wrong indentation for each marker type
703        let wrong_content = r#"* Asterisk
704   * Wrong asterisk
705- Hyphen
706 - Wrong hyphen
707+ Plus
708    + Wrong plus"#;
709
710        let ctx = LintContext::new(wrong_content);
711        let result = rule.check(&ctx).unwrap();
712        assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
713    }
714
715    #[test]
716    fn test_empty_list_items() {
717        let rule = MD007ULIndent::default();
718        let content = "* Item 1\n* \n  * Item 2";
719        let ctx = LintContext::new(content);
720        let result = rule.check(&ctx).unwrap();
721        assert!(
722            result.is_empty(),
723            "Empty list items should not affect indentation checks"
724        );
725    }
726
727    #[test]
728    fn test_list_with_code_blocks() {
729        let rule = MD007ULIndent::default();
730        let content = r#"* Item 1
731  ```
732  code
733  ```
734  * Item 2
735    * Item 3"#;
736        let ctx = LintContext::new(content);
737        let result = rule.check(&ctx).unwrap();
738        assert!(result.is_empty());
739    }
740
741    #[test]
742    fn test_list_in_front_matter() {
743        let rule = MD007ULIndent::default();
744        let content = r#"---
745tags:
746  - tag1
747  - tag2
748---
749* Item 1
750  * Item 2"#;
751        let ctx = LintContext::new(content);
752        let result = rule.check(&ctx).unwrap();
753        assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
754    }
755
756    #[test]
757    fn test_fix_preserves_content() {
758        let rule = MD007ULIndent::default();
759        let content = "* Item 1 with **bold** and *italic*\n   * Item 2 with `code`\n     * Item 3 with [link](url)";
760        let ctx = LintContext::new(content);
761        let fixed = rule.fix(&ctx).unwrap();
762        // With dynamic alignment: Item 3 aligns with Item 2's text (2 + 2 + 1 = 5 spaces)
763        let expected = "* Item 1 with **bold** and *italic*\n  * Item 2 with `code`\n     * Item 3 with [link](url)";
764        assert_eq!(fixed, expected, "Fix should only change indentation, not content");
765    }
766
767    #[test]
768    fn test_start_indented_config() {
769        let config = MD007Config {
770            start_indented: true,
771            start_indent: 4,
772            indent: 2,
773        };
774        let rule = MD007ULIndent::from_config_struct(config);
775
776        // First level should be indented by start_indent (4 spaces)
777        // Level 0: 4 spaces (start_indent)
778        // Level 1: 6 spaces (start_indent + indent = 4 + 2)
779        // Level 2: 8 spaces (start_indent + 2*indent = 4 + 4)
780        let content = "    * Item 1\n      * Item 2\n        * Item 3";
781        let ctx = LintContext::new(content);
782        let result = rule.check(&ctx).unwrap();
783        assert!(result.is_empty(), "Expected no warnings with start_indented config");
784
785        // Wrong first level indentation
786        let wrong_content = "  * Item 1\n    * Item 2";
787        let ctx = LintContext::new(wrong_content);
788        let result = rule.check(&ctx).unwrap();
789        assert_eq!(result.len(), 2);
790        assert_eq!(result[0].line, 1);
791        assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
792        assert_eq!(result[1].line, 2);
793        assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
794
795        // Fix should correct to start_indent for first level
796        let fixed = rule.fix(&ctx).unwrap();
797        assert_eq!(fixed, "    * Item 1\n      * Item 2");
798    }
799
800    #[test]
801    fn test_start_indented_false_allows_any_first_level() {
802        let rule = MD007ULIndent::default(); // start_indented is false by default
803
804        // When start_indented is false, first level items at any indentation are allowed
805        let content = "   * Item 1"; // First level at 3 spaces
806        let ctx = LintContext::new(content);
807        let result = rule.check(&ctx).unwrap();
808        assert!(
809            result.is_empty(),
810            "First level at any indentation should be allowed when start_indented is false"
811        );
812
813        // Multiple first level items at different indentations should all be allowed
814        let content = "* Item 1\n  * Item 2\n    * Item 3"; // All at level 0 (different indents)
815        let ctx = LintContext::new(content);
816        let result = rule.check(&ctx).unwrap();
817        assert!(
818            result.is_empty(),
819            "All first-level items should be allowed at any indentation"
820        );
821    }
822
823    #[test]
824    fn test_deeply_nested_lists() {
825        let rule = MD007ULIndent::default();
826        let content = r#"* L1
827  * L2
828    * L3
829      * L4
830        * L5
831          * L6"#;
832        let ctx = LintContext::new(content);
833        let result = rule.check(&ctx).unwrap();
834        assert!(result.is_empty());
835
836        // Test with wrong deep nesting
837        let wrong_content = r#"* L1
838  * L2
839    * L3
840      * L4
841         * L5
842            * L6"#;
843        let ctx = LintContext::new(wrong_content);
844        let result = rule.check(&ctx).unwrap();
845        assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
846    }
847
848    #[test]
849    fn test_bullets_nested_under_numbered_items() {
850        let rule = MD007ULIndent::default();
851        let content = "\
8521. **Active Directory/LDAP**
853   - User authentication and directory services
854   - LDAP for user information and validation
855
8562. **Oracle Unified Directory (OUD)**
857   - Extended user directory services";
858        let ctx = LintContext::new(content);
859        let result = rule.check(&ctx).unwrap();
860        // Should have no warnings - 3 spaces is correct for bullets under numbered items
861        assert!(
862            result.is_empty(),
863            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
864        );
865    }
866
867    #[test]
868    fn test_bullets_nested_under_numbered_items_wrong_indent() {
869        let rule = MD007ULIndent::default();
870        let content = "\
8711. **Active Directory/LDAP**
872  - Wrong: only 2 spaces";
873        let ctx = LintContext::new(content);
874        let result = rule.check(&ctx).unwrap();
875        // Should flag incorrect indentation
876        assert_eq!(
877            result.len(),
878            1,
879            "Expected warning for incorrect indentation under numbered items"
880        );
881        assert!(
882            result
883                .iter()
884                .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
885        );
886    }
887
888    #[test]
889    fn test_regular_bullet_nesting_still_works() {
890        let rule = MD007ULIndent::default();
891        let content = "\
892* Top level
893  * Nested bullet (2 spaces is correct)
894    * Deeply nested (4 spaces)";
895        let ctx = LintContext::new(content);
896        let result = rule.check(&ctx).unwrap();
897        // Should have no warnings - standard bullet nesting still uses 2-space increments
898        assert!(
899            result.is_empty(),
900            "Expected no warnings for standard bullet nesting, got: {result:?}"
901        );
902    }
903}