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