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#[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        // Skip empty files - they don't need trailing newlines
28        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        // Empty content is fine
36        if content.is_empty() {
37            return Ok(warnings);
38        }
39
40        // Holds for LF and CRLF alike; a CRLF document conforms the `\n` fix
41        // below to `\r\n` where warnings are collected.
42        let has_trailing_newline = content.ends_with('\n');
43
44        // Check for missing trailing newline
45        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            // Calculate precise character range for the end of file
51            // For missing newline, highlight the end of the last line
52            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        // Should not trigger when file has trailing newlines
128        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        // In production, content is normalized to LF before rules see it
138        // This test reflects the actual runtime behavior
139        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        // Rule always adds LF - I/O boundary converts to CRLF if needed
147        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        // Should not trigger when file contains only newlines
163        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    /// Roundtrip safety: applying check()'s Fix structs via apply_warning_fixes
171    /// must produce the same result as fix(). This guards against check/fix divergence.
172    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        // Apply fixes from check() warnings directly
179        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        // Multi-byte UTF-8 characters - ensure byte offsets in Fix are correct
224        assert_check_fix_roundtrip("Héllo wörld 日本語");
225    }
226
227    #[test]
228    fn test_roundtrip_inline_disable_on_last_line() {
229        // Inline disable should suppress the fix
230        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}