quickmark_core/rules/
md028.rs1use std::collections::HashSet;
2use std::rc::Rc;
3
4use tree_sitter::Node;
5
6use crate::{
7 linter::{range_from_tree_sitter, RuleViolation},
8 rules::{Context, Rule, RuleLinter, RuleType},
9};
10
11pub(crate) struct MD028Linter {
17 context: Rc<Context>,
18 violations: Vec<RuleViolation>,
19}
20
21impl MD028Linter {
22 pub fn new(context: Rc<Context>) -> Self {
23 Self {
24 context,
25 violations: Vec::new(),
26 }
27 }
28
29 fn analyze_all_lines(&mut self) {
30 let code_block_lines = self.get_code_block_lines();
31 let lines = self.context.lines.borrow();
32
33 let mut last_line_was_blockquote = false;
34 let mut blank_line_sequence_start: Option<usize> = None;
35
36 for (i, line) in lines.iter().enumerate() {
37 if code_block_lines.contains(&(i + 1)) {
38 last_line_was_blockquote = false;
39 blank_line_sequence_start = None;
40 continue;
41 }
42
43 if self.is_blockquote_line(line) {
44 if let Some(blank_idx) = blank_line_sequence_start {
45 self.violations.push(RuleViolation::new(
46 &MD028,
47 "Blank line inside blockquote".to_string(),
48 self.context.file_path.clone(),
49 range_from_tree_sitter(&tree_sitter::Range {
50 start_byte: 0,
51 end_byte: 0,
52 start_point: tree_sitter::Point {
53 row: blank_idx,
54 column: 0,
55 },
56 end_point: tree_sitter::Point {
57 row: blank_idx,
58 column: lines[blank_idx].len(),
59 },
60 }),
61 ));
62 }
63 last_line_was_blockquote = true;
64 blank_line_sequence_start = None;
65 } else if self.is_blank_line(line) {
66 if last_line_was_blockquote && blank_line_sequence_start.is_none() {
67 blank_line_sequence_start = Some(i);
68 }
69 } else {
70 last_line_was_blockquote = false;
71 blank_line_sequence_start = None;
72 }
73 }
74 }
75
76 fn get_code_block_lines(&self) -> HashSet<usize> {
77 let node_cache = self.context.node_cache.borrow();
78 let mut code_block_lines = HashSet::new();
79 let node_types = ["indented_code_block", "fenced_code_block", "html_block"];
80 for node_type in &node_types {
81 if let Some(nodes) = node_cache.get(*node_type) {
82 for node_info in nodes {
83 code_block_lines.extend((node_info.line_start + 1)..=(node_info.line_end + 1));
84 }
85 }
86 }
87 code_block_lines
88 }
89
90 fn is_blockquote_line(&self, line: &str) -> bool {
91 line.trim_start().starts_with('>')
92 }
93
94 fn is_blank_line(&self, line: &str) -> bool {
95 line.trim().is_empty()
96 }
97}
98
99impl RuleLinter for MD028Linter {
100 fn feed(&mut self, node: &Node) {
101 if node.kind() == "document" {
102 self.analyze_all_lines();
103 }
104 }
105
106 fn finalize(&mut self) -> Vec<RuleViolation> {
107 std::mem::take(&mut self.violations)
108 }
109}
110
111pub const MD028: Rule = Rule {
112 id: "MD028",
113 alias: "no-blanks-blockquote",
114 tags: &["blockquote", "whitespace"],
115 description: "Blank lines inside blockquotes",
116 rule_type: RuleType::Hybrid,
117 required_nodes: &[
118 "document",
119 "indented_code_block",
120 "fenced_code_block",
121 "html_block",
122 ],
123 new_linter: |context| Box::new(MD028Linter::new(context)),
124};
125
126#[cfg(test)]
127mod tests {
128 use crate::config::RuleSeverity;
129 use crate::linter::MultiRuleLinter;
130 use crate::test_utils::test_helpers::test_config_with_rules;
131 use std::path::PathBuf;
132
133 fn test_config() -> crate::config::QuickmarkConfig {
134 test_config_with_rules(vec![
135 ("no-blanks-blockquote", RuleSeverity::Error),
136 ("heading-style", RuleSeverity::Off),
137 ("heading-increment", RuleSeverity::Off),
138 ])
139 }
140
141 #[test]
142 fn test_md028_violation_basic() {
143 let input = r#"> First blockquote
144
145> Second blockquote"#;
146
147 let config = test_config();
148 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
149 let violations = linter.analyze();
150
151 assert!(
153 !violations.is_empty(),
154 "Should detect blank line inside blockquote"
155 );
156 }
157
158 #[test]
159 fn test_md028_valid_continuous_blockquote() {
160 let input = r#"> First line
161> Second line"#;
162
163 let config = test_config();
164 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
165 let violations = linter.analyze();
166
167 assert!(
169 violations.is_empty(),
170 "Should not violate for continuous blockquote"
171 );
172 }
173
174 #[test]
175 fn test_md028_valid_separated_with_content() {
176 let input = r#"> First blockquote
177
178Some text here.
179
180> Second blockquote"#;
181
182 let config = test_config();
183 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
184 let violations = linter.analyze();
185
186 assert!(
188 violations.is_empty(),
189 "Should not violate when blockquotes are separated with content"
190 );
191 }
192
193 #[test]
194 fn test_md028_valid_continuous_with_blank_line_marker() {
195 let input = r#"> First line
196>
197> Second line"#;
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
203 assert!(
205 violations.is_empty(),
206 "Should not violate when blank line has blockquote marker"
207 );
208 }
209
210 #[test]
211 fn test_md028_violation_multiple_blank_lines() {
212 let input = r#"> First blockquote
213
214
215> Second blockquote"#;
216
217 let config = test_config();
218 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
219 let violations = linter.analyze();
220
221 assert!(
223 !violations.is_empty(),
224 "Should detect multiple blank lines inside blockquote"
225 );
226 }
227
228 #[test]
229 fn test_md028_violation_nested_blockquotes() {
230 let input = r#"> First level
231> > Second level
232
233> > Another second level"#;
234
235 let config = test_config();
236 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
237 let violations = linter.analyze();
238
239 assert!(
241 !violations.is_empty(),
242 "Should detect blank lines in nested blockquotes"
243 );
244 }
245}