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