rumdl_lib/rules/
md047_single_trailing_newline.rs1use crate::utils::range_utils::LineIndex;
2
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
4
5#[derive(Debug, Default, Clone)]
10pub struct MD047SingleTrailingNewline;
11
12impl Rule for MD047SingleTrailingNewline {
13 fn name(&self) -> &'static str {
14 "MD047"
15 }
16
17 fn description(&self) -> &'static str {
18 "Files should end with a single newline character"
19 }
20
21 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
22 let content = ctx.content;
23 let mut warnings = Vec::new();
24
25 if content.is_empty() {
27 return Ok(warnings);
28 }
29
30 let has_trailing_newline = content.ends_with('\n');
32
33 let has_multiple_newlines = content.ends_with("\n\n");
35
36 if !has_trailing_newline || has_multiple_newlines {
38 let lines = &ctx.lines;
39 let last_line_num = lines.len();
40 let last_line_content = lines.last().map(|s| s.content.as_str()).unwrap_or("");
41
42 let (start_line, start_col, end_line, end_col) = if has_multiple_newlines {
44 let last_content_line = content.trim_end_matches('\n');
46 let last_content_line_count = last_content_line.lines().count();
47 if last_content_line_count == 0 {
48 (1, 1, 1, 2)
49 } else {
50 let line_content = last_content_line.lines().last().unwrap_or("");
51 (
52 last_content_line_count,
53 line_content.len() + 1,
54 last_content_line_count,
55 line_content.len() + 2,
56 )
57 }
58 } else {
59 (
61 last_line_num,
62 last_line_content.len() + 1,
63 last_line_num,
64 last_line_content.len() + 1,
65 )
66 };
67
68 let line_index = LineIndex::new(content.to_string());
70
71 warnings.push(LintWarning {
72 rule_name: Some(self.name()),
73 message: String::from("File should end with a single newline character"),
74 line: start_line,
75 column: start_col,
76 end_line,
77 end_column: end_col,
78 severity: Severity::Warning,
79 fix: Some(Fix {
80 range: if has_trailing_newline {
81 let start_range = line_index.line_col_to_byte_range_with_length(start_line, start_col, 0);
83 start_range.start..content.len()
84 } else {
85 let end_pos = content.len();
87 end_pos..end_pos
88 },
89 replacement: if has_trailing_newline {
90 let trimmed = content.trim_end();
92 if !trimmed.is_empty() {
93 "\n".to_string()
94 } else {
95 String::new()
97 }
98 } else {
99 String::from("\n")
101 },
102 }),
103 });
104 }
105
106 Ok(warnings)
107 }
108
109 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
110 let content = ctx.content;
111
112 if content.is_empty() {
114 return Ok(String::new());
115 }
116
117 let has_trailing_newline = content.ends_with('\n');
119 let has_multiple_newlines = content.ends_with("\n\n");
120
121 if has_trailing_newline && !has_multiple_newlines {
123 return Ok(content.to_string());
124 }
125
126 if !has_trailing_newline {
128 let mut result = String::with_capacity(content.len() + 1);
130 result.push_str(content);
131 result.push('\n');
132 Ok(result)
133 } else {
134 let content_without_trailing_newlines = content.trim_end_matches('\n');
136 if content_without_trailing_newlines.is_empty() {
137 Ok("\n".to_string())
139 } else {
140 let mut result = String::with_capacity(content_without_trailing_newlines.len() + 1);
141 result.push_str(content_without_trailing_newlines);
142 result.push('\n');
143 Ok(result)
144 }
145 }
146 }
147
148 fn as_any(&self) -> &dyn std::any::Any {
149 self
150 }
151
152 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
153 where
154 Self: Sized,
155 {
156 Box::new(MD047SingleTrailingNewline)
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use crate::lint_context::LintContext;
164
165 #[test]
166 fn test_valid_trailing_newline() {
167 let rule = MD047SingleTrailingNewline;
168 let content = "Line 1\nLine 2\n";
169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
170 let result = rule.check(&ctx).unwrap();
171 assert!(result.is_empty());
172 }
173
174 #[test]
175 fn test_missing_trailing_newline() {
176 let rule = MD047SingleTrailingNewline;
177 let content = "Line 1\nLine 2";
178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
179 let result = rule.check(&ctx).unwrap();
180 assert_eq!(result.len(), 1);
181 let fixed = rule.fix(&ctx).unwrap();
182 assert_eq!(fixed, "Line 1\nLine 2\n");
183 }
184
185 #[test]
186 fn test_multiple_trailing_newlines() {
187 let rule = MD047SingleTrailingNewline;
188 let content = "Line 1\nLine 2\n\n\n";
189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
190 let result = rule.check(&ctx).unwrap();
191 assert_eq!(result.len(), 1);
192 let fixed = rule.fix(&ctx).unwrap();
193 assert_eq!(fixed, "Line 1\nLine 2\n");
194 }
195
196 #[test]
197 fn test_blank_file() {
198 let rule = MD047SingleTrailingNewline;
199 let content = "";
200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
201 let result = rule.check(&ctx).unwrap();
202 assert!(result.is_empty());
203 }
204
205 #[test]
206 fn test_file_with_only_newlines() {
207 let rule = MD047SingleTrailingNewline;
208 let content = "\n\n\n";
209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
210 let result = rule.check(&ctx).unwrap();
211 assert_eq!(result.len(), 1);
212 let fixed = rule.fix(&ctx).unwrap();
213 assert_eq!(fixed, "\n");
214 }
215}