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        let content = ctx.content;
78
79        // Empty content remains empty
80        if content.is_empty() {
81            return Ok(String::new());
82        }
83
84        // Content has been normalized to LF at I/O boundary
85        // Check if file already ends with a newline
86        let has_trailing_newline = content.ends_with('\n');
87
88        if has_trailing_newline {
89            return Ok(content.to_string());
90        }
91
92        // Check if the rule is disabled on the last line via inline config
93        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        // Content doesn't end with newline, add LF (will be converted at I/O boundary if needed)
99        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        // Should not trigger when file has trailing newlines
145        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        // In production, content is normalized to LF before rules see it
155        // This test reflects the actual runtime behavior
156        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        // Rule always adds LF - I/O boundary converts to CRLF if needed
164        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        // Should not trigger when file contains only newlines
180        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}