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        ctx.content.is_empty() || !ctx.likely_has_lists()
341    }
342
343    fn as_any(&self) -> &dyn std::any::Any {
344        self
345    }
346
347    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
348    where
349        Self: Sized,
350    {
351        Box::new(MD006StartBullets)
352    }
353
354    fn default_config_section(&self) -> Option<(String, toml::Value)> {
355        None
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn test_with_lint_context() {
365        let rule = MD006StartBullets;
366
367        // Test with properly formatted lists
368        let content_valid = "* Item 1\n* Item 2\n  * Nested item\n  * Another nested item";
369        let ctx_valid = crate::lint_context::LintContext::new(content_valid, crate::config::MarkdownFlavor::Standard);
370        let result_valid = rule.check(&ctx_valid).unwrap();
371        assert!(
372            result_valid.is_empty(),
373            "Properly formatted lists should not generate warnings, found: {result_valid:?}"
374        );
375
376        // Test with improperly indented list - adjust expectations based on actual implementation
377        let content_invalid = "  * Item 1\n  * Item 2\n    * Nested item";
378        let ctx_invalid =
379            crate::lint_context::LintContext::new(content_invalid, crate::config::MarkdownFlavor::Standard);
380        let result = rule.check(&ctx_invalid).unwrap();
381
382        // If no warnings are generated, the test should be updated to match implementation behavior
383        assert!(!result.is_empty(), "Improperly indented lists should generate warnings");
384        assert_eq!(
385            result.len(),
386            3,
387            "Should generate warnings for all improperly indented items (2 top-level + 1 nested)"
388        );
389
390        // Test with mixed indentation - standard nesting is VALID
391        let content = "* Item 1\n  * Item 2 (standard nesting is valid)";
392        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
393        let result = rule.check(&ctx).unwrap();
394        // Assert that standard nesting does NOT generate warnings
395        assert!(
396            result.is_empty(),
397            "Standard nesting (* Item ->   * Item) should NOT generate warnings, found: {result:?}"
398        );
399    }
400
401    #[test]
402    fn test_bullets_nested_under_numbered_items() {
403        let rule = MD006StartBullets;
404        let content = "\
4051. **Active Directory/LDAP**
406   - User authentication and directory services
407   - LDAP for user information and validation
408
4092. **Oracle Unified Directory (OUD)**
410   - Extended user directory services";
411        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
412        let result = rule.check(&ctx).unwrap();
413        // Should have no warnings - 3 spaces is valid for bullets under numbered items
414        assert!(
415            result.is_empty(),
416            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
417        );
418    }
419
420    #[test]
421    fn test_bullets_nested_under_numbered_items_wrong_indent() {
422        let rule = MD006StartBullets;
423        let content = "\
4241. **Active Directory/LDAP**
425  - Wrong: only 2 spaces
426 - Wrong: only 1 space";
427        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428        let result = rule.check(&ctx).unwrap();
429        // Should flag the incorrect indentations (less than 3 spaces)
430        assert_eq!(
431            result.len(),
432            2,
433            "Expected warnings for bullets with insufficient spacing under numbered items"
434        );
435        assert!(result.iter().any(|w| w.line == 2));
436        assert!(result.iter().any(|w| w.line == 3));
437    }
438
439    #[test]
440    fn test_regular_bullet_nesting_still_works() {
441        let rule = MD006StartBullets;
442        let content = "\
443* Top level
444  * Nested bullet (2 spaces is correct)
445    * Deeply nested (4 spaces)";
446        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
447        let result = rule.check(&ctx).unwrap();
448        // Should have no warnings - standard bullet nesting still works
449        assert!(
450            result.is_empty(),
451            "Expected no warnings for standard bullet nesting, got: {result:?}"
452        );
453    }
454}