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 let content = ctx.content;
78
79 if content.is_empty() {
81 return Ok(String::new());
82 }
83
84 let has_trailing_newline = content.ends_with('\n');
87
88 if has_trailing_newline {
89 return Ok(content.to_string());
90 }
91
92 let last_line_num = ctx.lines.len();
94 if ctx.inline_config().is_rule_disabled(self.name(), last_line_num) {
95 return Ok(content.to_string());
96 }
97
98 let mut result = String::with_capacity(content.len() + 1);
100 result.push_str(content);
101 result.push('\n');
102 Ok(result)
103 }
104
105 fn as_any(&self) -> &dyn std::any::Any {
106 self
107 }
108
109 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
110 where
111 Self: Sized,
112 {
113 Box::new(MD047SingleTrailingNewline)
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::lint_context::LintContext;
121
122 #[test]
123 fn test_valid_trailing_newline() {
124 let rule = MD047SingleTrailingNewline;
125 let content = "Line 1\nLine 2\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_missing_trailing_newline() {
133 let rule = MD047SingleTrailingNewline;
134 let content = "Line 1\nLine 2";
135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
136 let result = rule.check(&ctx).unwrap();
137 assert_eq!(result.len(), 1);
138 let fixed = rule.fix(&ctx).unwrap();
139 assert_eq!(fixed, "Line 1\nLine 2\n");
140 }
141
142 #[test]
143 fn test_multiple_trailing_newlines() {
144 let rule = MD047SingleTrailingNewline;
146 let content = "Line 1\nLine 2\n\n\n";
147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
148 let result = rule.check(&ctx).unwrap();
149 assert!(result.is_empty());
150 }
151
152 #[test]
153 fn test_normalized_lf_content() {
154 let rule = MD047SingleTrailingNewline;
157 let content = "Line 1\nLine 2";
158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
159 let result = rule.check(&ctx).unwrap();
160 assert_eq!(result.len(), 1);
161
162 let fixed = rule.fix(&ctx).unwrap();
163 assert_eq!(fixed, "Line 1\nLine 2\n");
165 assert!(fixed.ends_with('\n'), "Should end with LF");
166 }
167
168 #[test]
169 fn test_blank_file() {
170 let rule = MD047SingleTrailingNewline;
171 let content = "";
172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
173 let result = rule.check(&ctx).unwrap();
174 assert!(result.is_empty());
175 }
176
177 #[test]
178 fn test_file_with_only_newlines() {
179 let rule = MD047SingleTrailingNewline;
181 let content = "\n\n\n";
182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
183 let result = rule.check(&ctx).unwrap();
184 assert!(result.is_empty());
185 }
186}