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