Skip to main content

mdlint/lint/rules/
md047.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::{Fix, Violation};
4use serde_json::Value;
5
6pub struct MD047;
7
8impl Rule for MD047 {
9    fn name(&self) -> &str {
10        "MD047"
11    }
12
13    fn description(&self) -> &str {
14        "Files should end with a single newline character"
15    }
16
17    fn tags(&self) -> &[&str] {
18        &["blank_lines"]
19    }
20
21    fn check(&self, parser: &MarkdownParser, _config: Option<&Value>) -> Vec<Violation> {
22        let mut violations = Vec::new();
23        let content = parser.content();
24
25        if content.is_empty() {
26            return violations;
27        }
28
29        let lines = parser.lines();
30
31        // Check if file ends with a newline
32        if !content.ends_with('\n') {
33            // Missing newline at end
34            let last_line = lines.last().unwrap_or(&"");
35            violations.push(Violation {
36                line: lines.len(),
37                column: Some(1),
38                rule: self.name().to_string(),
39                message: "Files should end with a single newline character".to_string(),
40                fix: Some(Fix {
41                    line_start: lines.len(),
42                    line_end: lines.len(),
43                    column_start: None,
44                    column_end: None,
45                    replacement: format!("{}\n", last_line),
46                    description: "Add newline at end of file".to_string(),
47                }),
48            });
49        } else if content.ends_with("\n\n") {
50            // Multiple trailing newlines - count them
51            let trailing_newlines = content.chars().rev().take_while(|&c| c == '\n').count();
52
53            if trailing_newlines > 1 {
54                // Remove all but one newline
55                // The last "line" in lines() will be empty string(s) for trailing newlines
56                let last_content_line_idx = lines.len().saturating_sub(trailing_newlines);
57                let last_content_line = if last_content_line_idx > 0 {
58                    lines.get(last_content_line_idx - 1).unwrap_or(&"")
59                } else {
60                    ""
61                };
62
63                violations.push(Violation {
64                    line: lines.len(),
65                    column: Some(1),
66                    rule: self.name().to_string(),
67                    message: "Files should end with a single newline character".to_string(),
68                    fix: Some(Fix {
69                        line_start: last_content_line_idx.max(1),
70                        line_end: lines.len(),
71                        column_start: None,
72                        column_end: None,
73                        replacement: if last_content_line_idx > 0 {
74                            format!("{}\n", last_content_line)
75                        } else {
76                            "\n".to_string()
77                        },
78                        description: "Remove extra newlines at end of file".to_string(),
79                    }),
80                });
81            }
82        }
83
84        violations
85    }
86
87    fn fixable(&self) -> bool {
88        true
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::fix::Fixer;
96
97    fn apply_fixes(content: &str, violations: &[Violation]) -> String {
98        let fixes: Vec<_> = violations.iter().filter_map(|v| v.fix.clone()).collect();
99        Fixer::new()
100            .apply_fixes_to_content(content, &fixes)
101            .unwrap()
102    }
103
104    #[test]
105    fn test_single_newline() {
106        let content = "# Heading\n\nContent\n";
107        let parser = MarkdownParser::new(content);
108        let rule = MD047;
109        let violations = rule.check(&parser, None);
110
111        assert_eq!(violations.len(), 0);
112    }
113
114    #[test]
115    fn test_no_newline() {
116        let content = "# Heading\n\nContent";
117        let parser = MarkdownParser::new(content);
118        let rule = MD047;
119        let violations = rule.check(&parser, None);
120
121        assert_eq!(violations.len(), 1);
122    }
123
124    #[test]
125    fn test_multiple_newlines() {
126        let content = "# Heading\n\nContent\n\n";
127        let parser = MarkdownParser::new(content);
128        let rule = MD047;
129        let violations = rule.check(&parser, None);
130
131        assert_eq!(violations.len(), 1);
132    }
133
134    #[test]
135    fn test_empty_file() {
136        let content = "";
137        let parser = MarkdownParser::new(content);
138        let rule = MD047;
139        let violations = rule.check(&parser, None);
140
141        assert_eq!(violations.len(), 0); // Empty file is OK
142    }
143
144    #[test]
145    fn test_fix_adds_trailing_newline() {
146        let content = "# Heading\n\nContent";
147        let parser = MarkdownParser::new(content);
148        let rule = MD047;
149        let violations = rule.check(&parser, None);
150        assert_eq!(violations.len(), 1);
151        let fixed = apply_fixes(content, &violations);
152        assert_eq!(fixed, "# Heading\n\nContent\n");
153    }
154}