use crate::lint::rule::Rule;
use crate::markdown::MarkdownParser;
use crate::types::{Fix, Violation};
use pulldown_cmark::{Event, Tag};
use serde_json::Value;
use std::collections::HashSet;
pub struct MD030;
impl Rule for MD030 {
fn name(&self) -> &str {
"MD030"
}
fn description(&self) -> &str {
"Spaces after list markers"
}
fn tags(&self) -> &[&str] {
&["ol", "ul", "whitespace"]
}
fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
let ul_single = config
.and_then(|c| c.get("ul_single"))
.and_then(|v| v.as_u64())
.unwrap_or(1) as usize;
let _ul_multi = config
.and_then(|c| c.get("ul_multi"))
.and_then(|v| v.as_u64())
.unwrap_or(1) as usize;
let ol_single = config
.and_then(|c| c.get("ol_single"))
.and_then(|v| v.as_u64())
.unwrap_or(1) as usize;
let _ol_multi = config
.and_then(|c| c.get("ol_multi"))
.and_then(|v| v.as_u64())
.unwrap_or(1) as usize;
let mut violations = Vec::new();
let code_lines = parser.get_code_block_line_numbers();
let mut emphasis_start_lines = HashSet::new();
let mut line_offsets = vec![0];
let mut current_offset = 0;
for line in parser.lines() {
current_offset += line.len() + 1; line_offsets.push(current_offset);
}
for (event, range) in parser.parse_with_offsets() {
if let Event::Start(Tag::Emphasis | Tag::Strong) = event {
let line_num = parser.offset_to_line(range.start);
if let Some(line) = parser.lines().get(line_num - 1) {
let trimmed_start = line.len() - line.trim_start().len();
if let Some(&line_start_offset) = line_offsets.get(line_num - 1)
&& range.start == line_start_offset + trimmed_start
{
emphasis_start_lines.insert(line_num);
}
}
}
}
for (line_num, line) in parser.lines().iter().enumerate() {
let line_number = line_num + 1;
if code_lines.contains(&line_number) {
continue;
}
if emphasis_start_lines.contains(&line_number) {
continue;
}
let trimmed = line.trim_start();
if is_horizontal_rule(trimmed) {
continue;
}
if is_table_separator(trimmed) {
continue;
}
if trimmed.starts_with('*') || trimmed.starts_with('+') || trimmed.starts_with('-') {
let marker_char = trimmed.chars().next().unwrap();
let after_marker = &trimmed[1..];
let space_count = after_marker.chars().take_while(|&c| c == ' ').count();
if !after_marker.trim().is_empty() {
let expected = ul_single;
if space_count != expected {
let leading_spaces = &line[..line.len() - trimmed.len()];
let content = after_marker[space_count..].trim_start();
let spaces = " ".repeat(expected);
let replacement =
format!("{}{}{}{}", leading_spaces, marker_char, spaces, content);
violations.push(Violation {
line: line_number,
column: Some(line.len() - trimmed.len() + 2),
rule: self.name().to_string(),
message: format!(
"Expected {} space(s) after list marker, found {}",
expected, space_count
),
fix: Some(Fix {
line_start: line_number,
line_end: line_number,
column_start: None,
column_end: None,
replacement,
description: format!("Adjust spacing to {} space(s)", expected),
}),
});
}
}
}
if let Some(dot_pos) = trimmed.find('.') {
let prefix = &trimmed[..dot_pos];
if prefix.chars().all(|c| c.is_ascii_digit()) && !prefix.is_empty() {
let after_dot = &trimmed[dot_pos + 1..];
if !after_dot.trim().is_empty() {
let space_count = after_dot.chars().take_while(|&c| c == ' ').count();
let expected = ol_single;
if space_count != expected {
let leading_spaces = &line[..line.len() - trimmed.len()];
let marker = &trimmed[..=dot_pos];
let content = after_dot[space_count..].trim_start();
let spaces = " ".repeat(expected);
let replacement =
format!("{}{}{}{}", leading_spaces, marker, spaces, content);
violations.push(Violation {
line: line_number,
column: Some(line.len() - trimmed.len() + dot_pos + 2),
rule: self.name().to_string(),
message: format!(
"Expected {} space(s) after list marker, found {}",
expected, space_count
),
fix: Some(Fix {
line_start: line_number,
line_end: line_number,
column_start: None,
column_end: None,
replacement,
description: format!("Adjust spacing to {} space(s)", expected),
}),
});
}
}
}
}
}
violations
}
fn fixable(&self) -> bool {
true
}
}
fn is_horizontal_rule(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.len() < 3 {
return false;
}
let chars: Vec<char> = trimmed.chars().filter(|&c| c != ' ').collect();
if chars.len() < 3 {
return false;
}
let first_char = chars[0];
if first_char != '-' && first_char != '*' && first_char != '_' {
return false;
}
chars.iter().all(|&c| c == first_char)
}
fn is_table_separator(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return false;
}
let has_pipe = trimmed.contains('|');
let dash_count = trimmed.chars().filter(|&c| c == '-').count();
if !has_pipe || dash_count < 3 {
return false;
}
trimmed.chars().all(|c| c == '-' || c == '|' || c == ' ')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_correct_spacing() {
let content = "* Item 1\n+ Item 2\n- Item 3\n1. Ordered";
let parser = MarkdownParser::new(content);
let rule = MD030;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
#[test]
fn test_no_space() {
let content = "*Item without space";
let parser = MarkdownParser::new(content);
let rule = MD030;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("found 0"));
}
#[test]
fn test_multiple_spaces() {
let content = "* Item with 2 spaces";
let parser = MarkdownParser::new(content);
let rule = MD030;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("found 2"));
}
#[test]
fn test_custom_spacing() {
let content = "* Item with 2 spaces";
let parser = MarkdownParser::new(content);
let rule = MD030;
let config = serde_json::json!({ "ul_single": 2 });
let violations = rule.check(&parser, Some(&config));
assert_eq!(violations.len(), 0); }
#[test]
fn test_bold_not_list_marker() {
let content = "**Slice-specific schemas** → some text\n\
**Bold text** at start\n\
*Italic text* here\n\
__Also bold__ text";
let parser = MarkdownParser::new(content);
let rule = MD030;
let violations = rule.check(&parser, None);
assert_eq!(
violations.len(),
0,
"Bold/emphasis should not trigger MD030"
);
}
#[test]
fn test_actual_list_with_bold() {
let content = "* **Bold** item\n\
+ *Italic* item\n\
- Normal item";
let parser = MarkdownParser::new(content);
let rule = MD030;
let violations = rule.check(&parser, None);
assert_eq!(violations.len(), 0);
}
#[test]
fn test_horizontal_rules_not_list_markers() {
let content = "# Heading\n\
\n\
---\n\
\n\
More content\n\
\n\
***\n\
\n\
___\n\
\n\
* * *\n\
\n\
- - -";
let parser = MarkdownParser::new(content);
let rule = MD030;
let violations = rule.check(&parser, None);
assert_eq!(
violations.len(),
0,
"Horizontal rules should not be treated as list markers"
);
}
#[test]
fn test_code_blocks_not_checked() {
let content = "# Heading\n\
\n\
```\n\
--config <CONFIG>\n\
--fix\n\
-h, --help\n\
```\n\
\n\
Normal text with `-h` inline code.";
let parser = MarkdownParser::new(content);
let rule = MD030;
let violations = rule.check(&parser, None);
assert_eq!(
violations.len(),
0,
"Code blocks and inline code should not be checked for list markers"
);
}
#[test]
fn test_real_list_after_code_block() {
let content = "```\n\
--config\n\
```\n\
\n\
*Item without space";
let parser = MarkdownParser::new(content);
let rule = MD030;
let violations = rule.check(&parser, None);
assert_eq!(
violations.len(),
1,
"Real list markers outside code blocks should be checked"
);
assert_eq!(violations[0].line, 5);
}
#[test]
fn test_table_separator_not_list() {
let content = "Rule | Description\n\
------|------------\n\
MD001 | First rule\n\
MD002 | Second rule";
let parser = MarkdownParser::new(content);
let rule = MD030;
let violations = rule.check(&parser, None);
assert_eq!(
violations.len(),
0,
"Table separator lines should not be treated as list markers"
);
}
}