mdlint/lint/rules/
md047.rs1use 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 if !content.ends_with('\n') {
33 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 let trailing_newlines = content.chars().rev().take_while(|&c| c == '\n').count();
52
53 if trailing_newlines > 1 {
54 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); }
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}