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