rumdl_lib/rules/
md047_single_trailing_newline.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2
3fn detect_line_ending(content: &str) -> &'static str {
5 if content.contains("\r\n") {
7 "\r\n"
8 } else if content.contains('\n') {
9 "\n"
10 } else {
11 "\n"
13 }
14}
15
16#[derive(Debug, Default, Clone)]
21pub struct MD047SingleTrailingNewline;
22
23impl Rule for MD047SingleTrailingNewline {
24 fn name(&self) -> &'static str {
25 "MD047"
26 }
27
28 fn description(&self) -> &'static str {
29 "Files should end with a single newline character"
30 }
31
32 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
33 let content = ctx.content;
34 let mut warnings = Vec::new();
35
36 if content.is_empty() {
38 return Ok(warnings);
39 }
40
41 let line_ending = detect_line_ending(content);
43
44 let has_trailing_newline = content.ends_with('\n');
46
47 if !has_trailing_newline {
49 let lines = &ctx.lines;
50 let last_line_num = lines.len();
51 let last_line_content = lines.last().map(|s| s.content.as_str()).unwrap_or("");
52
53 let (start_line, start_col, end_line, end_col) = (
56 last_line_num,
57 last_line_content.len() + 1,
58 last_line_num,
59 last_line_content.len() + 1,
60 );
61
62 warnings.push(LintWarning {
63 rule_name: Some(self.name()),
64 message: String::from("File should end with a single newline character"),
65 line: start_line,
66 column: start_col,
67 end_line,
68 end_column: end_col,
69 severity: Severity::Warning,
70 fix: Some(Fix {
71 range: content.len()..content.len(),
73 replacement: line_ending.to_string(),
75 }),
76 });
77 }
78
79 Ok(warnings)
80 }
81
82 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
83 let content = ctx.content;
84
85 if content.is_empty() {
87 return Ok(String::new());
88 }
89
90 let line_ending = detect_line_ending(content);
92
93 let has_trailing_newline = content.ends_with('\n');
95
96 if has_trailing_newline {
97 return Ok(content.to_string());
98 }
99
100 let mut result = String::with_capacity(content.len() + line_ending.len());
102 result.push_str(content);
103 result.push_str(line_ending);
104 Ok(result)
105 }
106
107 fn as_any(&self) -> &dyn std::any::Any {
108 self
109 }
110
111 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
112 where
113 Self: Sized,
114 {
115 Box::new(MD047SingleTrailingNewline)
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::lint_context::LintContext;
123
124 #[test]
125 fn test_valid_trailing_newline() {
126 let rule = MD047SingleTrailingNewline;
127 let content = "Line 1\nLine 2\n";
128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
129 let result = rule.check(&ctx).unwrap();
130 assert!(result.is_empty());
131 }
132
133 #[test]
134 fn test_missing_trailing_newline() {
135 let rule = MD047SingleTrailingNewline;
136 let content = "Line 1\nLine 2";
137 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
138 let result = rule.check(&ctx).unwrap();
139 assert_eq!(result.len(), 1);
140 let fixed = rule.fix(&ctx).unwrap();
141 assert_eq!(fixed, "Line 1\nLine 2\n");
142 }
143
144 #[test]
145 fn test_multiple_trailing_newlines() {
146 let rule = MD047SingleTrailingNewline;
148 let content = "Line 1\nLine 2\n\n\n";
149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
150 let result = rule.check(&ctx).unwrap();
151 assert!(result.is_empty());
152 }
153
154 #[test]
155 fn test_crlf_line_ending_preservation() {
156 let rule = MD047SingleTrailingNewline;
157 let content = "Line 1\r\nLine 2";
159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
160 let result = rule.check(&ctx).unwrap();
161 assert_eq!(result.len(), 1);
162
163 let fixed = rule.fix(&ctx).unwrap();
164 assert_eq!(fixed, "Line 1\r\nLine 2\r\n");
166 assert!(fixed.ends_with("\r\n"), "Should end with CRLF");
167 }
168
169 #[test]
170 fn test_crlf_multiple_newlines() {
171 let rule = MD047SingleTrailingNewline;
173 let content = "Line 1\r\nLine 2\r\n\r\n\r\n";
174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
175 let result = rule.check(&ctx).unwrap();
176 assert!(result.is_empty());
177 }
178
179 #[test]
180 fn test_detect_line_ending() {
181 assert_eq!(detect_line_ending("Line 1\nLine 2"), "\n");
182 assert_eq!(detect_line_ending("Line 1\r\nLine 2"), "\r\n");
183 assert_eq!(detect_line_ending("Single line"), "\n");
184 assert_eq!(detect_line_ending(""), "\n");
185
186 assert_eq!(detect_line_ending("Line 1\r\nLine 2\nLine 3"), "\r\n");
188 }
189
190 #[test]
191 fn test_blank_file() {
192 let rule = MD047SingleTrailingNewline;
193 let content = "";
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_file_with_only_newlines() {
201 let rule = MD047SingleTrailingNewline;
203 let content = "\n\n\n";
204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
205 let result = rule.check(&ctx).unwrap();
206 assert!(result.is_empty());
207 }
208}