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