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