rumdl_lib/rules/
md047_single_trailing_newline.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3#[derive(Debug, Default, Clone)]
8pub struct MD047SingleTrailingNewline;
9
10impl Rule for MD047SingleTrailingNewline {
11 fn name(&self) -> &'static str {
12 "MD047"
13 }
14
15 fn description(&self) -> &'static str {
16 "Files should end with a single newline character"
17 }
18
19 fn category(&self) -> RuleCategory {
20 RuleCategory::Whitespace
21 }
22
23 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
24 ctx.content.is_empty()
26 }
27
28 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
29 let content = ctx.content;
30 let mut warnings = Vec::new();
31
32 if content.is_empty() {
34 return Ok(warnings);
35 }
36
37 let has_trailing_newline = content.ends_with('\n');
40
41 if !has_trailing_newline {
43 let lines = &ctx.lines;
44 let last_line_num = lines.len();
45 let last_line_content = lines.last().map_or("", |s| s.content(content));
46
47 let (start_line, start_col, end_line, end_col) = (
50 last_line_num,
51 last_line_content.len() + 1,
52 last_line_num,
53 last_line_content.len() + 1,
54 );
55
56 warnings.push(LintWarning {
57 rule_name: Some(self.name().to_string()),
58 message: String::from("File should end with a single newline character"),
59 line: start_line,
60 column: start_col,
61 end_line,
62 end_column: end_col,
63 severity: Severity::Warning,
64 fix: Some(Fix::new(content.len()..content.len(), "\n".to_string())),
65 });
66 }
67
68 Ok(warnings)
69 }
70
71 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
72 if self.should_skip(ctx) {
73 return Ok(ctx.content.to_string());
74 }
75 let warnings = self.check(ctx)?;
76 if warnings.is_empty() {
77 return Ok(ctx.content.to_string());
78 }
79 let warnings =
80 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
81 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
82 }
83
84 fn as_any(&self) -> &dyn std::any::Any {
85 self
86 }
87
88 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
89 where
90 Self: Sized,
91 {
92 Box::new(MD047SingleTrailingNewline)
93 }
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use crate::lint_context::LintContext;
100
101 #[test]
102 fn test_valid_trailing_newline() {
103 let rule = MD047SingleTrailingNewline;
104 let content = "Line 1\nLine 2\n";
105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
106 let result = rule.check(&ctx).unwrap();
107 assert!(result.is_empty());
108 }
109
110 #[test]
111 fn test_missing_trailing_newline() {
112 let rule = MD047SingleTrailingNewline;
113 let content = "Line 1\nLine 2";
114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
115 let result = rule.check(&ctx).unwrap();
116 assert_eq!(result.len(), 1);
117 let fixed = rule.fix(&ctx).unwrap();
118 assert_eq!(fixed, "Line 1\nLine 2\n");
119 }
120
121 #[test]
122 fn test_multiple_trailing_newlines() {
123 let rule = MD047SingleTrailingNewline;
125 let content = "Line 1\nLine 2\n\n\n";
126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
127 let result = rule.check(&ctx).unwrap();
128 assert!(result.is_empty());
129 }
130
131 #[test]
132 fn test_normalized_lf_content() {
133 let rule = MD047SingleTrailingNewline;
136 let content = "Line 1\nLine 2";
137 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
138 let result = rule.check(&ctx).unwrap();
139 assert_eq!(result.len(), 1);
140
141 let fixed = rule.fix(&ctx).unwrap();
142 assert_eq!(fixed, "Line 1\nLine 2\n");
144 assert!(fixed.ends_with('\n'), "Should end with LF");
145 }
146
147 #[test]
148 fn test_blank_file() {
149 let rule = MD047SingleTrailingNewline;
150 let content = "";
151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
152 let result = rule.check(&ctx).unwrap();
153 assert!(result.is_empty());
154 }
155
156 #[test]
157 fn test_file_with_only_newlines() {
158 let rule = MD047SingleTrailingNewline;
160 let content = "\n\n\n";
161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
162 let result = rule.check(&ctx).unwrap();
163 assert!(result.is_empty());
164 }
165
166 fn assert_check_fix_roundtrip(content: &str) {
169 let rule = MD047SingleTrailingNewline;
170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
171 let warnings = rule.check(&ctx).unwrap();
172 let fixed_via_fix = rule.fix(&ctx).unwrap();
173
174 let fixed_via_check = if warnings.is_empty() {
176 content.to_string()
177 } else {
178 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap()
179 };
180
181 assert_eq!(
182 fixed_via_check, fixed_via_fix,
183 "check() Fix structs and fix() must produce identical results for content: {content:?}"
184 );
185 }
186
187 #[test]
188 fn test_roundtrip_missing_newline() {
189 assert_check_fix_roundtrip("Line 1\nLine 2");
190 }
191
192 #[test]
193 fn test_roundtrip_single_trailing_newline() {
194 assert_check_fix_roundtrip("Line 1\nLine 2\n");
195 }
196
197 #[test]
198 fn test_roundtrip_multiple_trailing_newlines() {
199 assert_check_fix_roundtrip("Line 1\nLine 2\n\n\n");
200 }
201
202 #[test]
203 fn test_roundtrip_empty_content() {
204 assert_check_fix_roundtrip("");
205 }
206
207 #[test]
208 fn test_roundtrip_only_newlines() {
209 assert_check_fix_roundtrip("\n\n\n");
210 }
211
212 #[test]
213 fn test_roundtrip_single_line_no_newline() {
214 assert_check_fix_roundtrip("Single line");
215 }
216
217 #[test]
218 fn test_roundtrip_unicode_content() {
219 assert_check_fix_roundtrip("Héllo wörld 日本語");
221 }
222
223 #[test]
224 fn test_roundtrip_inline_disable_on_last_line() {
225 let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD047 -->";
227 let rule = MD047SingleTrailingNewline;
228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
229 let fixed = rule.fix(&ctx).unwrap();
230 assert_eq!(fixed, content, "Inline disable on last line should prevent the fix");
231 }
232}