use crate::lint_context::LintContext;
use crate::lint_context::types::HeadingStyle;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rule_config_serde::RuleConfig;
use crate::utils::range_utils::calculate_trailing_range;
use crate::utils::regex_cache::{ORDERED_LIST_MARKER_REGEX, UNORDERED_LIST_MARKER_REGEX};
mod md009_config;
use md009_config::MD009Config;
fn is_setext_underline(ctx: &LintContext, line_num: usize) -> bool {
if line_num == 0 {
return false;
}
ctx.line_info(line_num).is_some_and(|prev| {
prev.heading
.as_ref()
.is_some_and(|h| matches!(h.style, HeadingStyle::Setext1 | HeadingStyle::Setext2))
})
}
fn br_produces_useful_break(ctx: &LintContext, line_num: usize) -> bool {
let lines = ctx.raw_lines();
let Some(current) = ctx.line_info(line_num + 1) else {
return false;
};
if !current.is_paragraph_context() || is_setext_underline(ctx, line_num) {
return false;
}
let next_idx = line_num + 1;
if next_idx >= lines.len() {
return false;
}
let Some(next) = ctx.line_info(next_idx + 1) else {
return false;
};
if next.is_blank || !next.is_paragraph_context() || next.list_item.is_some() || is_setext_underline(ctx, next_idx) {
return false;
}
true
}
#[derive(Debug, Clone, Default)]
pub struct MD009TrailingSpaces {
config: MD009Config,
}
impl MD009TrailingSpaces {
pub fn new(br_spaces: usize, strict: bool) -> Self {
Self {
config: MD009Config {
br_spaces: crate::types::BrSpaces::from_const(br_spaces),
strict,
list_item_empty_lines: false,
},
}
}
pub const fn from_config_struct(config: MD009Config) -> Self {
Self { config }
}
fn count_trailing_spaces(line: &str) -> usize {
line.chars().rev().take_while(|&c| c == ' ').count()
}
fn count_trailing_spaces_ascii(line: &str) -> usize {
line.as_bytes().iter().rev().take_while(|&&b| b == b' ').count()
}
fn count_trailing_whitespace(line: &str) -> usize {
line.chars().rev().take_while(|c| c.is_whitespace()).count()
}
fn trimmed_len_ascii_whitespace(line: &str) -> usize {
line.as_bytes()
.iter()
.rposition(|b| !b.is_ascii_whitespace())
.map_or(0, |idx| idx + 1)
}
fn calculate_trailing_range_ascii(
line: usize,
line_len: usize,
content_end: usize,
) -> (usize, usize, usize, usize) {
(line, content_end + 1, line, line_len + 1)
}
fn is_empty_list_item_line(line: &str, prev_line: Option<&str>) -> bool {
if !line.trim().is_empty() {
return false;
}
if let Some(prev) = prev_line {
UNORDERED_LIST_MARKER_REGEX.is_match(prev) || ORDERED_LIST_MARKER_REGEX.is_match(prev)
} else {
false
}
}
}
impl Rule for MD009TrailingSpaces {
fn name(&self) -> &'static str {
"MD009"
}
fn description(&self) -> &'static str {
"Trailing spaces should be removed"
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let content = ctx.content;
let line_index = &ctx.line_index;
let mut warnings = Vec::new();
let lines = ctx.raw_lines();
for (line_num, &line) in lines.iter().enumerate() {
if ctx.line_info(line_num + 1).is_some_and(|info| info.in_pymdown_block) {
continue;
}
let line_is_ascii = line.is_ascii();
let trailing_ascii_spaces = if line_is_ascii {
Self::count_trailing_spaces_ascii(line)
} else {
Self::count_trailing_spaces(line)
};
let trailing_all_whitespace = if line_is_ascii {
trailing_ascii_spaces
} else {
Self::count_trailing_whitespace(line)
};
if trailing_all_whitespace == 0 {
continue;
}
let trimmed_len = if line_is_ascii {
Self::trimmed_len_ascii_whitespace(line)
} else {
line.trim_end().len()
};
if trimmed_len == 0 {
if trailing_all_whitespace > 0 {
let prev_line = if line_num > 0 { Some(lines[line_num - 1]) } else { None };
if self.config.list_item_empty_lines && Self::is_empty_list_item_line(line, prev_line) {
continue;
}
let (start_line, start_col, end_line, end_col) = if line_is_ascii {
Self::calculate_trailing_range_ascii(line_num + 1, line.len(), 0)
} else {
calculate_trailing_range(line_num + 1, line, 0)
};
let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
let fix_range = if line_is_ascii {
line_start..line_start + line.len()
} else {
line_index.line_col_to_byte_range_with_length(line_num + 1, 1, line.chars().count())
};
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: "Empty line has trailing spaces".to_string(),
severity: Severity::Warning,
fix: Some(Fix::new(fix_range, String::new())),
});
}
continue;
}
if !self.config.strict {
if let Some(line_info) = ctx.line_info(line_num + 1)
&& line_info.in_code_block
{
continue;
}
}
let is_truly_last_line = line_num == lines.len() - 1 && !content.ends_with('\n');
let has_only_ascii_trailing = trailing_ascii_spaces == trailing_all_whitespace;
let matches_br_spaces = trailing_ascii_spaces == self.config.br_spaces.get();
if !is_truly_last_line && has_only_ascii_trailing && matches_br_spaces {
let allow = if self.config.strict {
br_produces_useful_break(ctx, line_num)
} else {
true
};
if allow {
continue;
}
}
let trimmed = if line_is_ascii {
&line[..trimmed_len]
} else {
line.trim_end()
};
let is_empty_blockquote_with_space = trimmed.chars().all(|c| c == '>' || c == ' ' || c == '\t')
&& trimmed.contains('>')
&& has_only_ascii_trailing
&& trailing_ascii_spaces == 1;
if is_empty_blockquote_with_space {
continue; }
let (start_line, start_col, end_line, end_col) = if line_is_ascii {
Self::calculate_trailing_range_ascii(line_num + 1, line.len(), trimmed.len())
} else {
calculate_trailing_range(line_num + 1, line, trimmed.len())
};
let line_start = *ctx.line_offsets.get(line_num).unwrap_or(&0);
let fix_range = if line_is_ascii {
let start = line_start + trimmed.len();
let end = start + trailing_all_whitespace;
start..end
} else {
line_index.line_col_to_byte_range_with_length(
line_num + 1,
trimmed.chars().count() + 1,
trailing_all_whitespace,
)
};
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: if trailing_all_whitespace == 1 {
"Trailing space found".to_string()
} else {
format!("{trailing_all_whitespace} trailing spaces found")
},
severity: Severity::Warning,
fix: Some(Fix::new(fix_range, String::new())),
});
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
if self.should_skip(ctx) {
return Ok(ctx.content.to_string());
}
let warnings = self.check(ctx)?;
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty()
}
fn category(&self) -> RuleCategory {
RuleCategory::Whitespace
}
fn default_config_section(&self) -> Option<(String, toml::Value)> {
let default_config = MD009Config::default();
let json_value = serde_json::to_value(&default_config).ok()?;
let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
if let toml::Value::Table(table) = toml_value {
if !table.is_empty() {
Some((MD009Config::RULE_NAME.to_string(), toml::Value::Table(table)))
} else {
None
}
} else {
None
}
}
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD009Config>(config);
Box::new(Self::from_config_struct(rule_config))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint_context::LintContext;
use crate::rule::Rule;
#[test]
fn test_no_trailing_spaces() {
let rule = MD009TrailingSpaces::default();
let content = "This is a line\nAnother line\nNo trailing spaces";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_basic_trailing_spaces() {
let rule = MD009TrailingSpaces::default();
let content = "Line with spaces \nAnother line \nClean line";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 1);
assert_eq!(result[0].message, "3 trailing spaces found");
}
#[test]
fn test_fix_basic_trailing_spaces() {
let rule = MD009TrailingSpaces::default();
let content = "Line with spaces \nAnother line \nClean line";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Line with spaces\nAnother line \nClean line");
}
#[test]
fn test_strict_mode() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "Line with spaces \nCode block: \n``` \nCode with spaces \n``` ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let lines_flagged: Vec<usize> = result.iter().map(|w| w.line).collect();
assert_eq!(lines_flagged, vec![2, 3, 4, 5], "got: {result:?}");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Line with spaces \nCode block:\n```\nCode with spaces\n```");
}
#[test]
fn test_strict_mode_allows_br_spaces_on_paragraph_lines() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "> Note: \n> This is in a new line due to 2 spaces behind \"Note:\".\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"strict mode should allow br_spaces on paragraph-context lines, got: {result:?}"
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_strict_mode_flags_br_spaces_on_heading() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "# Heading \nFollow-up paragraph.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "strict should flag heading br_spaces, got: {result:?}");
assert_eq!(result[0].line, 1);
}
#[test]
fn test_strict_mode_flags_br_spaces_on_last_paragraph_line() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "Paragraph \n\nNext paragraph.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.iter().map(|w| w.line).collect::<Vec<_>>(),
vec![1],
"strict should flag br_spaces on a single-line paragraph, got: {result:?}"
);
}
#[test]
fn test_strict_mode_flags_br_spaces_between_list_items() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "- item 1 \n- item 2\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.iter().map(|w| w.line).collect::<Vec<_>>(),
vec![1],
"strict should flag br_spaces at the end of a list item, got: {result:?}"
);
}
#[test]
fn test_strict_mode_allows_br_spaces_in_list_item_continuation() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "- first line \n second line of same item\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"strict should allow br_spaces between a list item and its continuation, got: {result:?}"
);
}
#[test]
fn test_strict_mode_flags_br_spaces_before_heading() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "Paragraph \n# Heading\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.iter().map(|w| w.line).collect::<Vec<_>>(),
vec![1],
"strict should flag br_spaces on the line before a heading, got: {result:?}"
);
}
#[test]
fn test_strict_mode_flags_br_spaces_on_setext_heading_text() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "Setext heading \n===\n\nFollow-up paragraph.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.iter().map(|w| w.line).collect::<Vec<_>>(),
vec![1],
"strict should flag setext heading text trailing spaces, got: {result:?}"
);
}
#[test]
fn test_strict_mode_flags_br_spaces_on_setext_underline() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "Setext heading\n=== \n\nFollow-up paragraph.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.iter().map(|w| w.line).collect::<Vec<_>>(),
vec![2],
"strict should flag setext underline trailing spaces, got: {result:?}"
);
}
#[test]
fn test_strict_mode_flags_br_spaces_in_indented_code_block() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "Paragraph above.\n\n code line \n another code \n\nParagraph below.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.iter().map(|w| w.line).collect::<Vec<_>>(),
vec![3, 4],
"strict should flag indented code block trailing spaces, got: {result:?}"
);
}
#[test]
fn test_strict_mode_allows_br_spaces_in_table_row() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "| col |\n| --- |\n| cell |\n\nParagraph.\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"rows that don't actually have trailing whitespace shouldn't trigger MD009, got: {result:?}"
);
}
#[test]
fn test_non_strict_mode_with_code_blocks() {
let rule = MD009TrailingSpaces::new(2, false);
let content = "Line with spaces \n```\nCode with spaces \n```\nOutside code ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 5);
}
#[test]
fn test_br_spaces_preservation() {
let rule = MD009TrailingSpaces::new(2, false);
let content = "Line with two spaces \nLine with three spaces \nLine with one space ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].line, 2);
assert_eq!(result[1].line, 3);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed,
"Line with two spaces \nLine with three spaces\nLine with one space"
);
}
#[test]
fn test_empty_lines_with_spaces() {
let rule = MD009TrailingSpaces::default();
let content = "Normal line\n \n \nAnother line";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].message, "Empty line has trailing spaces");
assert_eq!(result[1].message, "Empty line has trailing spaces");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Normal line\n\n\nAnother line");
}
#[test]
fn test_empty_blockquote_lines() {
let rule = MD009TrailingSpaces::default();
let content = "> Quote\n> \n> More quote";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 2);
assert_eq!(result[0].message, "3 trailing spaces found");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "> Quote\n>\n> More quote"); }
#[test]
fn test_last_line_handling() {
let rule = MD009TrailingSpaces::new(2, false);
let content = "First line \nLast line ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 2);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "First line \nLast line");
let content_with_newline = "First line \nLast line \n";
let ctx = LintContext::new(content_with_newline, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_single_trailing_space() {
let rule = MD009TrailingSpaces::new(2, false);
let content = "Line with one space ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].message, "Trailing space found");
}
#[test]
fn test_tabs_not_spaces() {
let rule = MD009TrailingSpaces::default();
let content = "Line with tab\t\nLine with spaces ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 2);
}
#[test]
fn test_mixed_content() {
let rule = MD009TrailingSpaces::new(2, false);
let mut content = String::new();
content.push_str("# Heading");
content.push_str(" "); content.push('\n');
content.push_str("Normal paragraph\n> Blockquote\n>\n```\nCode block\n```\n- List item\n");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 1);
assert!(result[0].message.contains("trailing spaces"));
}
#[test]
fn test_column_positions() {
let rule = MD009TrailingSpaces::default();
let content = "Text ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].column, 5); assert_eq!(result[0].end_column, 8); }
#[test]
fn test_default_config() {
let rule = MD009TrailingSpaces::default();
let config = rule.default_config_section();
assert!(config.is_some());
let (name, _value) = config.unwrap();
assert_eq!(name, "MD009");
}
#[test]
fn test_from_config() {
let mut config = crate::config::Config::default();
let mut rule_config = crate::config::RuleConfig::default();
rule_config
.values
.insert("br_spaces".to_string(), toml::Value::Integer(3));
rule_config
.values
.insert("strict".to_string(), toml::Value::Boolean(true));
config.rules.insert("MD009".to_string(), rule_config);
let rule = MD009TrailingSpaces::from_config(&config);
let content = "Line ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Line");
}
#[test]
fn test_list_item_empty_lines() {
let config = MD009Config {
list_item_empty_lines: true,
..Default::default()
};
let rule = MD009TrailingSpaces::from_config_struct(config);
let content = "- First item\n \n- Second item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
let content = "1. First item\n \n2. Second item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
let content = "Normal paragraph\n \nAnother paragraph";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 2);
}
#[test]
fn test_list_item_empty_lines_disabled() {
let rule = MD009TrailingSpaces::default();
let content = "- First item\n \n- Second item";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 2);
}
#[test]
fn test_performance_large_document() {
let rule = MD009TrailingSpaces::default();
let mut content = String::new();
for i in 0..1000 {
content.push_str(&format!("Line {i} with spaces \n"));
}
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_preserve_content_after_fix() {
let rule = MD009TrailingSpaces::new(2, false);
let content = "**Bold** text \n*Italic* text \n[Link](url) ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "**Bold** text \n*Italic* text \n[Link](url)");
}
#[test]
fn test_nested_blockquotes() {
let rule = MD009TrailingSpaces::default();
let content = "> > Nested \n> > \n> Normal ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].line, 2);
assert_eq!(result[1].line, 3);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "> > Nested \n> >\n> Normal");
}
#[test]
fn test_normalized_line_endings() {
let rule = MD009TrailingSpaces::default();
let content = "Line with spaces \nAnother line ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 2);
}
#[test]
fn test_issue_80_no_space_normalization() {
let rule = MD009TrailingSpaces::new(2, false);
let content = "Line with one space \nNext line";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 1);
assert_eq!(result[0].message, "Trailing space found");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Line with one space\nNext line");
let content = "Line with three spaces \nNext line";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].line, 1);
assert_eq!(result[0].message, "3 trailing spaces found");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Line with three spaces\nNext line");
let content = "Line with two spaces \nNext line";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Line with two spaces \nNext line");
}
#[test]
fn test_unicode_whitespace_idempotent_fix() {
let rule = MD009TrailingSpaces::default();
let content = "> 0\u{2000} ";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should detect trailing Unicode+ASCII whitespace");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "> 0", "Should strip all trailing whitespace in one pass");
let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
let fixed2 = rule.fix(&ctx2).unwrap();
assert_eq!(fixed, fixed2, "Fix must be idempotent");
}
#[test]
fn test_unicode_whitespace_variants() {
let rule = MD009TrailingSpaces::default();
let content = "text\u{2000}\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "text\n");
let content = "text\u{2001}\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "text\n");
let content = "text\u{3000}\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "text\n");
let content = "text\u{2000} \n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Unicode+ASCII mix should be flagged");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, "text\n",
"All trailing whitespace should be stripped when mix includes Unicode"
);
let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
let fixed2 = rule.fix(&ctx2).unwrap();
assert_eq!(fixed, fixed2, "Fix must be idempotent");
let content = "text \nnext\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Pure ASCII br_spaces should still be preserved");
}
#[test]
fn test_unicode_whitespace_strict_mode() {
let rule = MD009TrailingSpaces::new(2, true);
let content = "text\u{2000}\nmore\u{3000}\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "text\nmore\n");
}
fn assert_fix_roundtrip(rule: &MD009TrailingSpaces, content: &str) {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
let remaining = rule.check(&ctx2).unwrap();
assert!(
remaining.is_empty(),
"After fix(), check() should find 0 violations.\nOriginal: {content:?}\nFixed: {fixed:?}\nRemaining: {remaining:?}"
);
}
#[test]
fn test_roundtrip_basic_trailing_spaces() {
let rule = MD009TrailingSpaces::default();
assert_fix_roundtrip(&rule, "Line with spaces \nAnother line \nClean line");
}
#[test]
fn test_roundtrip_strict_mode() {
let rule = MD009TrailingSpaces::new(2, true);
assert_fix_roundtrip(
&rule,
"Line with spaces \nCode block: \n``` \nCode with spaces \n``` ",
);
}
#[test]
fn test_roundtrip_empty_lines() {
let rule = MD009TrailingSpaces::default();
assert_fix_roundtrip(&rule, "Normal line\n \n \nAnother line");
}
#[test]
fn test_roundtrip_br_spaces_preservation() {
let rule = MD009TrailingSpaces::new(2, false);
assert_fix_roundtrip(
&rule,
"Line with two spaces \nLine with three spaces \nLine with one space ",
);
}
#[test]
fn test_roundtrip_last_line_no_newline() {
let rule = MD009TrailingSpaces::new(2, false);
assert_fix_roundtrip(&rule, "First line \nLast line ");
}
#[test]
fn test_roundtrip_last_line_with_newline() {
let rule = MD009TrailingSpaces::new(2, false);
assert_fix_roundtrip(&rule, "First line \nLast line \n");
}
#[test]
fn test_roundtrip_unicode_whitespace() {
let rule = MD009TrailingSpaces::default();
assert_fix_roundtrip(&rule, "> 0\u{2000} ");
assert_fix_roundtrip(&rule, "text\u{2000}\n");
assert_fix_roundtrip(&rule, "text\u{3000}\n");
assert_fix_roundtrip(&rule, "text\u{2000} \n");
}
#[test]
fn test_roundtrip_code_blocks_non_strict() {
let rule = MD009TrailingSpaces::new(2, false);
assert_fix_roundtrip(
&rule,
"Line with spaces \n```\nCode with spaces \n```\nOutside code ",
);
}
#[test]
fn test_roundtrip_blockquotes() {
let rule = MD009TrailingSpaces::default();
assert_fix_roundtrip(&rule, "> Quote\n> \n> More quote");
assert_fix_roundtrip(&rule, "> > Nested \n> > \n> Normal ");
}
#[test]
fn test_roundtrip_list_item_empty_lines() {
let config = MD009Config {
list_item_empty_lines: true,
..Default::default()
};
let rule = MD009TrailingSpaces::from_config_struct(config);
assert_fix_roundtrip(&rule, "- First item\n \n- Second item");
assert_fix_roundtrip(&rule, "Normal paragraph\n \nAnother paragraph");
}
#[test]
fn test_roundtrip_complex_document() {
let rule = MD009TrailingSpaces::default();
assert_fix_roundtrip(
&rule,
"# Title \n\nParagraph \n\n- List \n - Nested \n\n```\ncode \n```\n\n> Quote \n> \n\nEnd ",
);
}
#[test]
fn test_roundtrip_multibyte() {
let rule = MD009TrailingSpaces::new(2, true);
assert_fix_roundtrip(&rule, "- 1€ expenses \n");
assert_fix_roundtrip(&rule, "€100 + €50 = €150 \n");
assert_fix_roundtrip(&rule, "Hello 你好世界 \n");
assert_fix_roundtrip(&rule, "Party 🎉🎉🎉 \n");
assert_fix_roundtrip(&rule, "안녕하세요 \n");
}
#[test]
fn test_roundtrip_mixed_tabs_and_spaces() {
let rule = MD009TrailingSpaces::default();
assert_fix_roundtrip(&rule, "Line with tab\t\nLine with spaces ");
assert_fix_roundtrip(&rule, "Line\t \nAnother\n");
}
#[test]
fn test_roundtrip_heading_with_br_spaces() {
let rule = MD009TrailingSpaces::new(2, false);
let content = "# Heading \nParagraph\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"check() should not flag heading with exactly br_spaces trailing spaces"
);
assert_fix_roundtrip(&rule, content);
}
#[test]
fn test_fix_replacement_always_removes_trailing_spaces() {
let rule = MD009TrailingSpaces::new(2, false);
let content = "Hello \nWorld\n";
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let fix = result[0].fix.as_ref().expect("Should have a fix");
assert_eq!(
fix.replacement, "",
"Fix replacement should always be empty string (remove trailing spaces)"
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "Hello\nWorld\n");
}
}