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