Skip to main content

rumdl_lib/rules/
md047_single_trailing_newline.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, 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 should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
20        // Skip empty files - they don't need trailing newlines
21        ctx.content.is_empty()
22    }
23
24    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
25        let content = ctx.content;
26        let mut warnings = Vec::new();
27
28        // Empty content is fine
29        if content.is_empty() {
30            return Ok(warnings);
31        }
32
33        // Content has been normalized to LF at I/O boundary
34        // Check if file ends with newline
35        let has_trailing_newline = content.ends_with('\n');
36
37        // Check for missing trailing newline
38        if !has_trailing_newline {
39            let lines = &ctx.lines;
40            let last_line_num = lines.len();
41            let last_line_content = lines.last().map(|s| s.content(content)).unwrap_or("");
42
43            // Calculate precise character range for the end of file
44            // For missing newline, highlight the end of the last line
45            let (start_line, start_col, end_line, end_col) = (
46                last_line_num,
47                last_line_content.len() + 1,
48                last_line_num,
49                last_line_content.len() + 1,
50            );
51
52            warnings.push(LintWarning {
53                rule_name: Some(self.name().to_string()),
54                message: String::from("File should end with a single newline character"),
55                line: start_line,
56                column: start_col,
57                end_line,
58                end_column: end_col,
59                severity: Severity::Warning,
60                fix: Some(Fix {
61                    // For missing newline, insert at the end of the file
62                    range: content.len()..content.len(),
63                    // Always add LF - will be converted to CRLF at I/O boundary if needed
64                    replacement: "\n".to_string(),
65                }),
66            });
67        }
68
69        Ok(warnings)
70    }
71
72    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
73        let content = ctx.content;
74
75        // Empty content remains empty
76        if content.is_empty() {
77            return Ok(String::new());
78        }
79
80        // Content has been normalized to LF at I/O boundary
81        // Check if file already ends with a newline
82        let has_trailing_newline = content.ends_with('\n');
83
84        if has_trailing_newline {
85            return Ok(content.to_string());
86        }
87
88        // Check if the rule is disabled on the last line via inline config
89        let last_line_num = ctx.lines.len();
90        if ctx.inline_config().is_rule_disabled(self.name(), last_line_num) {
91            return Ok(content.to_string());
92        }
93
94        // Content doesn't end with newline, add LF (will be converted at I/O boundary if needed)
95        let mut result = String::with_capacity(content.len() + 1);
96        result.push_str(content);
97        result.push('\n');
98        Ok(result)
99    }
100
101    fn as_any(&self) -> &dyn std::any::Any {
102        self
103    }
104
105    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
106    where
107        Self: Sized,
108    {
109        Box::new(MD047SingleTrailingNewline)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::lint_context::LintContext;
117
118    #[test]
119    fn test_valid_trailing_newline() {
120        let rule = MD047SingleTrailingNewline;
121        let content = "Line 1\nLine 2\n";
122        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
123        let result = rule.check(&ctx).unwrap();
124        assert!(result.is_empty());
125    }
126
127    #[test]
128    fn test_missing_trailing_newline() {
129        let rule = MD047SingleTrailingNewline;
130        let content = "Line 1\nLine 2";
131        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
132        let result = rule.check(&ctx).unwrap();
133        assert_eq!(result.len(), 1);
134        let fixed = rule.fix(&ctx).unwrap();
135        assert_eq!(fixed, "Line 1\nLine 2\n");
136    }
137
138    #[test]
139    fn test_multiple_trailing_newlines() {
140        // Should not trigger when file has trailing newlines
141        let rule = MD047SingleTrailingNewline;
142        let content = "Line 1\nLine 2\n\n\n";
143        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
144        let result = rule.check(&ctx).unwrap();
145        assert!(result.is_empty());
146    }
147
148    #[test]
149    fn test_normalized_lf_content() {
150        // In production, content is normalized to LF before rules see it
151        // This test reflects the actual runtime behavior
152        let rule = MD047SingleTrailingNewline;
153        let content = "Line 1\nLine 2";
154        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
155        let result = rule.check(&ctx).unwrap();
156        assert_eq!(result.len(), 1);
157
158        let fixed = rule.fix(&ctx).unwrap();
159        // Rule always adds LF - I/O boundary converts to CRLF if needed
160        assert_eq!(fixed, "Line 1\nLine 2\n");
161        assert!(fixed.ends_with('\n'), "Should end with LF");
162    }
163
164    #[test]
165    fn test_blank_file() {
166        let rule = MD047SingleTrailingNewline;
167        let content = "";
168        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
169        let result = rule.check(&ctx).unwrap();
170        assert!(result.is_empty());
171    }
172
173    #[test]
174    fn test_file_with_only_newlines() {
175        // Should not trigger when file contains only newlines
176        let rule = MD047SingleTrailingNewline;
177        let content = "\n\n\n";
178        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
179        let result = rule.check(&ctx).unwrap();
180        assert!(result.is_empty());
181    }
182}