quickmark_core/rules/
md028.rs

1use 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
11/// MD028 Blank lines inside blockquote Rule Linter
12///
13/// **SINGLE-USE CONTRACT**: This linter is designed for one-time use only.
14/// After processing a document (via feed() calls and finalize()), the linter
15/// should be discarded. The violations state is not cleared between uses.
16pub(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        // This test should fail initially (TDD approach)
152        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        // This should not violate - continuous blockquote
168        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        // This should not violate - properly separated with content
187        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        // This should not violate - blank line with blockquote marker
204        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        // This should violate - multiple blank lines between blockquotes
222        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        // This should violate - blank line in nested blockquotes
240        assert!(
241            !violations.is_empty(),
242            "Should detect blank lines in nested blockquotes"
243        );
244    }
245}