mdbook_lint_core/rules/standard/
md047.rs

1//! MD047: Files should end with a single newline character
2//!
3//! This rule checks that files end with exactly one newline character.
4
5use crate::error::Result;
6use crate::rule::{Rule, RuleCategory, RuleMetadata};
7use crate::{
8    Document,
9    violation::{Severity, Violation},
10};
11
12/// Rule to check that files end with a single newline
13pub struct MD047;
14
15impl MD047 {
16    /// Check the ending of the file content
17    fn check_file_ending(&self, content: &str) -> Option<String> {
18        if content.is_empty() {
19            return Some("File should end with a single newline character".to_string());
20        }
21
22        let ends_with_newline = content.ends_with('\n');
23        let ends_with_multiple_newlines = content.ends_with("\n\n");
24
25        if !ends_with_newline {
26            Some("File should end with a single newline character".to_string())
27        } else if ends_with_multiple_newlines {
28            // Count trailing newlines
29            let trailing_newlines = content.chars().rev().take_while(|&c| c == '\n').count();
30
31            if trailing_newlines > 1 {
32                Some(format!(
33                    "File should end with a single newline character (found {trailing_newlines} trailing newlines)"
34                ))
35            } else {
36                None
37            }
38        } else {
39            None
40        }
41    }
42}
43
44impl Rule for MD047 {
45    fn id(&self) -> &'static str {
46        "MD047"
47    }
48
49    fn name(&self) -> &'static str {
50        "single-trailing-newline"
51    }
52
53    fn description(&self) -> &'static str {
54        "Files should end with a single newline character"
55    }
56
57    fn metadata(&self) -> RuleMetadata {
58        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
59    }
60
61    fn check_with_ast<'a>(
62        &self,
63        document: &Document,
64        _ast: Option<&'a comrak::nodes::AstNode<'a>>,
65    ) -> Result<Vec<Violation>> {
66        let mut violations = Vec::new();
67
68        if let Some(message) = self.check_file_ending(&document.content) {
69            let line_count = document.lines.len();
70            let line_number = if line_count == 0 { 1 } else { line_count };
71
72            violations.push(self.create_violation(message, line_number, 1, Severity::Warning));
73        }
74
75        Ok(violations)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::rule::Rule;
83    use std::path::PathBuf;
84
85    fn create_test_document(content: &str) -> Document {
86        Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
87    }
88
89    #[test]
90    fn test_md047_single_newline_valid() {
91        let content = "# Heading\n\nSome content here.\n";
92        let document = create_test_document(content);
93        let rule = MD047;
94        let violations = rule.check(&document).unwrap();
95
96        assert_eq!(violations.len(), 0);
97    }
98
99    #[test]
100    fn test_md047_no_newline_invalid() {
101        let content = "# Heading\n\nSome content here.";
102        let document = create_test_document(content);
103        let rule = MD047;
104        let violations = rule.check(&document).unwrap();
105
106        assert_eq!(violations.len(), 1);
107        assert_eq!(violations[0].rule_id, "MD047");
108        assert!(
109            violations[0]
110                .message
111                .contains("File should end with a single newline character")
112        );
113    }
114
115    #[test]
116    fn test_md047_multiple_newlines_invalid() {
117        let content = "# Heading\n\nSome content here.\n\n";
118        let document = create_test_document(content);
119        let rule = MD047;
120        let violations = rule.check(&document).unwrap();
121
122        assert_eq!(violations.len(), 1);
123        assert_eq!(violations[0].rule_id, "MD047");
124        assert!(violations[0].message.contains("found 2 trailing newlines"));
125    }
126
127    #[test]
128    fn test_md047_three_newlines_invalid() {
129        let content = "# Heading\n\nSome content here.\n\n\n";
130        let document = create_test_document(content);
131        let rule = MD047;
132        let violations = rule.check(&document).unwrap();
133
134        assert_eq!(violations.len(), 1);
135        assert!(violations[0].message.contains("found 3 trailing newlines"));
136    }
137
138    #[test]
139    fn test_md047_empty_file_invalid() {
140        let content = "";
141        let document = create_test_document(content);
142        let rule = MD047;
143        let violations = rule.check(&document).unwrap();
144
145        assert_eq!(violations.len(), 1);
146        assert!(
147            violations[0]
148                .message
149                .contains("File should end with a single newline character")
150        );
151    }
152
153    #[test]
154    fn test_md047_only_newline_valid() {
155        let content = "\n";
156        let document = create_test_document(content);
157        let rule = MD047;
158        let violations = rule.check(&document).unwrap();
159
160        assert_eq!(violations.len(), 0);
161    }
162
163    #[test]
164    fn test_md047_only_multiple_newlines_invalid() {
165        let content = "\n\n";
166        let document = create_test_document(content);
167        let rule = MD047;
168        let violations = rule.check(&document).unwrap();
169
170        assert_eq!(violations.len(), 1);
171        assert!(violations[0].message.contains("found 2 trailing newlines"));
172    }
173
174    #[test]
175    fn test_md047_content_with_final_newline_valid() {
176        let content = "Line 1\nLine 2\nLine 3\n";
177        let document = create_test_document(content);
178        let rule = MD047;
179        let violations = rule.check(&document).unwrap();
180
181        assert_eq!(violations.len(), 0);
182    }
183
184    #[test]
185    fn test_md047_content_without_final_newline_invalid() {
186        let content = "Line 1\nLine 2\nLine 3";
187        let document = create_test_document(content);
188        let rule = MD047;
189        let violations = rule.check(&document).unwrap();
190
191        assert_eq!(violations.len(), 1);
192        assert_eq!(violations[0].line, 3); // Should report on last line
193    }
194
195    #[test]
196    fn test_md047_mixed_line_endings_with_newline_valid() {
197        let content = "# Title\r\n\r\nContent here.\n";
198        let document = create_test_document(content);
199        let rule = MD047;
200        let violations = rule.check(&document).unwrap();
201
202        assert_eq!(violations.len(), 0);
203    }
204
205    #[test]
206    fn test_md047_single_line_with_newline_valid() {
207        let content = "Single line\n";
208        let document = create_test_document(content);
209        let rule = MD047;
210        let violations = rule.check(&document).unwrap();
211
212        assert_eq!(violations.len(), 0);
213    }
214
215    #[test]
216    fn test_md047_single_line_without_newline_invalid() {
217        let content = "Single line";
218        let document = create_test_document(content);
219        let rule = MD047;
220        let violations = rule.check(&document).unwrap();
221
222        assert_eq!(violations.len(), 1);
223        assert_eq!(violations[0].line, 1);
224    }
225}