quickmark_core/rules/
md047.rs1use std::rc::Rc;
2
3use tree_sitter::Node;
4
5use crate::{
6    linter::{range_from_tree_sitter, RuleViolation},
7    rules::{Context, Rule, RuleLinter, RuleType},
8};
9
10pub(crate) struct MD047Linter {
16    context: Rc<Context>,
17    violations: Vec<RuleViolation>,
18}
19
20impl MD047Linter {
21    pub fn new(context: Rc<Context>) -> Self {
22        Self {
23            context,
24            violations: Vec::new(),
25        }
26    }
27
28    fn analyze_last_line(&mut self) {
30        let lines = self.context.lines.borrow();
31
32        if lines.is_empty() {
33            return;
34        }
35
36        let last_line_index = lines.len() - 1;
37        let last_line = &lines[last_line_index];
38
39        if !self.is_blank_line(last_line) {
40            let violation = self.create_violation(last_line_index, last_line);
41            self.violations.push(violation);
42        }
43    }
44
45    fn is_blank_line(&self, mut line: &str) -> bool {
50        loop {
51            line = line.trim_start(); if line.is_empty() {
54                return true;
55            }
56
57            if line.starts_with('>') {
58                line = &line[1..];
59                continue;
60            }
61
62            if line.starts_with("<!--") {
63                if let Some(end_index) = line.find("-->") {
64                    line = &line[end_index + 3..];
65                    continue;
66                }
67                return true;
69            }
70
71            return false;
73        }
74    }
75
76    fn create_violation(&self, line_index: usize, line: &str) -> RuleViolation {
78        RuleViolation::new(
79            &MD047,
80            MD047.description.to_string(),
81            self.context.file_path.clone(),
82            range_from_tree_sitter(&tree_sitter::Range {
83                start_byte: 0,
84                end_byte: line.len(),
85                start_point: tree_sitter::Point {
86                    row: line_index,
87                    column: line.len(),
88                },
89                end_point: tree_sitter::Point {
90                    row: line_index,
91                    column: line.len() + 1,
92                },
93            }),
94        )
95    }
96}
97
98impl RuleLinter for MD047Linter {
99    fn feed(&mut self, node: &Node) {
100        if node.kind() == "document" {
103            self.analyze_last_line();
104        }
105    }
106
107    fn finalize(&mut self) -> Vec<RuleViolation> {
108        std::mem::take(&mut self.violations)
109    }
110}
111
112pub const MD047: Rule = Rule {
113    id: "MD047",
114    alias: "single-trailing-newline",
115    tags: &["blank_lines"],
116    description: "Files should end with a single newline character",
117    rule_type: RuleType::Line,
118    required_nodes: &[], new_linter: |context| Box::new(MD047Linter::new(context)),
120};
121
122#[cfg(test)]
123mod test {
124    use std::path::PathBuf;
125
126    use crate::config::RuleSeverity;
127    use crate::linter::MultiRuleLinter;
128    use crate::test_utils::test_helpers::test_config_with_rules;
129
130    fn test_config() -> crate::config::QuickmarkConfig {
131        test_config_with_rules(vec![
132            ("single-trailing-newline", RuleSeverity::Error),
133            ("heading-style", RuleSeverity::Off),
134            ("heading-increment", RuleSeverity::Off),
135        ])
136    }
137
138    #[test]
139    fn test_file_without_trailing_newline() {
140        let input = "This file does not end with a newline";
141
142        let config = test_config();
143        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
144        let violations = linter.analyze();
145        assert_eq!(1, violations.len());
146
147        let violation = &violations[0];
148        assert_eq!("MD047", violation.rule().id);
149        assert_eq!(
150            "Files should end with a single newline character",
151            violation.message()
152        );
153    }
154
155    #[test]
156    fn test_file_with_trailing_newline() {
157        let input = "This file ends with a newline\n";
158
159        let config = test_config();
160        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
161        let violations = linter.analyze();
162        assert_eq!(0, violations.len());
163    }
164
165    #[test]
166    fn test_file_with_multiple_trailing_newlines() {
167        let input = "This file has multiple newlines\n\n";
168
169        let config = test_config();
170        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
171        let violations = linter.analyze();
172        assert_eq!(0, violations.len()); }
174
175    #[test]
176    fn test_empty_file() {
177        let input = "";
178
179        let config = test_config();
180        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
181        let violations = linter.analyze();
182        assert_eq!(0, violations.len()); }
184
185    #[test]
186    fn test_file_with_only_newline() {
187        let input = "\n";
188
189        let config = test_config();
190        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
191        let violations = linter.analyze();
192        assert_eq!(0, violations.len()); }
194
195    #[test]
196    fn test_file_with_whitespace_last_line() {
197        let input = "Content\n   \n";
198
199        let config = test_config();
200        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
201        let violations = linter.analyze();
202        assert_eq!(0, violations.len()); }
204
205    #[test]
206    fn test_file_ending_with_html_comment() {
207        let input = "Content\n<!-- This is a comment -->\n";
208
209        let config = test_config();
210        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
211        let violations = linter.analyze();
212        assert_eq!(0, violations.len()); }
214
215    #[test]
216    fn test_file_ending_with_html_comment_no_newline() {
217        let input = "Content\n<!-- This is a comment -->";
218
219        let config = test_config();
220        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
221        let violations = linter.analyze();
222        assert_eq!(0, violations.len()); }
224
225    #[test]
226    fn test_file_ending_with_blockquote_markers() {
227        let input = "Content\n>>>\n";
228
229        let config = test_config();
230        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
231        let violations = linter.analyze();
232        assert_eq!(0, violations.len()); }
234
235    #[test]
236    fn test_file_ending_with_blockquote_markers_no_newline() {
237        let input = "Content\n>>>";
238
239        let config = test_config();
240        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
241        let violations = linter.analyze();
242        assert_eq!(0, violations.len()); }
244
245    #[test]
246    fn test_file_ending_with_mixed_comments_and_blockquotes() {
247        let input = "Content\n<!-- comment -->>\n";
248
249        let config = test_config();
250        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
251        let violations = linter.analyze();
252        assert_eq!(0, violations.len()); }
254
255    #[test]
256    fn test_multiple_lines_last_without_newline() {
257        let input = "Line 1\nLine 2\nLast line without newline";
258
259        let config = test_config();
260        let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
261        let violations = linter.analyze();
262        assert_eq!(1, violations.len());
263
264        let violation = &violations[0];
265        assert_eq!("MD047", violation.rule().id);
266        assert_eq!(2, violation.location().range.start.line); }
269}