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        // Content doesn't end with newline, add LF (will be converted at I/O boundary if needed)
89        let mut result = String::with_capacity(content.len() + 1);
90        result.push_str(content);
91        result.push('\n');
92        Ok(result)
93    }
94
95    fn as_any(&self) -> &dyn std::any::Any {
96        self
97    }
98
99    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
100    where
101        Self: Sized,
102    {
103        Box::new(MD047SingleTrailingNewline)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::lint_context::LintContext;
111
112    #[test]
113    fn test_valid_trailing_newline() {
114        let rule = MD047SingleTrailingNewline;
115        let content = "Line 1\nLine 2\n";
116        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
117        let result = rule.check(&ctx).unwrap();
118        assert!(result.is_empty());
119    }
120
121    #[test]
122    fn test_missing_trailing_newline() {
123        let rule = MD047SingleTrailingNewline;
124        let content = "Line 1\nLine 2";
125        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
126        let result = rule.check(&ctx).unwrap();
127        assert_eq!(result.len(), 1);
128        let fixed = rule.fix(&ctx).unwrap();
129        assert_eq!(fixed, "Line 1\nLine 2\n");
130    }
131
132    #[test]
133    fn test_multiple_trailing_newlines() {
134        // Should not trigger when file has trailing newlines
135        let rule = MD047SingleTrailingNewline;
136        let content = "Line 1\nLine 2\n\n\n";
137        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
138        let result = rule.check(&ctx).unwrap();
139        assert!(result.is_empty());
140    }
141
142    #[test]
143    fn test_normalized_lf_content() {
144        // In production, content is normalized to LF before rules see it
145        // This test reflects the actual runtime behavior
146        let rule = MD047SingleTrailingNewline;
147        let content = "Line 1\nLine 2";
148        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
149        let result = rule.check(&ctx).unwrap();
150        assert_eq!(result.len(), 1);
151
152        let fixed = rule.fix(&ctx).unwrap();
153        // Rule always adds LF - I/O boundary converts to CRLF if needed
154        assert_eq!(fixed, "Line 1\nLine 2\n");
155        assert!(fixed.ends_with('\n'), "Should end with LF");
156    }
157
158    #[test]
159    fn test_blank_file() {
160        let rule = MD047SingleTrailingNewline;
161        let content = "";
162        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
163        let result = rule.check(&ctx).unwrap();
164        assert!(result.is_empty());
165    }
166
167    #[test]
168    fn test_file_with_only_newlines() {
169        // Should not trigger when file contains only newlines
170        let rule = MD047SingleTrailingNewline;
171        let content = "\n\n\n";
172        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
173        let result = rule.check(&ctx).unwrap();
174        assert!(result.is_empty());
175    }
176}