rumdl_lib/rules/
md047_single_trailing_newline.rs

1use crate::utils::range_utils::LineIndex;
2
3use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
4
5/// Detect the line ending style used in the content
6fn detect_line_ending(content: &str) -> &'static str {
7    // Check for CRLF first (more specific than LF)
8    if content.contains("\r\n") {
9        "\r\n"
10    } else if content.contains('\n') {
11        "\n"
12    } else {
13        // Default to LF for empty or single-line files
14        "\n"
15    }
16}
17
18/// Rule MD047: File should end with a single newline
19///
20/// See [docs/md047.md](../../docs/md047.md) for full documentation, configuration, and examples.
21
22#[derive(Debug, Default, Clone)]
23pub struct MD047SingleTrailingNewline;
24
25impl Rule for MD047SingleTrailingNewline {
26    fn name(&self) -> &'static str {
27        "MD047"
28    }
29
30    fn description(&self) -> &'static str {
31        "Files should end with a single newline character"
32    }
33
34    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
35        let content = ctx.content;
36        let mut warnings = Vec::new();
37
38        // Empty content is fine
39        if content.is_empty() {
40            return Ok(warnings);
41        }
42
43        // Detect the line ending style used in the document
44        let line_ending = detect_line_ending(content);
45
46        // Check if file ends with newline (supporting both LF and CRLF)
47        let has_trailing_newline = content.ends_with('\n');
48
49        // Check if file has multiple trailing newlines (supporting both styles)
50        let has_multiple_newlines = content.ends_with(&format!("{line_ending}{line_ending}"));
51
52        // Only issue warning if there's no newline or more than one
53        if !has_trailing_newline || has_multiple_newlines {
54            let lines = &ctx.lines;
55            let last_line_num = lines.len();
56            let last_line_content = lines.last().map(|s| s.content.as_str()).unwrap_or("");
57
58            // Calculate precise character range for the end of file
59            let (start_line, start_col, end_line, end_col) = if has_multiple_newlines {
60                // For multiple newlines, highlight from the end of the last content line to the end
61                let last_content_line = content.trim_end_matches('\n');
62                let last_content_line_count = last_content_line.lines().count();
63                if last_content_line_count == 0 {
64                    (1, 1, 1, 2)
65                } else {
66                    let line_content = last_content_line.lines().last().unwrap_or("");
67                    (
68                        last_content_line_count,
69                        line_content.len() + 1,
70                        last_content_line_count,
71                        line_content.len() + 2,
72                    )
73                }
74            } else {
75                // For missing newline, highlight the end of the last line
76                (
77                    last_line_num,
78                    last_line_content.len() + 1,
79                    last_line_num,
80                    last_line_content.len() + 1,
81                )
82            };
83
84            // Only create LineIndex when we actually need it for the fix
85            let line_index = LineIndex::new(content.to_string());
86
87            warnings.push(LintWarning {
88                rule_name: Some(self.name()),
89                message: String::from("File should end with a single newline character"),
90                line: start_line,
91                column: start_col,
92                end_line,
93                end_column: end_col,
94                severity: Severity::Warning,
95                fix: Some(Fix {
96                    range: if has_trailing_newline {
97                        // For multiple newlines, replace from the position to the end of file
98                        let start_range = line_index.line_col_to_byte_range_with_length(start_line, start_col, 0);
99                        start_range.start..content.len()
100                    } else {
101                        // For missing newline, insert at the end of the file
102                        let end_pos = content.len();
103                        end_pos..end_pos
104                    },
105                    replacement: if has_trailing_newline {
106                        // If there are multiple newlines, fix by ensuring just one
107                        let trimmed = content.trim_end();
108                        if !trimmed.is_empty() {
109                            line_ending.to_string()
110                        } else {
111                            // Handle the case where content is just whitespace and newlines
112                            String::new()
113                        }
114                    } else {
115                        // If there's no newline, add one using the detected line ending style
116                        line_ending.to_string()
117                    },
118                }),
119            });
120        }
121
122        Ok(warnings)
123    }
124
125    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
126        let content = ctx.content;
127
128        // Empty content remains empty
129        if content.is_empty() {
130            return Ok(String::new());
131        }
132
133        // Detect the line ending style used in the document
134        let line_ending = detect_line_ending(content);
135
136        // Check current state
137        let has_trailing_newline = content.ends_with('\n');
138        let has_multiple_newlines = content.ends_with(&format!("{line_ending}{line_ending}"));
139
140        // Early return if content is already correct
141        if has_trailing_newline && !has_multiple_newlines {
142            return Ok(content.to_string());
143        }
144
145        // Only allocate when we need to make changes
146        if !has_trailing_newline {
147            // Content doesn't end with newline, add one using detected style
148            let mut result = String::with_capacity(content.len() + line_ending.len());
149            result.push_str(content);
150            result.push_str(line_ending);
151            Ok(result)
152        } else {
153            // Has multiple newlines, trim them down to just one
154            // Need to handle both LF and CRLF when trimming
155            let content_without_trailing_newlines = if line_ending == "\r\n" {
156                content.trim_end_matches("\r\n")
157            } else {
158                content.trim_end_matches('\n')
159            };
160
161            if content_without_trailing_newlines.is_empty() {
162                // Handle the case where content is just newlines
163                Ok(line_ending.to_string())
164            } else {
165                let mut result = String::with_capacity(content_without_trailing_newlines.len() + line_ending.len());
166                result.push_str(content_without_trailing_newlines);
167                result.push_str(line_ending);
168                Ok(result)
169            }
170        }
171    }
172
173    fn as_any(&self) -> &dyn std::any::Any {
174        self
175    }
176
177    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
178    where
179        Self: Sized,
180    {
181        Box::new(MD047SingleTrailingNewline)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::lint_context::LintContext;
189
190    #[test]
191    fn test_valid_trailing_newline() {
192        let rule = MD047SingleTrailingNewline;
193        let content = "Line 1\nLine 2\n";
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_missing_trailing_newline() {
201        let rule = MD047SingleTrailingNewline;
202        let content = "Line 1\nLine 2";
203        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
204        let result = rule.check(&ctx).unwrap();
205        assert_eq!(result.len(), 1);
206        let fixed = rule.fix(&ctx).unwrap();
207        assert_eq!(fixed, "Line 1\nLine 2\n");
208    }
209
210    #[test]
211    fn test_multiple_trailing_newlines() {
212        let rule = MD047SingleTrailingNewline;
213        let content = "Line 1\nLine 2\n\n\n";
214        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
215        let result = rule.check(&ctx).unwrap();
216        assert_eq!(result.len(), 1);
217        let fixed = rule.fix(&ctx).unwrap();
218        assert_eq!(fixed, "Line 1\nLine 2\n");
219    }
220
221    #[test]
222    fn test_crlf_line_ending_preservation() {
223        let rule = MD047SingleTrailingNewline;
224        // Content with CRLF line endings but missing final newline
225        let content = "Line 1\r\nLine 2";
226        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
227        let result = rule.check(&ctx).unwrap();
228        assert_eq!(result.len(), 1);
229
230        let fixed = rule.fix(&ctx).unwrap();
231        // Should preserve CRLF style
232        assert_eq!(fixed, "Line 1\r\nLine 2\r\n");
233        assert!(fixed.ends_with("\r\n"), "Should end with CRLF");
234    }
235
236    #[test]
237    fn test_crlf_multiple_newlines() {
238        let rule = MD047SingleTrailingNewline;
239        // Content with CRLF line endings and multiple trailing newlines
240        let content = "Line 1\r\nLine 2\r\n\r\n\r\n";
241        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
242        let result = rule.check(&ctx).unwrap();
243        assert_eq!(result.len(), 1);
244
245        let fixed = rule.fix(&ctx).unwrap();
246        // Should preserve CRLF style and reduce to single trailing newline
247        assert_eq!(fixed, "Line 1\r\nLine 2\r\n");
248    }
249
250    #[test]
251    fn test_detect_line_ending() {
252        assert_eq!(detect_line_ending("Line 1\nLine 2"), "\n");
253        assert_eq!(detect_line_ending("Line 1\r\nLine 2"), "\r\n");
254        assert_eq!(detect_line_ending("Single line"), "\n");
255        assert_eq!(detect_line_ending(""), "\n");
256
257        // Mixed line endings should detect CRLF (first match wins)
258        assert_eq!(detect_line_ending("Line 1\r\nLine 2\nLine 3"), "\r\n");
259    }
260
261    #[test]
262    fn test_blank_file() {
263        let rule = MD047SingleTrailingNewline;
264        let content = "";
265        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
266        let result = rule.check(&ctx).unwrap();
267        assert!(result.is_empty());
268    }
269
270    #[test]
271    fn test_file_with_only_newlines() {
272        let rule = MD047SingleTrailingNewline;
273        let content = "\n\n\n";
274        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
275        let result = rule.check(&ctx).unwrap();
276        assert_eq!(result.len(), 1);
277        let fixed = rule.fix(&ctx).unwrap();
278        assert_eq!(fixed, "\n");
279    }
280}