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(|s| s.content(content)).unwrap_or("");
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 {
65 range: content.len()..content.len(),
67 replacement: "\n".to_string(),
69 }),
70 });
71 }
72
73 Ok(warnings)
74 }
75
76 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
77 if self.should_skip(ctx) {
78 return Ok(ctx.content.to_string());
79 }
80 let warnings = self.check(ctx)?;
81 if warnings.is_empty() {
82 return Ok(ctx.content.to_string());
83 }
84 let warnings =
85 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
86 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
87 }
88
89 fn as_any(&self) -> &dyn std::any::Any {
90 self
91 }
92
93 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
94 where
95 Self: Sized,
96 {
97 Box::new(MD047SingleTrailingNewline)
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use crate::lint_context::LintContext;
105
106 #[test]
107 fn test_valid_trailing_newline() {
108 let rule = MD047SingleTrailingNewline;
109 let content = "Line 1\nLine 2\n";
110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
111 let result = rule.check(&ctx).unwrap();
112 assert!(result.is_empty());
113 }
114
115 #[test]
116 fn test_missing_trailing_newline() {
117 let rule = MD047SingleTrailingNewline;
118 let content = "Line 1\nLine 2";
119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
120 let result = rule.check(&ctx).unwrap();
121 assert_eq!(result.len(), 1);
122 let fixed = rule.fix(&ctx).unwrap();
123 assert_eq!(fixed, "Line 1\nLine 2\n");
124 }
125
126 #[test]
127 fn test_multiple_trailing_newlines() {
128 let rule = MD047SingleTrailingNewline;
130 let content = "Line 1\nLine 2\n\n\n";
131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
132 let result = rule.check(&ctx).unwrap();
133 assert!(result.is_empty());
134 }
135
136 #[test]
137 fn test_normalized_lf_content() {
138 let rule = MD047SingleTrailingNewline;
141 let content = "Line 1\nLine 2";
142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
143 let result = rule.check(&ctx).unwrap();
144 assert_eq!(result.len(), 1);
145
146 let fixed = rule.fix(&ctx).unwrap();
147 assert_eq!(fixed, "Line 1\nLine 2\n");
149 assert!(fixed.ends_with('\n'), "Should end with LF");
150 }
151
152 #[test]
153 fn test_blank_file() {
154 let rule = MD047SingleTrailingNewline;
155 let content = "";
156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
157 let result = rule.check(&ctx).unwrap();
158 assert!(result.is_empty());
159 }
160
161 #[test]
162 fn test_file_with_only_newlines() {
163 let rule = MD047SingleTrailingNewline;
165 let content = "\n\n\n";
166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
167 let result = rule.check(&ctx).unwrap();
168 assert!(result.is_empty());
169 }
170
171 fn assert_check_fix_roundtrip(content: &str) {
174 let rule = MD047SingleTrailingNewline;
175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
176 let warnings = rule.check(&ctx).unwrap();
177 let fixed_via_fix = rule.fix(&ctx).unwrap();
178
179 let fixed_via_check = if warnings.is_empty() {
181 content.to_string()
182 } else {
183 crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap()
184 };
185
186 assert_eq!(
187 fixed_via_check, fixed_via_fix,
188 "check() Fix structs and fix() must produce identical results for content: {content:?}"
189 );
190 }
191
192 #[test]
193 fn test_roundtrip_missing_newline() {
194 assert_check_fix_roundtrip("Line 1\nLine 2");
195 }
196
197 #[test]
198 fn test_roundtrip_single_trailing_newline() {
199 assert_check_fix_roundtrip("Line 1\nLine 2\n");
200 }
201
202 #[test]
203 fn test_roundtrip_multiple_trailing_newlines() {
204 assert_check_fix_roundtrip("Line 1\nLine 2\n\n\n");
205 }
206
207 #[test]
208 fn test_roundtrip_empty_content() {
209 assert_check_fix_roundtrip("");
210 }
211
212 #[test]
213 fn test_roundtrip_only_newlines() {
214 assert_check_fix_roundtrip("\n\n\n");
215 }
216
217 #[test]
218 fn test_roundtrip_single_line_no_newline() {
219 assert_check_fix_roundtrip("Single line");
220 }
221
222 #[test]
223 fn test_roundtrip_unicode_content() {
224 assert_check_fix_roundtrip("Héllo wörld 日本語");
226 }
227
228 #[test]
229 fn test_roundtrip_inline_disable_on_last_line() {
230 let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD047 -->";
232 let rule = MD047SingleTrailingNewline;
233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
234 let fixed = rule.fix(&ctx).unwrap();
235 assert_eq!(fixed, content, "Inline disable on last line should prevent the fix");
236 }
237}