use mdbook_lint_core::error::Result;
use mdbook_lint_core::rule::{Rule, RuleCategory, RuleMetadata};
use mdbook_lint_core::{
Document,
violation::{Fix, Position, Severity, Violation},
};
#[derive(Debug, Clone, PartialEq)]
pub struct MD030Config {
pub ul_single: usize,
pub ol_single: usize,
pub ul_multi: usize,
pub ol_multi: usize,
}
impl Default for MD030Config {
fn default() -> Self {
Self {
ul_single: 1,
ol_single: 1,
ul_multi: 1,
ol_multi: 1,
}
}
}
pub struct MD030 {
config: MD030Config,
}
impl MD030 {
pub fn new() -> Self {
Self {
config: MD030Config::default(),
}
}
#[allow(dead_code)]
pub fn with_config(config: MD030Config) -> Self {
Self { config }
}
pub fn from_config(config: &toml::Value) -> Self {
let mut rule = Self::new();
if let Some(ul_single) = config.get("ul_single").and_then(|v| v.as_integer()) {
rule.config.ul_single = ul_single as usize;
} else if let Some(ul_single) = config.get("ul-single").and_then(|v| v.as_integer()) {
rule.config.ul_single = ul_single as usize;
}
if let Some(ol_single) = config.get("ol_single").and_then(|v| v.as_integer()) {
rule.config.ol_single = ol_single as usize;
} else if let Some(ol_single) = config.get("ol-single").and_then(|v| v.as_integer()) {
rule.config.ol_single = ol_single as usize;
}
if let Some(ul_multi) = config.get("ul_multi").and_then(|v| v.as_integer()) {
rule.config.ul_multi = ul_multi as usize;
} else if let Some(ul_multi) = config.get("ul-multi").and_then(|v| v.as_integer()) {
rule.config.ul_multi = ul_multi as usize;
}
if let Some(ol_multi) = config.get("ol_multi").and_then(|v| v.as_integer()) {
rule.config.ol_multi = ol_multi as usize;
} else if let Some(ol_multi) = config.get("ol-multi").and_then(|v| v.as_integer()) {
rule.config.ol_multi = ol_multi as usize;
}
rule
}
}
impl Default for MD030 {
fn default() -> Self {
Self::new()
}
}
impl Rule for MD030 {
fn id(&self) -> &'static str {
"MD030"
}
fn name(&self) -> &'static str {
"list-marker-space"
}
fn description(&self) -> &'static str {
"Spaces after list markers"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
}
fn can_fix(&self) -> bool {
true
}
fn check_with_ast<'a>(
&self,
document: &Document,
_ast: Option<&'a comrak::nodes::AstNode<'a>>,
) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let mut in_code_block = false;
let mut in_display_math = false;
for (line_number, line) in document.lines.iter().enumerate() {
let line_num = line_number + 1; let trimmed = line.trim_start();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
continue;
}
if let Some(after_opening) = trimmed.strip_prefix("$$") {
if after_opening.trim_end().ends_with("$$") && after_opening.len() > 2 {
continue;
}
in_display_math = !in_display_math;
continue;
}
if in_display_math && trimmed.ends_with("$$") {
in_display_math = false;
continue;
}
if in_display_math {
continue;
}
if let Some(violation) = self.check_list_marker_spacing(line, line_num) {
violations.push(violation);
}
}
Ok(violations)
}
}
impl MD030 {
fn check_list_marker_spacing(&self, line: &str, line_num: usize) -> Option<Violation> {
let trimmed = line.trim_start();
let indent_count = line.len() - trimmed.len();
if self.is_setext_underline(trimmed) {
return None;
}
if let Some(marker_char) = self.get_unordered_marker(trimmed) {
let after_marker = &trimmed[1..];
let whitespace_count = after_marker
.chars()
.take_while(|&c| c.is_whitespace())
.count();
let expected_spaces = self.config.ul_single;
let is_valid_spacing = if expected_spaces == 1 {
whitespace_count == 1
&& (after_marker.starts_with(' ') || after_marker.starts_with('\t'))
} else {
whitespace_count == expected_spaces
};
if !is_valid_spacing {
let indent = &line[..indent_count];
let content_after_spaces = after_marker.trim_start();
let spaces = " ".repeat(expected_spaces);
let fixed_line = format!(
"{}{}{}{}\n",
indent, marker_char, spaces, content_after_spaces
);
let fix = Fix {
description: format!("Use {} space(s) after list marker", expected_spaces),
replacement: Some(fixed_line),
start: Position {
line: line_num,
column: 1,
},
end: Position {
line: line_num,
column: line.len() + 1,
},
};
return Some(self.create_violation_with_fix(
format!(
"Unordered list marker spacing: expected {expected_spaces} space(s) after '{marker_char}', found {whitespace_count}"
),
line_num,
indent_count + 2, Severity::Warning,
fix,
));
}
}
if let Some((number, dot_pos)) = self.get_ordered_marker(trimmed) {
let after_dot = &trimmed[dot_pos + 1..];
let whitespace_count = after_dot.chars().take_while(|&c| c.is_whitespace()).count();
let expected_spaces = self.config.ol_single;
let is_valid_spacing = if expected_spaces == 1 {
whitespace_count == 1 && (after_dot.starts_with(' ') || after_dot.starts_with('\t'))
} else {
whitespace_count == expected_spaces
};
if !is_valid_spacing {
let indent = &line[..indent_count];
let content_after_spaces = after_dot.trim_start();
let spaces = " ".repeat(expected_spaces);
let fixed_line =
format!("{}{}.{}{}\n", indent, number, spaces, content_after_spaces);
let fix = Fix {
description: format!("Use {} space(s) after list marker", expected_spaces),
replacement: Some(fixed_line),
start: Position {
line: line_num,
column: 1,
},
end: Position {
line: line_num,
column: line.len() + 1,
},
};
return Some(self.create_violation_with_fix(
format!(
"Ordered list marker spacing: expected {expected_spaces} space(s) after '{number}. ', found {whitespace_count}"
),
line_num,
indent_count + dot_pos + 2, Severity::Warning,
fix,
));
}
}
None
}
fn get_unordered_marker(&self, trimmed: &str) -> Option<char> {
let first_char = trimmed.chars().next()?;
match first_char {
'-' | '*' | '+' => {
if self.is_emphasis_syntax(trimmed, first_char) {
return None;
}
if first_char == '*' && trimmed.len() > 1 {
let second_char = trimmed.chars().nth(1)?;
if second_char == '[' {
return None;
}
}
if first_char == '-' && trimmed.starts_with("-->") {
return None;
}
if first_char == '-' && trimmed.len() > 1 {
let second_char = trimmed.chars().nth(1)?;
if second_char == '-' || second_char == '>' {
return None;
}
}
if first_char == '+' && trimmed.len() > 1 {
let second_char = trimmed.chars().nth(1)?;
if second_char.is_ascii_lowercase() {
return None;
}
}
Some(first_char)
}
_ => None,
}
}
fn is_emphasis_syntax(&self, trimmed: &str, marker: char) -> bool {
if marker == '*' && trimmed.starts_with("**") {
return true;
}
if marker == '_' && trimmed.starts_with("__") {
return true;
}
if marker == '*' {
let chars: Vec<char> = trimmed.chars().collect();
if chars.len() > 1 && !chars[1].is_whitespace() && chars[1] != '*' {
let remaining: String = chars.iter().skip(2).collect();
if let Some(closing_pos) = remaining.find('*') {
if closing_pos < 50 && !remaining[..closing_pos].contains('\n') {
return true;
}
}
}
}
false
}
fn get_ordered_marker(&self, trimmed: &str) -> Option<(String, usize)> {
let 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.is_empty() {
return None;
}
let next_char = after_dot.chars().next()?;
if next_char.is_ascii_digit() {
return None;
}
Some((prefix.to_string(), dot_pos))
} else {
None
}
}
fn is_setext_underline(&self, trimmed: &str) -> bool {
if trimmed.is_empty() {
return false;
}
let first_char = trimmed.chars().next().unwrap();
(first_char == '=' || first_char == '-') && trimmed.chars().all(|c| c == first_char)
}
}
#[cfg(test)]
mod tests {
use super::*;
use mdbook_lint_core::Document;
use mdbook_lint_core::rule::Rule;
use std::path::PathBuf;
#[test]
fn test_md030_no_violations() {
let content = r#"# Valid List Spacing
Unordered lists with single space:
- Item 1
* Item 2
+ Item 3
Ordered lists with single space:
1. First item
2. Second item
42. Item with large number
Regular text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md030_unordered_multiple_spaces() {
let content = r#"# Unordered List Spacing Issues
- Single space is fine
- Two spaces after dash
* Three spaces after asterisk
+ Four spaces after plus
Regular text.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert!(
violations[0]
.message
.contains("expected 1 space(s) after '-', found 2")
);
assert!(
violations[1]
.message
.contains("expected 1 space(s) after '*', found 3")
);
assert!(
violations[2]
.message
.contains("expected 1 space(s) after '+', found 4")
);
assert_eq!(violations[0].line, 4);
assert_eq!(violations[1].line, 5);
assert_eq!(violations[2].line, 6);
}
#[test]
fn test_md030_ordered_multiple_spaces() {
let content = r#"# Ordered List Spacing Issues
1. Single space is fine
2. Two spaces after number
42. Three spaces after large number
100. Four spaces after even larger number
Regular text.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert!(
violations[0]
.message
.contains("expected 1 space(s) after '2. ', found 2")
);
assert!(
violations[1]
.message
.contains("expected 1 space(s) after '42. ', found 3")
);
assert!(
violations[2]
.message
.contains("expected 1 space(s) after '100. ', found 4")
);
assert_eq!(violations[0].line, 4);
assert_eq!(violations[1].line, 5);
assert_eq!(violations[2].line, 6);
}
#[test]
fn test_md030_no_spaces_after_marker() {
let content = r#"# No Spaces After Markers
-No space after dash
*No space after asterisk
+No space after plus
1.No space after number
42.No space after large number
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 5);
for violation in &violations {
assert!(violation.message.contains("expected 1 space(s)"));
assert!(violation.message.contains("found 0"));
}
}
#[test]
fn test_md030_custom_config() {
let content = r#"# Custom Configuration Test
- Single space (should be invalid)
- Two spaces (should be valid)
1. Single space (should be invalid)
2. Two spaces (should be valid)
Text here.
"#;
let config = MD030Config {
ul_single: 2,
ol_single: 2,
ul_multi: 2,
ol_multi: 2,
};
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::with_config(config);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(
violations[0]
.message
.contains("expected 2 space(s) after '-', found 1")
);
assert!(
violations[1]
.message
.contains("expected 2 space(s) after '1. ', found 1")
);
assert_eq!(violations[0].line, 3);
assert_eq!(violations[1].line, 5);
}
#[test]
fn test_md030_indented_lists() {
let content = r#"# Moderately Indented Lists
- Moderately indented item
- Too many spaces
* Another marker type
* Too many spaces here too
Regular text here.
1. Regular ordered list
2. Too many spaces
42. Correct spacing
100. Too many spaces
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 4);
assert_eq!(violations[0].line, 4); assert_eq!(violations[1].line, 6); assert_eq!(violations[2].line, 11); assert_eq!(violations[3].line, 13); }
#[test]
fn test_md030_nested_lists() {
let content = r#"# Nested Lists
- Top level item
- Nested item with correct spacing
- Nested item with too many spaces
* Different marker type
* Too many spaces with asterisk
1. Nested ordered list
2. Too many spaces in nested ordered
3. Correct spacing
More text.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert_eq!(violations[0].line, 5); assert_eq!(violations[1].line, 7); assert_eq!(violations[2].line, 9); }
#[test]
fn test_md030_mixed_violations() {
let content = r#"# Mixed Violations
- Correct spacing
- Too many spaces
* Correct spacing
*No spaces
+ Correct spacing
+ Way too many spaces
1. Correct spacing
2. Too many spaces
3. Correct spacing
42.No spaces
100. Many spaces
Text here.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 6);
assert_eq!(violations[0].line, 4); assert_eq!(violations[1].line, 6); assert_eq!(violations[2].line, 8); assert_eq!(violations[3].line, 11); assert_eq!(violations[4].line, 13); assert_eq!(violations[5].line, 14); }
#[test]
fn test_md030_tabs_after_markers() {
let content = "- Item with tab\t\n*\tItem starting with tab\n1.\tOrdered with tab\n42.\t\tMultiple tabs\n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1); assert_eq!(violations[0].line, 4); }
#[test]
fn test_md030_get_markers() {
let rule = MD030::new();
assert_eq!(rule.get_unordered_marker("- Item"), Some('-'));
assert_eq!(rule.get_unordered_marker("* Item"), Some('*'));
assert_eq!(rule.get_unordered_marker("+ Item"), Some('+'));
assert_eq!(rule.get_unordered_marker("Not a marker"), None);
assert_eq!(rule.get_unordered_marker("1. Ordered"), None);
assert_eq!(
rule.get_ordered_marker("1. Item"),
Some(("1".to_string(), 1))
);
assert_eq!(
rule.get_ordered_marker("42. Item"),
Some(("42".to_string(), 2))
);
assert_eq!(
rule.get_ordered_marker("100. Item"),
Some(("100".to_string(), 3))
);
assert_eq!(rule.get_ordered_marker("- Unordered"), None);
assert_eq!(rule.get_ordered_marker("Not a list"), None);
assert_eq!(rule.get_ordered_marker("a. Letter"), None);
}
#[test]
fn test_md030_setext_headings_ignored() {
let content = r#"Main Heading
============
Some content here.
Subheading
----------
More content.
- This is a real list
- With proper spacing
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md030_is_setext_underline() {
let rule = MD030::new();
assert!(rule.is_setext_underline("============"));
assert!(rule.is_setext_underline("----------"));
assert!(rule.is_setext_underline("==="));
assert!(rule.is_setext_underline("---"));
assert!(rule.is_setext_underline("="));
assert!(rule.is_setext_underline("-"));
assert!(!rule.is_setext_underline(""));
assert!(!rule.is_setext_underline("- Item"));
assert!(!rule.is_setext_underline("=-="));
assert!(!rule.is_setext_underline("=== Header ==="));
assert!(!rule.is_setext_underline("-- Comment --"));
assert!(!rule.is_setext_underline("* Not a setext"));
assert!(!rule.is_setext_underline("+ Also not"));
}
#[test]
fn test_md030_bold_text_not_flagged() {
let content = r#"# Bold Text Should Not Be Flagged
**Types**: feat, fix, docs
**Scopes**: cli, preprocessor, rules
**Important**: This is bold text, not a list marker
Regular bold text like **this** should be fine.
Italic text like *this* should also be fine.
But actual lists should still be checked:
- Valid list item
- Invalid spacing (should be flagged)
* Another valid item
* Invalid spacing (should be flagged)
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(
violations[0]
.message
.contains("expected 1 space(s) after '-', found 2")
);
assert!(
violations[1]
.message
.contains("expected 1 space(s) after '*', found 2")
);
assert_eq!(violations[0].line, 12); assert_eq!(violations[1].line, 14); }
#[test]
fn test_md030_emphasis_syntax_detection() {
let rule = MD030::new();
assert!(rule.is_emphasis_syntax("**bold text**", '*'));
assert!(rule.is_emphasis_syntax("**Types**: something", '*'));
assert!(rule.is_emphasis_syntax("__bold text__", '_'));
assert!(rule.is_emphasis_syntax("*italic text*", '*'));
assert!(rule.is_emphasis_syntax("*word*", '*'));
assert!(!rule.is_emphasis_syntax("* List item", '*'));
assert!(!rule.is_emphasis_syntax("- List item", '-'));
assert!(!rule.is_emphasis_syntax("+ List item", '+'));
assert!(!rule.is_emphasis_syntax("* List with extra spaces", '*'));
assert!(!rule.is_emphasis_syntax("* ", '*')); assert!(!rule.is_emphasis_syntax("*", '*')); assert!(!rule.is_emphasis_syntax("*text with no closing", '*')); }
#[test]
fn test_md030_mixed_emphasis_and_lists() {
let content = r#"# Mixed Content
**Bold**: This should not be flagged
*Italic*: This should not be flagged
Valid lists:
- Item one
* Item two
+ Item three
Invalid lists:
- Too many spaces after dash
* Too many spaces after asterisk
+ Too many spaces after plus
More **bold text** that should be ignored.
And some *italic text* that should be ignored.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
for violation in &violations {
assert!(violation.message.contains("expected 1 space(s)"));
assert!(violation.message.contains("found 2"));
}
assert_eq!(violations[0].line, 12); assert_eq!(violations[1].line, 13); assert_eq!(violations[2].line, 14); }
#[test]
fn test_md030_get_unordered_marker_with_emphasis() {
let rule = MD030::new();
assert_eq!(rule.get_unordered_marker("- List item"), Some('-'));
assert_eq!(rule.get_unordered_marker("* List item"), Some('*'));
assert_eq!(rule.get_unordered_marker("+ List item"), Some('+'));
assert_eq!(rule.get_unordered_marker("**Bold text**"), None);
assert_eq!(rule.get_unordered_marker("*Italic text*"), None);
assert_eq!(rule.get_unordered_marker("**Types**: something"), None);
assert_eq!(rule.get_unordered_marker("Not a list"), None);
assert_eq!(rule.get_unordered_marker("1. Ordered list"), None);
}
#[test]
fn test_md030_code_blocks_ignored() {
let content = r#"# Test Code Blocks
Valid list:
- Item one
```bash
# Deploy with CLI flags - these should not trigger MD030
rot deploy --admin-password secret123 \
--database-name myapp \
--port 6379
# List items that look like markdown but are inside code
- Not a real list item, just text
* Also not a real list item
1. Not an ordered list either
```
Another list:
- Item two
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md030_fix_unordered_no_space() {
let content = "*No space after marker";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement.as_ref().unwrap(),
"* No space after marker\n"
);
assert_eq!(fix.description, "Use 1 space(s) after list marker");
}
#[test]
fn test_md030_fix_unordered_too_many_spaces() {
let content = "- Too many spaces";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement.as_ref().unwrap(), "- Too many spaces\n");
}
#[test]
fn test_md030_fix_ordered_no_space() {
let content = "1.No space after period";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement.as_ref().unwrap(),
"1. No space after period\n"
);
}
#[test]
fn test_md030_fix_ordered_too_many_spaces() {
let content = "42. Way too many spaces";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement.as_ref().unwrap(),
"42. Way too many spaces\n"
);
}
#[test]
fn test_md030_fix_preserves_indentation() {
let content = " *No space after marker";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement.as_ref().unwrap(),
" * No space after marker\n"
);
}
#[test]
fn test_md030_fix_all_markers() {
let content = "-No space\n* Too many\n+ Way too many";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert_eq!(
violations[0]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"- No space\n"
);
assert_eq!(
violations[1]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"* Too many\n"
);
assert_eq!(
violations[2]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"+ Way too many\n"
);
}
#[test]
fn test_md030_fix_custom_config() {
let config = MD030Config {
ul_single: 2,
ol_single: 2,
ul_multi: 2,
ol_multi: 2,
};
let rule = MD030::with_config(config);
let content = "- One space\n1. One space";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(
violations[0]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"- One space\n"
);
assert_eq!(
violations[1]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"1. One space\n"
);
}
#[test]
fn test_md030_fix_position_accuracy() {
let content = "Text before\n* Too many spaces\nText after";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.start.line, 2);
assert_eq!(fix.start.column, 1);
assert_eq!(fix.end.line, 2);
assert_eq!(violations[0].line, 2);
}
#[test]
fn test_md030_html_comment_endings_not_flagged() {
let content = r#"<!-- ignore
--> and this continues the sentence
Some text here.
<!-- another comment
-->
More text.
--> This also looks like HTML comment ending
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md030_command_line_flags_not_flagged() {
let content = r#"Some text here.
--release` to compile it with optimizations.
Use `cargo build --release` for production.
--book` to open the documentation.
--verbose flag enables more output.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md030_rust_arrow_syntax_not_flagged() {
let content = r#"The function signature looks like:
-> &'a i32`.
Returns a reference with lifetime annotation.
-> Self for builder pattern.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md030_rustup_toolchain_syntax_not_flagged() {
let content = r#"Install with rustup:
+nightly component add miri`. This installs the component.
Use `rustup +stable update` to update.
+beta to use the beta channel.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md030_version_numbers_not_flagged() {
let content = r#"Some text here.
0.8.5, and this specification ensures compatibility.
Version 1.2.3 is the latest release.
38. This is a valid ordered list item
0.999.x series would look like this.
100.200.300 is a weird version but not a list.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md030_real_lists_still_flagged() {
let content = r#"Valid list items:
- Item one
* Item two
+ Item three
1. First
2. Second
Invalid spacing:
- Two spaces
* Three spaces
1. Two spaces after number
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert_eq!(violations[0].line, 9); assert_eq!(violations[1].line, 10); assert_eq!(violations[2].line, 11); }
#[test]
fn test_md030_display_math_blocks_ignored() {
let content = r#"# Math Blocks
$$f(x) = \begin{cases}
x^2 & \text{if } x \geq 0 \\
-x^2 & \text{if } x < 0
\end{cases}$$
$$|x| = \begin{cases}
x & x \geq 0 \\
-x & x < 0
\end{cases}$$
Regular list after math:
- Item one
- Item two
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md030_single_line_display_math_ignored() {
let content = r#"# Single Line Math
$$-x + y = z$$
$$a - b = c$$
- Valid list item
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md030_multiline_math_with_negatives() {
let content = r#"# Complex Math
$$
\begin{aligned}
f(x) &= x^2 \\
-g(x) &= -x^2
\end{aligned}
$$
- Valid list after math
- Invalid spacing (should be flagged)
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 11); }
#[test]
fn test_md030_multiple_math_blocks() {
let content = r#"# Multiple Math Blocks
First block:
$$
-a + b
$$
Second block:
$$
-c + d
$$
- Valid list
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md030_abbreviation_definitions_not_flagged() {
let content = r#"# Abbreviations
The HTML specification is maintained by W3C.
*[HTML]: Hypertext Markup Language
*[W3C]: World Wide Web Consortium
*[CSS]: Cascading Style Sheets
- This is a real list item
* Another list item
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD030::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
}