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 mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
392
393        // For markdownlint compatibility: if indent is explicitly configured and style is not,
394        // default to "fixed" style (markdownlint behavior) instead of "text-aligned"
395        if let Some(rule_cfg) = config.rules.get("MD007") {
396            let has_explicit_indent = rule_cfg.values.contains_key("indent");
397            let has_explicit_style = rule_cfg.values.contains_key("style");
398
399            if has_explicit_indent && !has_explicit_style && rule_config.indent != 2 {
400                // User set indent explicitly but not style, and it's not the default value
401                // Use fixed style for markdownlint compatibility
402                rule_config.style = md007_config::IndentStyle::Fixed;
403            }
404        }
405
406        Box::new(Self::from_config_struct(rule_config))
407    }
408}
409
410impl DocumentStructureExtensions for MD007ULIndent {
411    fn has_relevant_elements(
412        &self,
413        _ctx: &crate::lint_context::LintContext,
414        doc_structure: &DocumentStructure,
415    ) -> bool {
416        // Use the document structure to check if there are any unordered list elements
417        !doc_structure.list_lines.is_empty()
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::lint_context::LintContext;
425    use crate::rule::Rule;
426
427    #[test]
428    fn test_valid_list_indent() {
429        let rule = MD007ULIndent::default();
430        let content = "* Item 1\n  * Item 2\n    * Item 3";
431        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
432        let result = rule.check(&ctx).unwrap();
433        assert!(
434            result.is_empty(),
435            "Expected no warnings for valid indentation, but got {} warnings",
436            result.len()
437        );
438    }
439
440    #[test]
441    fn test_invalid_list_indent() {
442        let rule = MD007ULIndent::default();
443        let content = "* Item 1\n   * Item 2\n      * Item 3";
444        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
445        let result = rule.check(&ctx).unwrap();
446        assert_eq!(result.len(), 2);
447        assert_eq!(result[0].line, 2);
448        assert_eq!(result[0].column, 1);
449        assert_eq!(result[1].line, 3);
450        assert_eq!(result[1].column, 1);
451    }
452
453    #[test]
454    fn test_mixed_indentation() {
455        let rule = MD007ULIndent::default();
456        let content = "* Item 1\n  * Item 2\n   * Item 3\n  * Item 4";
457        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
458        let result = rule.check(&ctx).unwrap();
459        assert_eq!(result.len(), 1);
460        assert_eq!(result[0].line, 3);
461        assert_eq!(result[0].column, 1);
462    }
463
464    #[test]
465    fn test_fix_indentation() {
466        let rule = MD007ULIndent::default();
467        let content = "* Item 1\n   * Item 2\n      * Item 3";
468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
469        let result = rule.fix(&ctx).unwrap();
470        // With dynamic alignment:
471        // Item 2 aligns with Item 1's text (2 spaces)
472        // Item 3 aligns with Item 2's text (4 + 1 = 5 spaces)
473        let expected = "* Item 1\n  * Item 2\n     * Item 3";
474        assert_eq!(result, expected);
475    }
476
477    #[test]
478    fn test_md007_in_yaml_code_block() {
479        let rule = MD007ULIndent::default();
480        let content = r#"```yaml
481repos:
482-   repo: https://github.com/rvben/rumdl
483    rev: v0.5.0
484    hooks:
485    -   id: rumdl-check
486```"#;
487        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
488        let result = rule.check(&ctx).unwrap();
489        assert!(
490            result.is_empty(),
491            "MD007 should not trigger inside a code block, but got warnings: {result:?}"
492        );
493    }
494
495    #[test]
496    fn test_blockquoted_list_indent() {
497        let rule = MD007ULIndent::default();
498        let content = "> * Item 1\n>   * Item 2\n>     * Item 3";
499        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
500        let result = rule.check(&ctx).unwrap();
501        assert!(
502            result.is_empty(),
503            "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
504        );
505    }
506
507    #[test]
508    fn test_blockquoted_list_invalid_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);
512        let result = rule.check(&ctx).unwrap();
513        assert_eq!(
514            result.len(),
515            2,
516            "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
517        );
518        assert_eq!(result[0].line, 2);
519        assert_eq!(result[1].line, 3);
520    }
521
522    #[test]
523    fn test_nested_blockquote_list_indent() {
524        let rule = MD007ULIndent::default();
525        let content = "> > * Item 1\n> >   * Item 2\n> >     * Item 3";
526        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
527        let result = rule.check(&ctx).unwrap();
528        assert!(
529            result.is_empty(),
530            "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
531        );
532    }
533
534    #[test]
535    fn test_blockquote_list_with_code_block() {
536        let rule = MD007ULIndent::default();
537        let content = "> * Item 1\n>   * Item 2\n>   ```\n>   code\n>   ```\n>   * Item 3";
538        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
539        let result = rule.check(&ctx).unwrap();
540        assert!(
541            result.is_empty(),
542            "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
543        );
544    }
545
546    #[test]
547    fn test_properly_indented_lists() {
548        let rule = MD007ULIndent::default();
549
550        // Test various properly indented lists
551        let test_cases = vec![
552            "* Item 1\n* Item 2",
553            "* Item 1\n  * Item 1.1\n    * Item 1.1.1",
554            "- Item 1\n  - Item 1.1",
555            "+ Item 1\n  + Item 1.1",
556            "* Item 1\n  * Item 1.1\n* Item 2\n  * Item 2.1",
557        ];
558
559        for content in test_cases {
560            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
561            let result = rule.check(&ctx).unwrap();
562            assert!(
563                result.is_empty(),
564                "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
565                content,
566                result.len()
567            );
568        }
569    }
570
571    #[test]
572    fn test_under_indented_lists() {
573        let rule = MD007ULIndent::default();
574
575        let test_cases = vec![
576            ("* Item 1\n * Item 1.1", 1, 2),                   // Expected 2 spaces, got 1
577            ("* Item 1\n  * Item 1.1\n   * Item 1.1.1", 1, 3), // Expected 4 spaces, got 3
578        ];
579
580        for (content, expected_warnings, line) in test_cases {
581            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
582            let result = rule.check(&ctx).unwrap();
583            assert_eq!(
584                result.len(),
585                expected_warnings,
586                "Expected {expected_warnings} warnings for under-indented list:\n{content}"
587            );
588            if expected_warnings > 0 {
589                assert_eq!(result[0].line, line);
590            }
591        }
592    }
593
594    #[test]
595    fn test_over_indented_lists() {
596        let rule = MD007ULIndent::default();
597
598        let test_cases = vec![
599            ("* Item 1\n   * Item 1.1", 1, 2),                   // Expected 2 spaces, got 3
600            ("* Item 1\n    * Item 1.1", 1, 2),                  // Expected 2 spaces, got 4
601            ("* Item 1\n  * Item 1.1\n     * Item 1.1.1", 1, 3), // Expected 4 spaces, got 5
602        ];
603
604        for (content, expected_warnings, line) in test_cases {
605            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
606            let result = rule.check(&ctx).unwrap();
607            assert_eq!(
608                result.len(),
609                expected_warnings,
610                "Expected {expected_warnings} warnings for over-indented list:\n{content}"
611            );
612            if expected_warnings > 0 {
613                assert_eq!(result[0].line, line);
614            }
615        }
616    }
617
618    #[test]
619    fn test_custom_indent_2_spaces() {
620        let rule = MD007ULIndent::new(2); // Default
621        let content = "* Item 1\n  * Item 2\n    * Item 3";
622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
623        let result = rule.check(&ctx).unwrap();
624        assert!(result.is_empty());
625    }
626
627    #[test]
628    fn test_custom_indent_3_spaces() {
629        // Test dynamic alignment behavior (default start_indented=false)
630        let rule = MD007ULIndent::new(3);
631
632        let content = "* Item 1\n   * Item 2\n      * Item 3";
633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
634        let result = rule.check(&ctx).unwrap();
635        // With dynamic alignment, Item 2 should align with Item 1's text (2 spaces)
636        // and Item 3 should align with Item 2's text (4 spaces), not fixed increments
637        assert!(!result.is_empty()); // Should have warnings due to alignment
638
639        // Test that dynamic alignment works correctly
640        // Item 3 should align with Item 2's text content (4 spaces)
641        let correct_content = "* Item 1\n  * Item 2\n    * Item 3";
642        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
643        let result = rule.check(&ctx).unwrap();
644        assert!(result.is_empty());
645    }
646
647    #[test]
648    fn test_custom_indent_4_spaces() {
649        // Test dynamic alignment behavior (default start_indented=false)
650        let rule = MD007ULIndent::new(4);
651        let content = "* Item 1\n    * Item 2\n        * Item 3";
652        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
653        let result = rule.check(&ctx).unwrap();
654        // With dynamic alignment, should expect 2 spaces and 6 spaces, not 4 and 8
655        assert!(!result.is_empty()); // Should have warnings due to alignment
656
657        // Test correct dynamic alignment
658        // Item 3 should align with Item 2's text content (4 spaces)
659        let correct_content = "* Item 1\n  * Item 2\n    * Item 3";
660        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
661        let result = rule.check(&ctx).unwrap();
662        assert!(result.is_empty());
663    }
664
665    #[test]
666    fn test_tab_indentation() {
667        let rule = MD007ULIndent::default();
668
669        // Single tab
670        let content = "* Item 1\n\t* Item 2";
671        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
672        let result = rule.check(&ctx).unwrap();
673        assert_eq!(result.len(), 1, "Tab indentation should trigger warning");
674
675        // Fix should convert tab to spaces
676        let fixed = rule.fix(&ctx).unwrap();
677        assert_eq!(fixed, "* Item 1\n  * Item 2");
678
679        // Multiple tabs
680        let content_multi = "* Item 1\n\t* Item 2\n\t\t* Item 3";
681        let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard);
682        let fixed = rule.fix(&ctx).unwrap();
683        // With dynamic alignment: Item 3 aligns with Item 2 at correct position
684        assert_eq!(fixed, "* Item 1\n  * Item 2\n   * Item 3");
685
686        // Mixed tabs and spaces
687        let content_mixed = "* Item 1\n \t* Item 2\n\t * Item 3";
688        let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard);
689        let fixed = rule.fix(&ctx).unwrap();
690        // With dynamic alignment: Item 3 aligns with Item 2 at correct position
691        assert_eq!(fixed, "* Item 1\n  * Item 2\n    * Item 3");
692    }
693
694    #[test]
695    fn test_mixed_ordered_unordered_lists() {
696        let rule = MD007ULIndent::default();
697
698        // MD007 only checks unordered lists, so ordered lists should be ignored
699        // Note: 3 spaces is now correct for bullets under ordered items
700        let content = r#"1. Ordered item
701   * Unordered sub-item (correct - 3 spaces under ordered)
702   2. Ordered sub-item
703* Unordered item
704  1. Ordered sub-item
705  * Unordered sub-item"#;
706
707        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
708        let result = rule.check(&ctx).unwrap();
709        assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
710
711        // No fix needed as all indentation is correct
712        let fixed = rule.fix(&ctx).unwrap();
713        assert_eq!(fixed, content);
714    }
715
716    #[test]
717    fn test_list_markers_variety() {
718        let rule = MD007ULIndent::default();
719
720        // Test all three unordered list markers
721        let content = r#"* Asterisk
722  * Nested asterisk
723- Hyphen
724  - Nested hyphen
725+ Plus
726  + Nested plus"#;
727
728        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
729        let result = rule.check(&ctx).unwrap();
730        assert!(
731            result.is_empty(),
732            "All unordered list markers should work with proper indentation"
733        );
734
735        // Test with wrong indentation for each marker type
736        let wrong_content = r#"* Asterisk
737   * Wrong asterisk
738- Hyphen
739 - Wrong hyphen
740+ Plus
741    + Wrong plus"#;
742
743        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
744        let result = rule.check(&ctx).unwrap();
745        assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
746    }
747
748    #[test]
749    fn test_empty_list_items() {
750        let rule = MD007ULIndent::default();
751        let content = "* Item 1\n* \n  * Item 2";
752        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
753        let result = rule.check(&ctx).unwrap();
754        assert!(
755            result.is_empty(),
756            "Empty list items should not affect indentation checks"
757        );
758    }
759
760    #[test]
761    fn test_list_with_code_blocks() {
762        let rule = MD007ULIndent::default();
763        let content = r#"* Item 1
764  ```
765  code
766  ```
767  * Item 2
768    * Item 3"#;
769        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
770        let result = rule.check(&ctx).unwrap();
771        assert!(result.is_empty());
772    }
773
774    #[test]
775    fn test_list_in_front_matter() {
776        let rule = MD007ULIndent::default();
777        let content = r#"---
778tags:
779  - tag1
780  - tag2
781---
782* Item 1
783  * Item 2"#;
784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
785        let result = rule.check(&ctx).unwrap();
786        assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
787    }
788
789    #[test]
790    fn test_fix_preserves_content() {
791        let rule = MD007ULIndent::default();
792        let content = "* Item 1 with **bold** and *italic*\n   * Item 2 with `code`\n     * Item 3 with [link](url)";
793        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
794        let fixed = rule.fix(&ctx).unwrap();
795        // With dynamic alignment: Item 3 aligns with Item 2's text (2 + 2 + 1 = 5 spaces)
796        let expected = "* Item 1 with **bold** and *italic*\n  * Item 2 with `code`\n     * Item 3 with [link](url)";
797        assert_eq!(fixed, expected, "Fix should only change indentation, not content");
798    }
799
800    #[test]
801    fn test_start_indented_config() {
802        let config = MD007Config {
803            start_indented: true,
804            start_indent: 4,
805            indent: 2,
806            style: md007_config::IndentStyle::TextAligned,
807        };
808        let rule = MD007ULIndent::from_config_struct(config);
809
810        // First level should be indented by start_indent (4 spaces)
811        // Level 0: 4 spaces (start_indent)
812        // Level 1: 6 spaces (start_indent + indent = 4 + 2)
813        // Level 2: 8 spaces (start_indent + 2*indent = 4 + 4)
814        let content = "    * Item 1\n      * Item 2\n        * Item 3";
815        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
816        let result = rule.check(&ctx).unwrap();
817        assert!(result.is_empty(), "Expected no warnings with start_indented config");
818
819        // Wrong first level indentation
820        let wrong_content = "  * Item 1\n    * Item 2";
821        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
822        let result = rule.check(&ctx).unwrap();
823        assert_eq!(result.len(), 2);
824        assert_eq!(result[0].line, 1);
825        assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
826        assert_eq!(result[1].line, 2);
827        assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
828
829        // Fix should correct to start_indent for first level
830        let fixed = rule.fix(&ctx).unwrap();
831        assert_eq!(fixed, "    * Item 1\n      * Item 2");
832    }
833
834    #[test]
835    fn test_start_indented_false_allows_any_first_level() {
836        let rule = MD007ULIndent::default(); // start_indented is false by default
837
838        // When start_indented is false, first level items at any indentation are allowed
839        let content = "   * Item 1"; // First level at 3 spaces
840        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
841        let result = rule.check(&ctx).unwrap();
842        assert!(
843            result.is_empty(),
844            "First level at any indentation should be allowed when start_indented is false"
845        );
846
847        // Multiple first level items at different indentations should all be allowed
848        let content = "* Item 1\n  * Item 2\n    * Item 3"; // All at level 0 (different indents)
849        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
850        let result = rule.check(&ctx).unwrap();
851        assert!(
852            result.is_empty(),
853            "All first-level items should be allowed at any indentation"
854        );
855    }
856
857    #[test]
858    fn test_deeply_nested_lists() {
859        let rule = MD007ULIndent::default();
860        let content = r#"* L1
861  * L2
862    * L3
863      * L4
864        * L5
865          * L6"#;
866        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
867        let result = rule.check(&ctx).unwrap();
868        assert!(result.is_empty());
869
870        // Test with wrong deep nesting
871        let wrong_content = r#"* L1
872  * L2
873    * L3
874      * L4
875         * L5
876            * L6"#;
877        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard);
878        let result = rule.check(&ctx).unwrap();
879        assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
880    }
881
882    #[test]
883    fn test_excessive_indentation_detected() {
884        let rule = MD007ULIndent::default();
885
886        // Test excessive indentation (5 spaces instead of 2)
887        let content = "- Item 1\n     - Item 2 with 5 spaces";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
889        let result = rule.check(&ctx).unwrap();
890        assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
891        assert_eq!(result[0].line, 2);
892        assert!(result[0].message.contains("Expected 2 spaces"));
893        assert!(result[0].message.contains("found 5"));
894
895        // Test slightly excessive indentation (3 spaces instead of 2)
896        let content = "- Item 1\n   - Item 2 with 3 spaces";
897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
898        let result = rule.check(&ctx).unwrap();
899        assert_eq!(
900            result.len(),
901            1,
902            "Should detect slightly excessive indentation (3 instead of 2)"
903        );
904        assert_eq!(result[0].line, 2);
905        assert!(result[0].message.contains("Expected 2 spaces"));
906        assert!(result[0].message.contains("found 3"));
907
908        // Test insufficient indentation (1 space instead of 2)
909        let content = "- Item 1\n - Item 2 with 1 space";
910        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
911        let result = rule.check(&ctx).unwrap();
912        assert_eq!(
913            result.len(),
914            1,
915            "Should detect insufficient indentation (1 instead of 2)"
916        );
917        assert_eq!(result[0].line, 2);
918        assert!(result[0].message.contains("Expected 2 spaces"));
919        assert!(result[0].message.contains("found 1"));
920    }
921
922    #[test]
923    fn test_excessive_indentation_with_4_space_config() {
924        let rule = MD007ULIndent::new(4);
925
926        // Test excessive indentation (5 spaces instead of 4) - like Ruff's versioning.md
927        let content = "- Formatter:\n     - The stable style changed";
928        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
929        let result = rule.check(&ctx).unwrap();
930
931        // Due to text-aligned style, the expected indent should be 2 (aligning with "Formatter" text)
932        // But with 5 spaces, it's wrong
933        assert!(
934            !result.is_empty(),
935            "Should detect 5 spaces when expecting proper alignment"
936        );
937
938        // Test with correct alignment
939        let correct_content = "- Formatter:\n  - The stable style changed";
940        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard);
941        let result = rule.check(&ctx).unwrap();
942        assert!(result.is_empty(), "Should accept correct text alignment");
943    }
944
945    #[test]
946    fn test_bullets_nested_under_numbered_items() {
947        let rule = MD007ULIndent::default();
948        let content = "\
9491. **Active Directory/LDAP**
950   - User authentication and directory services
951   - LDAP for user information and validation
952
9532. **Oracle Unified Directory (OUD)**
954   - Extended user directory services";
955        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
956        let result = rule.check(&ctx).unwrap();
957        // Should have no warnings - 3 spaces is correct for bullets under numbered items
958        assert!(
959            result.is_empty(),
960            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
961        );
962    }
963
964    #[test]
965    fn test_bullets_nested_under_numbered_items_wrong_indent() {
966        let rule = MD007ULIndent::default();
967        let content = "\
9681. **Active Directory/LDAP**
969  - Wrong: only 2 spaces";
970        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
971        let result = rule.check(&ctx).unwrap();
972        // Should flag incorrect indentation
973        assert_eq!(
974            result.len(),
975            1,
976            "Expected warning for incorrect indentation under numbered items"
977        );
978        assert!(
979            result
980                .iter()
981                .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
982        );
983    }
984
985    #[test]
986    fn test_regular_bullet_nesting_still_works() {
987        let rule = MD007ULIndent::default();
988        let content = "\
989* Top level
990  * Nested bullet (2 spaces is correct)
991    * Deeply nested (4 spaces)";
992        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
993        let result = rule.check(&ctx).unwrap();
994        // Should have no warnings - standard bullet nesting still uses 2-space increments
995        assert!(
996            result.is_empty(),
997            "Expected no warnings for standard bullet nesting, got: {result:?}"
998        );
999    }
1000}