quickmark_core/rules/
md047.rs

1use 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
10/// MD047 Single Trailing Newline Rule Linter
11///
12/// **SINGLE-USE CONTRACT**: This linter is designed for one-time use only.
13/// After processing a document (via feed() calls and finalize()), the linter
14/// should be discarded. The violations state is not cleared between uses.
15pub(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    /// Analyze the last line to check if file ends with newline
29    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    /// Check if a line is "blank" according to markdownlint's logic.
46    /// A line is blank if it's empty or consists of only whitespace,
47    /// blockquote markers (`>`), or HTML comments (`<!-- ... -->`).
48    /// This implementation is optimized to avoid string allocations.
49    fn is_blank_line(&self, mut line: &str) -> bool {
50        loop {
51            line = line.trim_start(); // Skips leading whitespace
52
53            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                // Unmatched "<!--" means the rest of the line is a comment
68                return true;
69            }
70
71            // Anything else is considered content
72            return false;
73        }
74    }
75
76    /// Create a violation for a line that doesn't end with newline
77    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        // This rule is line-based and only needs to run once.
101        // We trigger the analysis on seeing the top-level `document` node.
102        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: &[], // Line-based rules don't require specific nodes
119    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()); // Should not violate - ends with newline
173    }
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()); // Empty file shouldn't violate
183    }
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()); // Single newline should not violate
193    }
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()); // Whitespace-only last line should not violate
203    }
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()); // HTML comment on last line should not violate if ends with newline
213    }
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()); // HTML comment only should be considered blank
223    }
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()); // Blockquote markers only should not violate
233    }
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()); // Blockquote markers only should be considered blank
243    }
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()); // Mixed comments and blockquotes should not violate
253    }
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        // Should point to the end of the last line
267        assert_eq!(2, violation.location().range.start.line); // 0-indexed, so line 2 = third line
268    }
269}