Skip to main content

rumdl_lib/rules/
md047_single_trailing_newline.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2
3/// Rule MD047: File should end with a single newline
4///
5/// See [docs/md047.md](../../docs/md047.md) for full documentation, configuration, and examples.
6
7#[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        // Skip empty files - they don't need trailing newlines
25        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        // Empty content is fine
33        if content.is_empty() {
34            return Ok(warnings);
35        }
36
37        // Holds for LF and CRLF alike; a CRLF document conforms the `\n` fix
38        // below to `\r\n` where warnings are collected.
39        let has_trailing_newline = content.ends_with('\n');
40
41        // Check for missing trailing newline
42        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            // Calculate precise character range for the end of file
48            // For missing newline, highlight the end of the last line
49            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        // Should not trigger when file has trailing newlines
121        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        // In production, content is normalized to LF before rules see it
131        // This test reflects the actual runtime behavior
132        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        // Rule always adds LF - I/O boundary converts to CRLF if needed
140        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        // Should not trigger when file contains only newlines
156        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    /// Roundtrip safety: applying check()'s Fix structs via apply_warning_fixes
164    /// must produce the same result as fix(). This guards against check/fix divergence.
165    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        // Apply fixes from check() warnings directly
172        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        // Multi-byte UTF-8 characters - ensure byte offsets in Fix are correct
217        assert_check_fix_roundtrip("Héllo wörld 日本語");
218    }
219
220    #[test]
221    fn test_roundtrip_inline_disable_on_last_line() {
222        // Inline disable should suppress the fix
223        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}