rumdl_lib/rules/
md047_single_trailing_newline.rs1use crate::utils::range_utils::LineIndex;
2
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
4
5fn detect_line_ending(content: &str) -> &'static str {
7 if content.contains("\r\n") {
9 "\r\n"
10 } else if content.contains('\n') {
11 "\n"
12 } else {
13 "\n"
15 }
16}
17
18#[derive(Debug, Default, Clone)]
23pub struct MD047SingleTrailingNewline;
24
25impl Rule for MD047SingleTrailingNewline {
26 fn name(&self) -> &'static str {
27 "MD047"
28 }
29
30 fn description(&self) -> &'static str {
31 "Files should end with a single newline character"
32 }
33
34 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
35 let content = ctx.content;
36 let mut warnings = Vec::new();
37
38 if content.is_empty() {
40 return Ok(warnings);
41 }
42
43 let line_ending = detect_line_ending(content);
45
46 let has_trailing_newline = content.ends_with('\n');
48
49 let has_multiple_newlines = content.ends_with(&format!("{line_ending}{line_ending}"));
51
52 if !has_trailing_newline || has_multiple_newlines {
54 let lines = &ctx.lines;
55 let last_line_num = lines.len();
56 let last_line_content = lines.last().map(|s| s.content.as_str()).unwrap_or("");
57
58 let (start_line, start_col, end_line, end_col) = if has_multiple_newlines {
60 let last_content_line = content.trim_end_matches('\n');
62 let last_content_line_count = last_content_line.lines().count();
63 if last_content_line_count == 0 {
64 (1, 1, 1, 2)
65 } else {
66 let line_content = last_content_line.lines().last().unwrap_or("");
67 (
68 last_content_line_count,
69 line_content.len() + 1,
70 last_content_line_count,
71 line_content.len() + 2,
72 )
73 }
74 } else {
75 (
77 last_line_num,
78 last_line_content.len() + 1,
79 last_line_num,
80 last_line_content.len() + 1,
81 )
82 };
83
84 let line_index = LineIndex::new(content.to_string());
86
87 warnings.push(LintWarning {
88 rule_name: Some(self.name()),
89 message: String::from("File should end with a single newline character"),
90 line: start_line,
91 column: start_col,
92 end_line,
93 end_column: end_col,
94 severity: Severity::Warning,
95 fix: Some(Fix {
96 range: if has_trailing_newline {
97 let start_range = line_index.line_col_to_byte_range_with_length(start_line, start_col, 0);
99 start_range.start..content.len()
100 } else {
101 let end_pos = content.len();
103 end_pos..end_pos
104 },
105 replacement: if has_trailing_newline {
106 let trimmed = content.trim_end();
108 if !trimmed.is_empty() {
109 line_ending.to_string()
110 } else {
111 String::new()
113 }
114 } else {
115 line_ending.to_string()
117 },
118 }),
119 });
120 }
121
122 Ok(warnings)
123 }
124
125 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
126 let content = ctx.content;
127
128 if content.is_empty() {
130 return Ok(String::new());
131 }
132
133 let line_ending = detect_line_ending(content);
135
136 let has_trailing_newline = content.ends_with('\n');
138 let has_multiple_newlines = content.ends_with(&format!("{line_ending}{line_ending}"));
139
140 if has_trailing_newline && !has_multiple_newlines {
142 return Ok(content.to_string());
143 }
144
145 if !has_trailing_newline {
147 let mut result = String::with_capacity(content.len() + line_ending.len());
149 result.push_str(content);
150 result.push_str(line_ending);
151 Ok(result)
152 } else {
153 let content_without_trailing_newlines = if line_ending == "\r\n" {
156 content.trim_end_matches("\r\n")
157 } else {
158 content.trim_end_matches('\n')
159 };
160
161 if content_without_trailing_newlines.is_empty() {
162 Ok(line_ending.to_string())
164 } else {
165 let mut result = String::with_capacity(content_without_trailing_newlines.len() + line_ending.len());
166 result.push_str(content_without_trailing_newlines);
167 result.push_str(line_ending);
168 Ok(result)
169 }
170 }
171 }
172
173 fn as_any(&self) -> &dyn std::any::Any {
174 self
175 }
176
177 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
178 where
179 Self: Sized,
180 {
181 Box::new(MD047SingleTrailingNewline)
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::lint_context::LintContext;
189
190 #[test]
191 fn test_valid_trailing_newline() {
192 let rule = MD047SingleTrailingNewline;
193 let content = "Line 1\nLine 2\n";
194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
195 let result = rule.check(&ctx).unwrap();
196 assert!(result.is_empty());
197 }
198
199 #[test]
200 fn test_missing_trailing_newline() {
201 let rule = MD047SingleTrailingNewline;
202 let content = "Line 1\nLine 2";
203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
204 let result = rule.check(&ctx).unwrap();
205 assert_eq!(result.len(), 1);
206 let fixed = rule.fix(&ctx).unwrap();
207 assert_eq!(fixed, "Line 1\nLine 2\n");
208 }
209
210 #[test]
211 fn test_multiple_trailing_newlines() {
212 let rule = MD047SingleTrailingNewline;
213 let content = "Line 1\nLine 2\n\n\n";
214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
215 let result = rule.check(&ctx).unwrap();
216 assert_eq!(result.len(), 1);
217 let fixed = rule.fix(&ctx).unwrap();
218 assert_eq!(fixed, "Line 1\nLine 2\n");
219 }
220
221 #[test]
222 fn test_crlf_line_ending_preservation() {
223 let rule = MD047SingleTrailingNewline;
224 let content = "Line 1\r\nLine 2";
226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
227 let result = rule.check(&ctx).unwrap();
228 assert_eq!(result.len(), 1);
229
230 let fixed = rule.fix(&ctx).unwrap();
231 assert_eq!(fixed, "Line 1\r\nLine 2\r\n");
233 assert!(fixed.ends_with("\r\n"), "Should end with CRLF");
234 }
235
236 #[test]
237 fn test_crlf_multiple_newlines() {
238 let rule = MD047SingleTrailingNewline;
239 let content = "Line 1\r\nLine 2\r\n\r\n\r\n";
241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
242 let result = rule.check(&ctx).unwrap();
243 assert_eq!(result.len(), 1);
244
245 let fixed = rule.fix(&ctx).unwrap();
246 assert_eq!(fixed, "Line 1\r\nLine 2\r\n");
248 }
249
250 #[test]
251 fn test_detect_line_ending() {
252 assert_eq!(detect_line_ending("Line 1\nLine 2"), "\n");
253 assert_eq!(detect_line_ending("Line 1\r\nLine 2"), "\r\n");
254 assert_eq!(detect_line_ending("Single line"), "\n");
255 assert_eq!(detect_line_ending(""), "\n");
256
257 assert_eq!(detect_line_ending("Line 1\r\nLine 2\nLine 3"), "\r\n");
259 }
260
261 #[test]
262 fn test_blank_file() {
263 let rule = MD047SingleTrailingNewline;
264 let content = "";
265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
266 let result = rule.check(&ctx).unwrap();
267 assert!(result.is_empty());
268 }
269
270 #[test]
271 fn test_file_with_only_newlines() {
272 let rule = MD047SingleTrailingNewline;
273 let content = "\n\n\n";
274 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
275 let result = rule.check(&ctx).unwrap();
276 assert_eq!(result.len(), 1);
277 let fixed = rule.fix(&ctx).unwrap();
278 assert_eq!(fixed, "\n");
279 }
280}