rumdl_lib/rules/
md006_start_bullets.rs1use 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#[derive(Clone)]
15pub struct MD006StartBullets;
16
17impl MD006StartBullets {
18 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 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 if list_item.marker_column < check_indent {
33 if list_item.is_ordered {
35 return true;
37 }
38 check_indent = list_item.marker_column;
40 }
41 }
42 else if !line_info.is_blank && line_info.indent == 0 {
44 break;
45 }
46 }
47 }
48 false
49 }
50
51 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 let mut valid_bullet_lines = vec![false; lines.len()];
60
61 for list_block in &ctx.list_blocks {
63 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 if list_item.is_ordered {
71 continue;
72 }
73
74 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 is_valid = true;
88 } else {
89 if self.is_nested_under_ordered_item(ctx, item_line, indent) {
94 if indent >= 3 {
97 is_valid = true;
98 }
99 } else {
100 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 is_valid = valid_bullet_lines[prev_idx];
107 }
108 std::cmp::Ordering::Greater => {
109 }
111 }
112 }
113 None => {
114 }
116 }
117 }
118 }
119
120 valid_bullet_lines[line_idx] = is_valid;
121
122 if !is_valid {
123 let start_col = 1;
125 let end_col = indent + 3; 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 let fix_range =
138 line_index.line_col_to_byte_range_with_length(item_line, start_col, end_col - start_col);
139
140 let message = if self.is_nested_under_ordered_item(ctx, item_line, indent) {
142 format!(
144 "Nested list needs at least 3 spaces of indentation under ordered item (found {indent})"
145 )
146 } else if indent > 0 {
147 format!(
149 "Consider starting bulleted lists at the beginning of the line (found {indent} leading spaces)"
150 )
151 } else {
152 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().to_string()),
164 fix: Some(Fix {
165 range: fix_range,
166 replacement: bullet_part,
167 }),
168 });
169 }
170 }
171 }
172 }
173
174 Ok(result)
175 }
176 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 fn is_blank_line(line: &str) -> bool {
188 line.trim().is_empty()
189 }
190
191 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 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 let content_indent = check_line.len() - check_line.trim_start().len();
214
215 let is_continuation = content_indent >= prev_indent.max(2); 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 continue;
234 }
235 }
236 } else {
238 let content_indent = lines[i].len() - lines[i].trim_start().len();
240 if content_indent >= 2 {
242 continue;
243 }
244 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 if content.is_empty() || ctx.list_blocks.is_empty() {
266 return Ok(Vec::new());
267 }
268
269 if !content.contains('*') && !content.contains('-') && !content.contains('+') {
271 return Ok(Vec::new());
272 }
273
274 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 let mut line_replacements = std::collections::HashMap::new();
294 for warning in warnings {
295 if let Some(fix) = warning.fix {
296 let line_idx = warning.line - 1;
298 line_replacements.insert(line_idx, fix.replacement);
299 }
300 }
301
302 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 if !prev_line_is_blank && !prev_line_is_list && i > 0 {
311 fixed_lines.push(String::new());
312 }
313 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 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 fn category(&self) -> RuleCategory {
335 RuleCategory::List
336 }
337
338 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 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 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 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 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!(
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 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 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 assert!(
450 result.is_empty(),
451 "Expected no warnings for standard bullet nesting, got: {result:?}"
452 );
453 }
454}