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