markdownlint-rs 0.2.7

A fast, flexible, configuration-based command-line interface for linting Markdown/CommonMark files
Documentation
use crate::lint::rule::Rule;
use crate::markdown::MarkdownParser;
use crate::types::{Fix, Violation};
use serde_json::Value;

pub struct MD009;

impl Rule for MD009 {
    fn name(&self) -> &str {
        "MD009"
    }

    fn description(&self) -> &str {
        "Trailing spaces"
    }

    fn tags(&self) -> &[&str] {
        &["whitespace"]
    }

    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
        let br_spaces = config
            .and_then(|c| c.get("br_spaces"))
            .and_then(|v| v.as_u64())
            .unwrap_or(2) as usize;

        let strict = config
            .and_then(|c| c.get("strict"))
            .and_then(|v| v.as_bool())
            .unwrap_or(false);

        let mut violations = Vec::new();

        for (line_num, line) in parser.lines().iter().enumerate() {
            let trimmed = line.trim_end();
            let trailing_spaces = line.len() - trimmed.len();

            if trailing_spaces > 0 {
                // Allow br_spaces for line breaks unless strict mode
                if !strict && trailing_spaces == br_spaces {
                    continue;
                }

                violations.push(Violation {
                    line: line_num + 1,
                    column: Some(trimmed.len() + 1),
                    rule: self.name().to_string(),
                    message: format!("Trailing spaces ({} spaces)", trailing_spaces),
                    fix: Some(Fix {
                        line_start: line_num + 1,
                        line_end: line_num + 1,
                        column_start: Some(trimmed.len() + 1),
                        column_end: Some(line.len() + 1),
                        replacement: String::new(),
                        description: "Remove trailing spaces".to_string(),
                    }),
                });
            }
        }

        violations
    }

    fn fixable(&self) -> bool {
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_no_trailing_spaces() {
        let content = "Line 1\nLine 2\nLine 3";
        let parser = MarkdownParser::new(content);
        let rule = MD009;
        let violations = rule.check(&parser, None);

        assert_eq!(violations.len(), 0);
    }

    #[test]
    fn test_trailing_spaces() {
        let content = "Line 1  \nLine 2\nLine 3   ";
        let parser = MarkdownParser::new(content);
        let rule = MD009;
        let violations = rule.check(&parser, None);

        assert_eq!(violations.len(), 1); // Line 3 has 3 spaces, Line 1 has 2 (allowed for br)
        assert_eq!(violations[0].line, 3);
        assert_eq!(violations[0].column, Some(7));
    }

    #[test]
    fn test_strict_mode() {
        let content = "Line 1  \nLine 2";
        let parser = MarkdownParser::new(content);
        let rule = MD009;
        let config = serde_json::json!({ "strict": true });
        let violations = rule.check(&parser, Some(&config));

        assert_eq!(violations.len(), 1);
        assert_eq!(violations[0].line, 1);
    }

    #[test]
    fn test_custom_br_spaces() {
        let content = "Line 1   \nLine 2";
        let parser = MarkdownParser::new(content);
        let rule = MD009;
        let config = serde_json::json!({ "br_spaces": 3 });
        let violations = rule.check(&parser, Some(&config));

        assert_eq!(violations.len(), 0); // 3 spaces allowed for br
    }
}