rumdl_lib/rules/
md047_single_trailing_newline.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
2
3/// Detect the line ending style used in the content
4fn detect_line_ending(content: &str, ctx: &crate::lint_context::LintContext) -> &'static str {
5    // Check for CRLF first (more specific than LF)
6    // Use O(1) character check before doing O(n) string search
7    if ctx.has_char('\r') && content.contains("\r\n") {
8        "\r\n"
9    } else if ctx.has_char('\n') {
10        "\n"
11    } else {
12        // Default to LF for empty or single-line files
13        "\n"
14    }
15}
16
17/// Rule MD047: File should end with a single newline
18///
19/// See [docs/md047.md](../../docs/md047.md) for full documentation, configuration, and examples.
20
21#[derive(Debug, Default, Clone)]
22pub struct MD047SingleTrailingNewline;
23
24impl Rule for MD047SingleTrailingNewline {
25    fn name(&self) -> &'static str {
26        "MD047"
27    }
28
29    fn description(&self) -> &'static str {
30        "Files should end with a single newline character"
31    }
32
33    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
34        // Skip empty files - they don't need trailing newlines
35        ctx.content.is_empty()
36    }
37
38    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
39        let content = ctx.content;
40        let mut warnings = Vec::new();
41
42        // Empty content is fine
43        if content.is_empty() {
44            return Ok(warnings);
45        }
46
47        // Detect the line ending style used in the document
48        let line_ending = detect_line_ending(content, ctx);
49
50        // Check if file ends with newline (supporting both LF and CRLF)
51        let has_trailing_newline = content.ends_with('\n');
52
53        // Check for missing trailing newline
54        if !has_trailing_newline {
55            let lines = &ctx.lines;
56            let last_line_num = lines.len();
57            let last_line_content = lines.last().map(|s| s.content.as_str()).unwrap_or("");
58
59            // Calculate precise character range for the end of file
60            // For missing newline, highlight the end of the last line
61            let (start_line, start_col, end_line, end_col) = (
62                last_line_num,
63                last_line_content.len() + 1,
64                last_line_num,
65                last_line_content.len() + 1,
66            );
67
68            warnings.push(LintWarning {
69                rule_name: Some(self.name()),
70                message: String::from("File should end with a single newline character"),
71                line: start_line,
72                column: start_col,
73                end_line,
74                end_column: end_col,
75                severity: Severity::Warning,
76                fix: Some(Fix {
77                    // For missing newline, insert at the end of the file
78                    range: content.len()..content.len(),
79                    // Add newline using the detected line ending style
80                    replacement: line_ending.to_string(),
81                }),
82            });
83        }
84
85        Ok(warnings)
86    }
87
88    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
89        let content = ctx.content;
90
91        // Empty content remains empty
92        if content.is_empty() {
93            return Ok(String::new());
94        }
95
96        // Detect the line ending style used in the document
97        let line_ending = detect_line_ending(content, ctx);
98
99        // Check if file already ends with a newline
100        let has_trailing_newline = content.ends_with('\n');
101
102        if has_trailing_newline {
103            return Ok(content.to_string());
104        }
105
106        // Content doesn't end with newline, add one using detected style
107        let mut result = String::with_capacity(content.len() + line_ending.len());
108        result.push_str(content);
109        result.push_str(line_ending);
110        Ok(result)
111    }
112
113    fn as_any(&self) -> &dyn std::any::Any {
114        self
115    }
116
117    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
118    where
119        Self: Sized,
120    {
121        Box::new(MD047SingleTrailingNewline)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::lint_context::LintContext;
129
130    #[test]
131    fn test_valid_trailing_newline() {
132        let rule = MD047SingleTrailingNewline;
133        let content = "Line 1\nLine 2\n";
134        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
135        let result = rule.check(&ctx).unwrap();
136        assert!(result.is_empty());
137    }
138
139    #[test]
140    fn test_missing_trailing_newline() {
141        let rule = MD047SingleTrailingNewline;
142        let content = "Line 1\nLine 2";
143        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
144        let result = rule.check(&ctx).unwrap();
145        assert_eq!(result.len(), 1);
146        let fixed = rule.fix(&ctx).unwrap();
147        assert_eq!(fixed, "Line 1\nLine 2\n");
148    }
149
150    #[test]
151    fn test_multiple_trailing_newlines() {
152        // Should not trigger when file has trailing newlines
153        let rule = MD047SingleTrailingNewline;
154        let content = "Line 1\nLine 2\n\n\n";
155        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
156        let result = rule.check(&ctx).unwrap();
157        assert!(result.is_empty());
158    }
159
160    #[test]
161    fn test_crlf_line_ending_preservation() {
162        let rule = MD047SingleTrailingNewline;
163        // Content with CRLF line endings but missing final newline
164        let content = "Line 1\r\nLine 2";
165        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
166        let result = rule.check(&ctx).unwrap();
167        assert_eq!(result.len(), 1);
168
169        let fixed = rule.fix(&ctx).unwrap();
170        // Should preserve CRLF style
171        assert_eq!(fixed, "Line 1\r\nLine 2\r\n");
172        assert!(fixed.ends_with("\r\n"), "Should end with CRLF");
173    }
174
175    #[test]
176    fn test_crlf_multiple_newlines() {
177        // Should not trigger when file has CRLF trailing newlines
178        let rule = MD047SingleTrailingNewline;
179        let content = "Line 1\r\nLine 2\r\n\r\n\r\n";
180        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
181        let result = rule.check(&ctx).unwrap();
182        assert!(result.is_empty());
183    }
184
185    #[test]
186    fn test_detect_line_ending() {
187        let content1 = "Line 1\nLine 2";
188        let ctx1 = LintContext::new(content1, crate::config::MarkdownFlavor::Standard);
189        assert_eq!(detect_line_ending(content1, &ctx1), "\n");
190
191        let content2 = "Line 1\r\nLine 2";
192        let ctx2 = LintContext::new(content2, crate::config::MarkdownFlavor::Standard);
193        assert_eq!(detect_line_ending(content2, &ctx2), "\r\n");
194
195        let content3 = "Single line";
196        let ctx3 = LintContext::new(content3, crate::config::MarkdownFlavor::Standard);
197        assert_eq!(detect_line_ending(content3, &ctx3), "\n");
198
199        let content4 = "";
200        let ctx4 = LintContext::new(content4, crate::config::MarkdownFlavor::Standard);
201        assert_eq!(detect_line_ending(content4, &ctx4), "\n");
202
203        // Mixed line endings should detect CRLF (first match wins)
204        let content5 = "Line 1\r\nLine 2\nLine 3";
205        let ctx5 = LintContext::new(content5, crate::config::MarkdownFlavor::Standard);
206        assert_eq!(detect_line_ending(content5, &ctx5), "\r\n");
207    }
208
209    #[test]
210    fn test_blank_file() {
211        let rule = MD047SingleTrailingNewline;
212        let content = "";
213        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
214        let result = rule.check(&ctx).unwrap();
215        assert!(result.is_empty());
216    }
217
218    #[test]
219    fn test_file_with_only_newlines() {
220        // Should not trigger when file contains only newlines
221        let rule = MD047SingleTrailingNewline;
222        let content = "\n\n\n";
223        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
224        let result = rule.check(&ctx).unwrap();
225        assert!(result.is_empty());
226    }
227}