rumdl_lib/rules/
md047_single_trailing_newline.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2
3fn detect_line_ending(content: &str, ctx: &crate::lint_context::LintContext) -> &'static str {
5 if ctx.has_char('\r') && content.contains("\r\n") {
8 "\r\n"
9 } else if ctx.has_char('\n') {
10 "\n"
11 } else {
12 "\n"
14 }
15}
16
17#[derive(Debug, Default, Clone)]
22pub struct MD047SingleTrailingNewline;
23
24impl Rule for MD047SingleTrailingNewline {
25 fn name(&self) -> &'static str {
26 "MD047"
27 }
28
29 fn description(&self) -> &'static str {
30 "Files should end with a single newline character"
31 }
32
33 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
34 ctx.content.is_empty()
36 }
37
38 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
39 let content = ctx.content;
40 let mut warnings = Vec::new();
41
42 if content.is_empty() {
44 return Ok(warnings);
45 }
46
47 let line_ending = detect_line_ending(content, ctx);
49
50 let has_trailing_newline = content.ends_with('\n');
52
53 if !has_trailing_newline {
55 let lines = &ctx.lines;
56 let last_line_num = lines.len();
57 let last_line_content = lines.last().map(|s| s.content.as_str()).unwrap_or("");
58
59 let (start_line, start_col, end_line, end_col) = (
62 last_line_num,
63 last_line_content.len() + 1,
64 last_line_num,
65 last_line_content.len() + 1,
66 );
67
68 warnings.push(LintWarning {
69 rule_name: Some(self.name()),
70 message: String::from("File should end with a single newline character"),
71 line: start_line,
72 column: start_col,
73 end_line,
74 end_column: end_col,
75 severity: Severity::Warning,
76 fix: Some(Fix {
77 range: content.len()..content.len(),
79 replacement: line_ending.to_string(),
81 }),
82 });
83 }
84
85 Ok(warnings)
86 }
87
88 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
89 let content = ctx.content;
90
91 if content.is_empty() {
93 return Ok(String::new());
94 }
95
96 let line_ending = detect_line_ending(content, ctx);
98
99 let has_trailing_newline = content.ends_with('\n');
101
102 if has_trailing_newline {
103 return Ok(content.to_string());
104 }
105
106 let mut result = String::with_capacity(content.len() + line_ending.len());
108 result.push_str(content);
109 result.push_str(line_ending);
110 Ok(result)
111 }
112
113 fn as_any(&self) -> &dyn std::any::Any {
114 self
115 }
116
117 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
118 where
119 Self: Sized,
120 {
121 Box::new(MD047SingleTrailingNewline)
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use crate::lint_context::LintContext;
129
130 #[test]
131 fn test_valid_trailing_newline() {
132 let rule = MD047SingleTrailingNewline;
133 let content = "Line 1\nLine 2\n";
134 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
135 let result = rule.check(&ctx).unwrap();
136 assert!(result.is_empty());
137 }
138
139 #[test]
140 fn test_missing_trailing_newline() {
141 let rule = MD047SingleTrailingNewline;
142 let content = "Line 1\nLine 2";
143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
144 let result = rule.check(&ctx).unwrap();
145 assert_eq!(result.len(), 1);
146 let fixed = rule.fix(&ctx).unwrap();
147 assert_eq!(fixed, "Line 1\nLine 2\n");
148 }
149
150 #[test]
151 fn test_multiple_trailing_newlines() {
152 let rule = MD047SingleTrailingNewline;
154 let content = "Line 1\nLine 2\n\n\n";
155 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
156 let result = rule.check(&ctx).unwrap();
157 assert!(result.is_empty());
158 }
159
160 #[test]
161 fn test_crlf_line_ending_preservation() {
162 let rule = MD047SingleTrailingNewline;
163 let content = "Line 1\r\nLine 2";
165 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
166 let result = rule.check(&ctx).unwrap();
167 assert_eq!(result.len(), 1);
168
169 let fixed = rule.fix(&ctx).unwrap();
170 assert_eq!(fixed, "Line 1\r\nLine 2\r\n");
172 assert!(fixed.ends_with("\r\n"), "Should end with CRLF");
173 }
174
175 #[test]
176 fn test_crlf_multiple_newlines() {
177 let rule = MD047SingleTrailingNewline;
179 let content = "Line 1\r\nLine 2\r\n\r\n\r\n";
180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
181 let result = rule.check(&ctx).unwrap();
182 assert!(result.is_empty());
183 }
184
185 #[test]
186 fn test_detect_line_ending() {
187 let content1 = "Line 1\nLine 2";
188 let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard);
189 assert_eq!(detect_line_ending(content1, &ctx1), "\n");
190
191 let content2 = "Line 1\r\nLine 2";
192 let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
193 assert_eq!(detect_line_ending(content2, &ctx2), "\r\n");
194
195 let content3 = "Single line";
196 let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard);
197 assert_eq!(detect_line_ending(content3, &ctx3), "\n");
198
199 let content4 = "";
200 let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard);
201 assert_eq!(detect_line_ending(content4, &ctx4), "\n");
202
203 let content5 = "Line 1\r\nLine 2\nLine 3";
205 let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard);
206 assert_eq!(detect_line_ending(content5, &ctx5), "\r\n");
207 }
208
209 #[test]
210 fn test_blank_file() {
211 let rule = MD047SingleTrailingNewline;
212 let content = "";
213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
214 let result = rule.check(&ctx).unwrap();
215 assert!(result.is_empty());
216 }
217
218 #[test]
219 fn test_file_with_only_newlines() {
220 let rule = MD047SingleTrailingNewline;
222 let content = "\n\n\n";
223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
224 let result = rule.check(&ctx).unwrap();
225 assert!(result.is_empty());
226 }
227}