use crate::lint::rule::Rule;
use crate::markdown::MarkdownParser;
use crate::types::Violation;
use serde_json::Value;
pub struct MD032;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ListMarker {
Asterisk,
Plus,
Dash,
Ordered,
}
impl Rule for MD032 {
fn name(&self) -> &str {
"MD032"
}
fn description(&self) -> &str {
"Lists should be surrounded by blank lines"
}
fn tags(&self) -> &[&str] {
&["bullet", "ul", "ol", "blank_lines"]
}
fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
let mut violations = Vec::new();
let lines = parser.lines();
let mut in_list = false;
let mut current_marker: Option<ListMarker> = None;
let mut last_list_line: usize = 0;
for (line_num, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
let list_marker = get_list_marker(trimmed);
let is_indented = !line.is_empty() && line.chars().next().unwrap().is_whitespace();
if let Some(marker) = list_marker {
if !in_list {
in_list = true;
current_marker = Some(marker);
last_list_line = line_num;
if line_num > 0 {
let prev_line = &lines[line_num - 1];
if !prev_line.trim().is_empty() {
violations.push(Violation {
line: line_num + 1,
column: Some(1),
rule: self.name().to_string(),
message: "List should be surrounded by blank lines".to_string(),
fix: None,
});
}
}
} else if Some(marker) != current_marker {
violations.push(Violation {
line: last_list_line + 1,
column: Some(1),
rule: self.name().to_string(),
message: "List should be surrounded by blank lines".to_string(),
fix: None,
});
violations.push(Violation {
line: line_num + 1,
column: Some(1),
rule: self.name().to_string(),
message: "List should be surrounded by blank lines".to_string(),
fix: None,
});
current_marker = Some(marker);
last_list_line = line_num;
} else {
last_list_line = line_num;
}
} else if in_list && is_indented && !line.trim().is_empty() {
} else if in_list && !line.trim().is_empty() {
in_list = false;
current_marker = None;
violations.push(Violation {
line: line_num + 1, column: Some(1),
rule: self.name().to_string(),
message: "List should be surrounded by blank lines".to_string(),
fix: None,
});
} else if in_list && line.trim().is_empty() {
let mut continues = false;
for future_line in lines.iter().skip(line_num + 1) {
if let Some(future_marker) = get_list_marker(future_line.trim_start()) {
if Some(future_marker) == current_marker {
continues = true;
}
break;
} else if !future_line.trim().is_empty() {
break;
}
}
if !continues {
in_list = false;
current_marker = None;
}
}
}
violations
}
fn fixable(&self) -> bool {
false
}
}
fn get_list_marker(trimmed: &str) -> Option<ListMarker> {
if trimmed.starts_with("* ") {
return Some(ListMarker::Asterisk);
}
if trimmed.starts_with("+ ") {
return Some(ListMarker::Plus);
}
if trimmed.starts_with("- ") {
return Some(ListMarker::Dash);
}
if let Some(dot_pos) = trimmed.find(". ") {
let prefix = &trimmed[..dot_pos];
if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
return Some(ListMarker::Ordered);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_properly_surrounded() {
let content = "Text before\n\n* Item 1\n* Item 2\n\nText after";
let parser = MarkdownParser::new(content);
let rule = MD032;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
#[test]
fn test_missing_blank_before() {
let content = "Text before\n* Item 1\n* Item 2\n\nText after";
let parser = MarkdownParser::new(content);
let rule = MD032;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 2); }
#[test]
fn test_missing_blank_after() {
let content = "Text before\n\n* Item 1\n* Item 2\nText after";
let parser = MarkdownParser::new(content);
let rule = MD032;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 5); }
#[test]
fn test_first_line() {
let content = "* Item 1\n* Item 2\n\nText after";
let parser = MarkdownParser::new(content);
let rule = MD032;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0); }
#[test]
fn test_wrapped_list_item() {
let content = "Text before\n\n* This is a long list item\n that wraps to the next line\n* Item 2\n\nText after";
let parser = MarkdownParser::new(content);
let rule = MD032;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
#[test]
fn test_multiple_wrapped_lines() {
let content = "Text\n\n* Item with multiple\n lines of text\n spanning across\n multiple lines\n* Item 2\n\nText after";
let parser = MarkdownParser::new(content);
let rule = MD032;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
#[test]
fn test_wrapped_with_nested_list() {
let content =
"Text\n\n* Item 1 that\n wraps across lines\n * Nested item\n* Item 2\n\nText after";
let parser = MarkdownParser::new(content);
let rule = MD032;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
#[test]
fn test_mixed_markers_are_separate_lists() {
let content = "Text\n\n* Item asterisk\n+ Item plus\n- Item dash\n\nText after";
let parser = MarkdownParser::new(content);
let rule = MD032;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 4);
}
}