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