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