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}