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        // Content has been normalized to LF at I/O boundary
38        // Check if file ends with newline
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 (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::new(content.len()..content.len(), "\n".to_string())),
65            });
66        }
67
68        Ok(warnings)
69    }
70
71    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
72        if self.should_skip(ctx) {
73            return Ok(ctx.content.to_string());
74        }
75        let warnings = self.check(ctx)?;
76        if warnings.is_empty() {
77            return Ok(ctx.content.to_string());
78        }
79        let warnings =
80            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
81        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
82    }
83
84    fn as_any(&self) -> &dyn std::any::Any {
85        self
86    }
87
88    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
89    where
90        Self: Sized,
91    {
92        Box::new(MD047SingleTrailingNewline)
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::lint_context::LintContext;
100
101    #[test]
102    fn test_valid_trailing_newline() {
103        let rule = MD047SingleTrailingNewline;
104        let content = "Line 1\nLine 2\n";
105        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
106        let result = rule.check(&ctx).unwrap();
107        assert!(result.is_empty());
108    }
109
110    #[test]
111    fn test_missing_trailing_newline() {
112        let rule = MD047SingleTrailingNewline;
113        let content = "Line 1\nLine 2";
114        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
115        let result = rule.check(&ctx).unwrap();
116        assert_eq!(result.len(), 1);
117        let fixed = rule.fix(&ctx).unwrap();
118        assert_eq!(fixed, "Line 1\nLine 2\n");
119    }
120
121    #[test]
122    fn test_multiple_trailing_newlines() {
123        // Should not trigger when file has trailing newlines
124        let rule = MD047SingleTrailingNewline;
125        let content = "Line 1\nLine 2\n\n\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_normalized_lf_content() {
133        // In production, content is normalized to LF before rules see it
134        // This test reflects the actual runtime behavior
135        let rule = MD047SingleTrailingNewline;
136        let content = "Line 1\nLine 2";
137        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
138        let result = rule.check(&ctx).unwrap();
139        assert_eq!(result.len(), 1);
140
141        let fixed = rule.fix(&ctx).unwrap();
142        // Rule always adds LF - I/O boundary converts to CRLF if needed
143        assert_eq!(fixed, "Line 1\nLine 2\n");
144        assert!(fixed.ends_with('\n'), "Should end with LF");
145    }
146
147    #[test]
148    fn test_blank_file() {
149        let rule = MD047SingleTrailingNewline;
150        let content = "";
151        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
152        let result = rule.check(&ctx).unwrap();
153        assert!(result.is_empty());
154    }
155
156    #[test]
157    fn test_file_with_only_newlines() {
158        // Should not trigger when file contains only newlines
159        let rule = MD047SingleTrailingNewline;
160        let content = "\n\n\n";
161        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
162        let result = rule.check(&ctx).unwrap();
163        assert!(result.is_empty());
164    }
165
166    /// Roundtrip safety: applying check()'s Fix structs via apply_warning_fixes
167    /// must produce the same result as fix(). This guards against check/fix divergence.
168    fn assert_check_fix_roundtrip(content: &str) {
169        let rule = MD047SingleTrailingNewline;
170        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
171        let warnings = rule.check(&ctx).unwrap();
172        let fixed_via_fix = rule.fix(&ctx).unwrap();
173
174        // Apply fixes from check() warnings directly
175        let fixed_via_check = if warnings.is_empty() {
176            content.to_string()
177        } else {
178            crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap()
179        };
180
181        assert_eq!(
182            fixed_via_check, fixed_via_fix,
183            "check() Fix structs and fix() must produce identical results for content: {content:?}"
184        );
185    }
186
187    #[test]
188    fn test_roundtrip_missing_newline() {
189        assert_check_fix_roundtrip("Line 1\nLine 2");
190    }
191
192    #[test]
193    fn test_roundtrip_single_trailing_newline() {
194        assert_check_fix_roundtrip("Line 1\nLine 2\n");
195    }
196
197    #[test]
198    fn test_roundtrip_multiple_trailing_newlines() {
199        assert_check_fix_roundtrip("Line 1\nLine 2\n\n\n");
200    }
201
202    #[test]
203    fn test_roundtrip_empty_content() {
204        assert_check_fix_roundtrip("");
205    }
206
207    #[test]
208    fn test_roundtrip_only_newlines() {
209        assert_check_fix_roundtrip("\n\n\n");
210    }
211
212    #[test]
213    fn test_roundtrip_single_line_no_newline() {
214        assert_check_fix_roundtrip("Single line");
215    }
216
217    #[test]
218    fn test_roundtrip_unicode_content() {
219        // Multi-byte UTF-8 characters - ensure byte offsets in Fix are correct
220        assert_check_fix_roundtrip("Héllo wörld 日本語");
221    }
222
223    #[test]
224    fn test_roundtrip_inline_disable_on_last_line() {
225        // Inline disable should suppress the fix
226        let content = "Line 1\nLine 2 <!-- rumdl-disable-line MD047 -->";
227        let rule = MD047SingleTrailingNewline;
228        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
229        let fixed = rule.fix(&ctx).unwrap();
230        assert_eq!(fixed, content, "Inline disable on last line should prevent the fix");
231    }
232}