use crate::Document;
use crate::error::Result;
use crate::rule::{Rule, RuleCategory, RuleMetadata};
use crate::violation::{Severity, Violation};
pub struct MD006;
impl Rule for MD006 {
fn id(&self) -> &'static str {
"MD006"
}
fn name(&self) -> &'static str {
"ul-start-left"
}
fn description(&self) -> &'static str {
"Consider starting bulleted lists at the beginning of the line"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Formatting)
}
fn check_with_ast<'a>(
&self,
document: &Document,
_ast: Option<&'a comrak::nodes::AstNode<'a>>,
) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let lines: Vec<&str> = document.content.lines().collect();
let in_code_block = self.get_code_block_ranges(&lines);
for (line_number, line) in lines.iter().enumerate() {
let line_number = line_number + 1;
if line.trim().is_empty() {
continue;
}
if in_code_block[line_number - 1] {
continue;
}
if let Some(first_char_pos) = line.find(|c: char| !c.is_whitespace()) {
if first_char_pos > 0 {
let remaining = &line[first_char_pos..];
if let Some(first_char) = remaining.chars().next() {
if matches!(first_char, '*' | '+' | '-') {
if remaining.len() > 1 {
let second_char = remaining.chars().nth(1).unwrap();
if second_char.is_whitespace() {
violations.push(self.create_violation(
"Consider starting bulleted lists at the beginning of the line".to_string(),
line_number,
1,
Severity::Warning,
));
}
}
}
}
}
}
}
Ok(violations)
}
}
impl MD006 {
fn get_code_block_ranges(&self, lines: &[&str]) -> Vec<bool> {
let mut in_code_block = vec![false; lines.len()];
let mut in_fenced_block = false;
let mut in_indented_block = false;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fenced_block = !in_fenced_block;
in_code_block[i] = true;
continue;
}
if in_fenced_block {
in_code_block[i] = true;
continue;
}
if !line.trim().is_empty() && line.starts_with(" ") {
in_indented_block = true;
in_code_block[i] = true;
} else if !line.trim().is_empty() {
in_indented_block = false;
} else if in_indented_block {
in_code_block[i] = true;
}
}
in_code_block
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Document;
use std::path::PathBuf;
#[test]
fn test_md006_no_violations() {
let content = r#"# Heading
* Item 1
* Item 2
* Item 3
Some text
+ Item A
+ Item B
More text
- Item X
- Item Y
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD006;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md006_indented_list() {
let content = r#"# Heading
Some text
* Indented item 1
* Indented item 2
More text
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD006;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 4);
assert_eq!(violations[1].line, 5);
assert!(
violations[0]
.message
.contains("Consider starting bulleted lists")
);
}
#[test]
fn test_md006_mixed_indentation() {
let content = r#"* Good item
* Bad item
* Good item
+ Another bad item
- Good item
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD006;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 2);
assert_eq!(violations[1].line, 4);
}
#[test]
fn test_md006_nested_lists_valid() {
let content = r#"* Item 1
* Nested item (this triggers the rule - it's indented)
* Another nested item
* Item 2
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD006;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2); assert_eq!(violations[0].line, 2);
assert_eq!(violations[1].line, 3);
}
#[test]
fn test_md006_code_blocks_ignored() {
let content = r#"# Heading
```
* This is in a code block
* Should not trigger the rule
```
* But this should trigger it
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD006;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 8);
}
#[test]
fn test_md006_blockquotes_ignored() {
let content = r#"# Heading
> * This is in a blockquote
> * Should not trigger the rule
* But this should trigger it
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD006;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 6);
}
#[test]
fn test_md006_different_markers() {
let content = r#" * Asterisk indented
+ Plus indented
- Dash indented
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD006;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert_eq!(violations[0].line, 1);
assert_eq!(violations[1].line, 2);
assert_eq!(violations[2].line, 3);
}
#[test]
fn test_md006_not_list_markers() {
let content = r#" * Not followed by space
*Not followed by space
- Not followed by space
-Not followed by space
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD006;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 1);
assert_eq!(violations[1].line, 3);
}
#[test]
fn test_md006_tab_indentation() {
let content = "\t* Tab indented item\n\t+ Another tab indented";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD006;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 1);
assert_eq!(violations[1].line, 2);
}
}