rumdl_lib/rules/
md006_start_bullets.rs

1use crate::utils::range_utils::LineIndex;
2
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
4use crate::utils::regex_cache::UNORDERED_LIST_MARKER_REGEX;
5
6/// Rule MD006: Consider starting bulleted lists at the leftmost column
7///
8/// See [docs/md006.md](../../docs/md006.md) for full documentation, configuration, and examples.
9///
10/// In standard Markdown:
11/// - Top-level bullet items should start at column 0 (no indentation)
12/// - Nested bullet items should be indented under their parent
13/// - A bullet item following non-list content should start a new list at column 0
14#[derive(Clone)]
15pub struct MD006StartBullets;
16
17impl MD006StartBullets {
18    /// Check if a bullet is nested under an ordered list item (anywhere in the hierarchy)
19    fn is_nested_under_ordered_item(
20        &self,
21        ctx: &crate::lint_context::LintContext,
22        current_line: usize,
23        current_indent: usize,
24    ) -> bool {
25        // Look backward from current line to find any ordered ancestor
26        let mut check_indent = current_indent;
27
28        for line_idx in (1..current_line).rev() {
29            if let Some(line_info) = ctx.line_info(line_idx) {
30                if let Some(list_item) = &line_info.list_item {
31                    // Found a list item - check if it's at a lower indentation (ancestor level)
32                    if list_item.marker_column < check_indent {
33                        // This is an ancestor item
34                        if list_item.is_ordered {
35                            // Found an ordered ancestor
36                            return true;
37                        }
38                        // Continue looking for higher-level ancestors
39                        check_indent = list_item.marker_column;
40                    }
41                }
42                // If we encounter non-blank, non-list content at column 0, stop looking
43                else if !line_info.is_blank && line_info.indent == 0 {
44                    break;
45                }
46            }
47        }
48        false
49    }
50
51    /// Optimized check using centralized list blocks
52    fn check_optimized(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
53        let content = ctx.content;
54        let line_index = LineIndex::new(content.to_string());
55        let mut result = Vec::new();
56        let lines: Vec<&str> = content.lines().collect();
57
58        // Track which lines contain valid bullet items
59        let mut valid_bullet_lines = vec![false; lines.len()];
60
61        // Process each list block
62        for list_block in &ctx.list_blocks {
63            // Check each list item in this block
64            // We need to check unordered items even in mixed lists
65            for &item_line in &list_block.item_lines {
66                if let Some(line_info) = ctx.line_info(item_line)
67                    && let Some(list_item) = &line_info.list_item
68                {
69                    // Skip ordered list items - we only care about unordered ones
70                    if list_item.is_ordered {
71                        continue;
72                    }
73
74                    // Skip list items inside blockquotes - they're supposed to be indented
75                    if line_info.blockquote.is_some() {
76                        continue;
77                    }
78
79                    let line_idx = item_line - 1;
80                    let indent = list_item.marker_column;
81                    let line = &lines[line_idx];
82
83                    let mut is_valid = false;
84
85                    if indent == 0 {
86                        // Top-level items are always valid
87                        is_valid = true;
88                    } else {
89                        // Check if this is nested under an ordered item with correct indentation
90                        // For single-digit ordered lists (1.), need at least 3 spaces for proper nesting
91                        // For double-digit (10.), need at least 4 spaces, etc.
92                        // But MD006's purpose is to flag top-level indented lists, not validate nesting depth
93                        if self.is_nested_under_ordered_item(ctx, item_line, indent) {
94                            // It's nested under an ordered item
95                            // Only flag if indentation is less than 3 (won't nest properly in CommonMark)
96                            if indent >= 3 {
97                                is_valid = true;
98                            }
99                        } else {
100                            // Check if this is a valid nested item under another bullet
101                            match Self::find_relevant_previous_bullet(&lines, line_idx) {
102                                Some((prev_idx, prev_indent)) => {
103                                    match prev_indent.cmp(&indent) {
104                                        std::cmp::Ordering::Less | std::cmp::Ordering::Equal => {
105                                            // Valid nesting or sibling if previous item was valid
106                                            is_valid = valid_bullet_lines[prev_idx];
107                                        }
108                                        std::cmp::Ordering::Greater => {
109                                            // remains invalid
110                                        }
111                                    }
112                                }
113                                None => {
114                                    // Indented item with no previous bullet remains invalid
115                                }
116                            }
117                        }
118                    }
119
120                    valid_bullet_lines[line_idx] = is_valid;
121
122                    if !is_valid {
123                        // Calculate the precise range for the indentation that needs to be removed
124                        let start_col = 1;
125                        let end_col = indent + 3; // Include marker and space after it
126
127                        // For the fix, we need to replace the highlighted part with just the bullet marker
128                        let trimmed = line.trim_start();
129                        let bullet_part = if let Some(captures) = UNORDERED_LIST_MARKER_REGEX.captures(trimmed) {
130                            let marker = captures.get(2).map_or("*", |m| m.as_str());
131                            format!("{marker} ")
132                        } else {
133                            "* ".to_string()
134                        };
135
136                        // Calculate the byte range for the fix
137                        let fix_range =
138                            line_index.line_col_to_byte_range_with_length(item_line, start_col, end_col - start_col);
139
140                        // Generate appropriate message based on context
141                        let message = if self.is_nested_under_ordered_item(ctx, item_line, indent) {
142                            // It's trying to nest under an ordered item but has insufficient indentation
143                            format!(
144                                "Nested list needs at least 3 spaces of indentation under ordered item (found {indent})"
145                            )
146                        } else if indent > 0 {
147                            // It's indented but not nested under anything - should start at column 0
148                            format!(
149                                "Consider starting bulleted lists at the beginning of the line (found {indent} leading spaces)"
150                            )
151                        } else {
152                            // Shouldn't happen, but just in case
153                            format!("List indentation issue (found {indent} leading spaces)")
154                        };
155
156                        result.push(LintWarning {
157                            line: item_line,
158                            column: start_col,
159                            end_line: item_line,
160                            end_column: end_col,
161                            message,
162                            severity: Severity::Warning,
163                            rule_name: Some(self.name()),
164                            fix: Some(Fix {
165                                range: fix_range,
166                                replacement: bullet_part,
167                            }),
168                        });
169                    }
170                }
171            }
172        }
173
174        Ok(result)
175    }
176    /// Checks if a line is a bullet list item and returns its indentation level
177    fn is_bullet_list_item(line: &str) -> Option<usize> {
178        if let Some(captures) = UNORDERED_LIST_MARKER_REGEX.captures(line)
179            && let Some(indent) = captures.get(1)
180        {
181            return Some(indent.as_str().len());
182        }
183        None
184    }
185
186    /// Checks if a line is blank (empty or whitespace only)
187    fn is_blank_line(line: &str) -> bool {
188        line.trim().is_empty()
189    }
190
191    /// Find the most relevant previous bullet item for nesting validation
192    fn find_relevant_previous_bullet(lines: &[&str], line_idx: usize) -> Option<(usize, usize)> {
193        let current_indent = Self::is_bullet_list_item(lines[line_idx])?;
194
195        let mut i = line_idx;
196
197        while i > 0 {
198            i -= 1;
199            if Self::is_blank_line(lines[i]) {
200                continue;
201            }
202            if let Some(prev_indent) = Self::is_bullet_list_item(lines[i]) {
203                if prev_indent <= current_indent {
204                    // Found a potential parent or sibling
205                    // Check if there's any non-list content between this potential parent and current item
206                    let mut has_breaking_content = false;
207                    for check_line in &lines[(i + 1)..line_idx] {
208                        if Self::is_blank_line(check_line) {
209                            continue;
210                        }
211                        if Self::is_bullet_list_item(check_line).is_none() {
212                            // Found non-list content - check if it breaks the list structure
213                            let content_indent = check_line.len() - check_line.trim_start().len();
214
215                            // Content is acceptable if:
216                            // 1. It's indented at least as much as the current item (continuation of parent)
217                            // 2. OR it's indented more than the previous bullet (continuation of previous item)
218                            // 3. AND we have a true parent relationship (prev_indent < current_indent)
219                            let is_continuation = content_indent >= prev_indent.max(2); // At least 2 spaces for continuation
220                            let is_valid_nesting = prev_indent < current_indent;
221
222                            if !is_continuation || !is_valid_nesting {
223                                has_breaking_content = true;
224                                break;
225                            }
226                        }
227                    }
228
229                    if !has_breaking_content {
230                        return Some((i, prev_indent));
231                    } else {
232                        // Content breaks the list structure, but continue searching for an earlier valid parent
233                        continue;
234                    }
235                }
236                // If prev_indent > current_indent, it's a child of a sibling, ignore it and keep searching.
237            } else {
238                // Found non-list content - check if it's a continuation line
239                let content_indent = lines[i].len() - lines[i].trim_start().len();
240                // If it's indented enough to be a continuation, don't break the search
241                if content_indent >= 2 {
242                    continue;
243                }
244                // Otherwise, this breaks the search
245                return None;
246            }
247        }
248        None
249    }
250}
251
252impl Rule for MD006StartBullets {
253    fn name(&self) -> &'static str {
254        "MD006"
255    }
256
257    fn description(&self) -> &'static str {
258        "Consider starting bulleted lists at the beginning of the line"
259    }
260
261    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
262        let content = ctx.content;
263
264        // Early returns for performance
265        if content.is_empty() || ctx.list_blocks.is_empty() {
266            return Ok(Vec::new());
267        }
268
269        // Quick check for any list markers before processing
270        if !content.contains('*') && !content.contains('-') && !content.contains('+') {
271            return Ok(Vec::new());
272        }
273
274        // Use centralized list blocks for better performance and consistency
275        self.check_optimized(ctx)
276    }
277
278    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
279        let content = ctx.content;
280        let _line_index = LineIndex::new(content.to_string());
281
282        let warnings = self.check(ctx)?;
283        if warnings.is_empty() {
284            return Ok(content.to_string());
285        }
286
287        let lines: Vec<&str> = content.lines().collect();
288
289        let mut fixed_lines: Vec<String> = Vec::with_capacity(lines.len());
290
291        // Create a map of line numbers to replacements
292
293        let mut line_replacements = std::collections::HashMap::new();
294        for warning in warnings {
295            if let Some(fix) = warning.fix {
296                // Line number is 1-based in warnings but we need 0-based for indexing
297                let line_idx = warning.line - 1;
298                line_replacements.insert(line_idx, fix.replacement);
299            }
300        }
301
302        // Apply replacements line by line
303
304        let mut i = 0;
305        while i < lines.len() {
306            if let Some(_replacement) = line_replacements.get(&i) {
307                let prev_line_is_blank = i > 0 && Self::is_blank_line(lines[i - 1]);
308                let prev_line_is_list = i > 0 && Self::is_bullet_list_item(lines[i - 1]).is_some();
309                // Only insert a blank line if previous line is not blank and not a list
310                if !prev_line_is_blank && !prev_line_is_list && i > 0 {
311                    fixed_lines.push(String::new());
312                }
313                // The replacement is the fixed line (unindented list item)
314                // Use the original line, trimmed of leading whitespace
315                let fixed_line = lines[i].trim_start();
316                fixed_lines.push(fixed_line.to_string());
317            } else {
318                fixed_lines.push(lines[i].to_string());
319            }
320            i += 1;
321        }
322
323        // Join the lines with newlines
324
325        let result = fixed_lines.join("\n");
326        if content.ends_with('\n') {
327            Ok(result + "\n")
328        } else {
329            Ok(result)
330        }
331    }
332
333    /// Get the category of this rule for selective processing
334    fn category(&self) -> RuleCategory {
335        RuleCategory::List
336    }
337
338    /// Check if this rule should be skipped
339    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
340        let content = ctx.content;
341        content.is_empty() || (!content.contains('*') && !content.contains('-') && !content.contains('+'))
342    }
343
344    fn as_any(&self) -> &dyn std::any::Any {
345        self
346    }
347
348    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
349    where
350        Self: Sized,
351    {
352        Box::new(MD006StartBullets)
353    }
354
355    fn default_config_section(&self) -> Option<(String, toml::Value)> {
356        None
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_with_lint_context() {
366        let rule = MD006StartBullets;
367
368        // Test with properly formatted lists
369        let content_valid = "* Item 1\n* Item 2\n  * Nested item\n  * Another nested item";
370        let ctx_valid = crate::lint_context::LintContext::new(content_valid, crate::config::MarkdownFlavor::Standard);
371        let result_valid = rule.check(&ctx_valid).unwrap();
372        assert!(
373            result_valid.is_empty(),
374            "Properly formatted lists should not generate warnings, found: {result_valid:?}"
375        );
376
377        // Test with improperly indented list - adjust expectations based on actual implementation
378        let content_invalid = "  * Item 1\n  * Item 2\n    * Nested item";
379        let ctx_invalid =
380            crate::lint_context::LintContext::new(content_invalid, crate::config::MarkdownFlavor::Standard);
381        let result = rule.check(&ctx_invalid).unwrap();
382
383        // If no warnings are generated, the test should be updated to match implementation behavior
384        assert!(!result.is_empty(), "Improperly indented lists should generate warnings");
385        assert_eq!(
386            result.len(),
387            3,
388            "Should generate warnings for all improperly indented items (2 top-level + 1 nested)"
389        );
390
391        // Test with mixed indentation - standard nesting is VALID
392        let content = "* Item 1\n  * Item 2 (standard nesting is valid)";
393        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
394        let result = rule.check(&ctx).unwrap();
395        // Assert that standard nesting does NOT generate warnings
396        assert!(
397            result.is_empty(),
398            "Standard nesting (* Item ->   * Item) should NOT generate warnings, found: {result:?}"
399        );
400    }
401
402    #[test]
403    fn test_bullets_nested_under_numbered_items() {
404        let rule = MD006StartBullets;
405        let content = "\
4061. **Active Directory/LDAP**
407   - User authentication and directory services
408   - LDAP for user information and validation
409
4102. **Oracle Unified Directory (OUD)**
411   - Extended user directory services";
412        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
413        let result = rule.check(&ctx).unwrap();
414        // Should have no warnings - 3 spaces is valid for bullets under numbered items
415        assert!(
416            result.is_empty(),
417            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
418        );
419    }
420
421    #[test]
422    fn test_bullets_nested_under_numbered_items_wrong_indent() {
423        let rule = MD006StartBullets;
424        let content = "\
4251. **Active Directory/LDAP**
426  - Wrong: only 2 spaces
427 - Wrong: only 1 space";
428        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
429        let result = rule.check(&ctx).unwrap();
430        // Should flag the incorrect indentations (less than 3 spaces)
431        assert_eq!(
432            result.len(),
433            2,
434            "Expected warnings for bullets with insufficient spacing under numbered items"
435        );
436        assert!(result.iter().any(|w| w.line == 2));
437        assert!(result.iter().any(|w| w.line == 3));
438    }
439
440    #[test]
441    fn test_regular_bullet_nesting_still_works() {
442        let rule = MD006StartBullets;
443        let content = "\
444* Top level
445  * Nested bullet (2 spaces is correct)
446    * Deeply nested (4 spaces)";
447        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
448        let result = rule.check(&ctx).unwrap();
449        // Should have no warnings - standard bullet nesting still works
450        assert!(
451            result.is_empty(),
452            "Expected no warnings for standard bullet nesting, got: {result:?}"
453        );
454    }
455}