rumdl_lib/rules/
md028_no_blanks_blockquote.rs

1/// Rule MD028: No blank lines inside blockquotes
2///
3/// See [docs/md028.md](../../docs/md028.md) for full documentation, configuration, and examples.
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
6use crate::utils::range_utils::{LineIndex, calculate_line_range};
7
8#[derive(Clone)]
9pub struct MD028NoBlanksBlockquote;
10
11impl MD028NoBlanksBlockquote {
12    /// Generates the replacement for a blank blockquote line
13    fn get_replacement(indent: &str, level: usize) -> String {
14        let mut result = indent.to_string();
15
16        // For nested blockquotes: ">>" or ">" based on level
17        for _ in 0..level {
18            result.push('>');
19        }
20        // Add a single space after the marker for proper blockquote formatting
21        result.push(' ');
22
23        result
24    }
25}
26
27impl Default for MD028NoBlanksBlockquote {
28    fn default() -> Self {
29        Self
30    }
31}
32
33impl Rule for MD028NoBlanksBlockquote {
34    fn name(&self) -> &'static str {
35        "MD028"
36    }
37
38    fn description(&self) -> &'static str {
39        "Blank line inside blockquote"
40    }
41
42    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
43        Some(self)
44    }
45
46    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
47        // Early return for content without blockquotes
48        if !ctx.content.contains('>') {
49            return Ok(Vec::new());
50        }
51
52        let line_index = LineIndex::new(ctx.content.to_string());
53        let mut warnings = Vec::new();
54
55        // Process all lines using cached blockquote information
56        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
57            let line_num = line_idx + 1;
58
59            // Skip lines in code blocks
60            if line_info.in_code_block {
61                continue;
62            }
63
64            // Check if this is a blockquote that needs MD028 fix
65            if let Some(blockquote) = &line_info.blockquote
66                && blockquote.needs_md028_fix
67            {
68                // Calculate precise character range for the entire empty blockquote line
69                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, &line_info.content);
70
71                warnings.push(LintWarning {
72                    rule_name: Some(self.name()),
73                    message: "Empty blockquote line should contain '>' marker".to_string(),
74                    line: start_line,
75                    column: start_col,
76                    end_line,
77                    end_column: end_col,
78                    severity: Severity::Warning,
79                    fix: Some(Fix {
80                        range: line_index.line_col_to_byte_range_with_length(line_num, 1, line_info.content.len()),
81                        replacement: Self::get_replacement(&blockquote.indent, blockquote.nesting_level),
82                    }),
83                });
84            }
85        }
86
87        Ok(warnings)
88    }
89
90    /// Optimized check using document structure
91    fn check_with_structure(
92        &self,
93        ctx: &crate::lint_context::LintContext,
94        _structure: &DocumentStructure,
95    ) -> LintResult {
96        // Just delegate to the main check method since it now uses cached data
97        self.check(ctx)
98    }
99
100    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
101        let mut result = Vec::with_capacity(ctx.lines.len());
102
103        for line_info in &ctx.lines {
104            if let Some(blockquote) = &line_info.blockquote {
105                if blockquote.needs_md028_fix {
106                    let replacement = Self::get_replacement(&blockquote.indent, blockquote.nesting_level);
107                    result.push(replacement);
108                } else {
109                    result.push(line_info.content.clone());
110                }
111            } else {
112                result.push(line_info.content.clone());
113            }
114        }
115
116        Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
117    }
118
119    /// Get the category of this rule for selective processing
120    fn category(&self) -> RuleCategory {
121        RuleCategory::Blockquote
122    }
123
124    /// Check if this rule should be skipped
125    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
126        !ctx.content.contains('>')
127    }
128
129    fn as_any(&self) -> &dyn std::any::Any {
130        self
131    }
132
133    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
134    where
135        Self: Sized,
136    {
137        Box::new(MD028NoBlanksBlockquote)
138    }
139}
140
141impl DocumentStructureExtensions for MD028NoBlanksBlockquote {
142    fn has_relevant_elements(
143        &self,
144        _ctx: &crate::lint_context::LintContext,
145        doc_structure: &DocumentStructure,
146    ) -> bool {
147        !doc_structure.blockquotes.is_empty()
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::lint_context::LintContext;
155
156    #[test]
157    fn test_no_blockquotes() {
158        let rule = MD028NoBlanksBlockquote;
159        let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
160        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
161        let result = rule.check(&ctx).unwrap();
162        assert!(result.is_empty(), "Should not flag content without blockquotes");
163    }
164
165    #[test]
166    fn test_valid_blockquote_no_blanks() {
167        let rule = MD028NoBlanksBlockquote;
168        let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
169        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
170        let result = rule.check(&ctx).unwrap();
171        assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
172    }
173
174    #[test]
175    fn test_blank_line_in_blockquote() {
176        let rule = MD028NoBlanksBlockquote;
177        let content = "> First line\n>\n> Third line";
178        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
179        let result = rule.check(&ctx).unwrap();
180        assert_eq!(result.len(), 1);
181        assert_eq!(result[0].line, 2);
182        assert!(result[0].message.contains("Empty blockquote line"));
183    }
184
185    #[test]
186    fn test_multiple_blank_lines() {
187        let rule = MD028NoBlanksBlockquote;
188        let content = "> First\n>\n>\n> Fourth";
189        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
190        let result = rule.check(&ctx).unwrap();
191        assert_eq!(result.len(), 2, "Should flag each blank line separately");
192        assert_eq!(result[0].line, 2);
193        assert_eq!(result[1].line, 3);
194    }
195
196    #[test]
197    fn test_nested_blockquote_blank() {
198        let rule = MD028NoBlanksBlockquote;
199        let content = ">> Nested quote\n>>\n>> More nested";
200        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
201        let result = rule.check(&ctx).unwrap();
202        assert_eq!(result.len(), 1);
203        assert_eq!(result[0].line, 2);
204    }
205
206    #[test]
207    fn test_fix_single_blank() {
208        let rule = MD028NoBlanksBlockquote;
209        let content = "> First\n>\n> Third";
210        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
211        let fixed = rule.fix(&ctx).unwrap();
212        assert_eq!(fixed, "> First\n> \n> Third");
213    }
214
215    #[test]
216    fn test_fix_nested_blank() {
217        let rule = MD028NoBlanksBlockquote;
218        let content = ">> Nested\n>>\n>> More";
219        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
220        let fixed = rule.fix(&ctx).unwrap();
221        assert_eq!(fixed, ">> Nested\n>> \n>> More");
222    }
223
224    #[test]
225    fn test_fix_with_indentation() {
226        let rule = MD028NoBlanksBlockquote;
227        let content = "  > Indented quote\n  >\n  > More";
228        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
229        let fixed = rule.fix(&ctx).unwrap();
230        assert_eq!(fixed, "  > Indented quote\n  > \n  > More");
231    }
232
233    #[test]
234    fn test_mixed_levels() {
235        let rule = MD028NoBlanksBlockquote;
236        let content = "> Level 1\n>\n>> Level 2\n>>\n> Level 1 again";
237        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
238        let result = rule.check(&ctx).unwrap();
239        assert_eq!(result.len(), 2);
240        assert_eq!(result[0].line, 2);
241        assert_eq!(result[1].line, 4);
242    }
243
244    #[test]
245    fn test_blockquote_with_code_block() {
246        let rule = MD028NoBlanksBlockquote;
247        let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
248        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
249        let result = rule.check(&ctx).unwrap();
250        assert_eq!(result.len(), 1);
251        assert_eq!(result[0].line, 5);
252    }
253
254    #[test]
255    fn test_category() {
256        let rule = MD028NoBlanksBlockquote;
257        assert_eq!(rule.category(), RuleCategory::Blockquote);
258    }
259
260    #[test]
261    fn test_should_skip() {
262        let rule = MD028NoBlanksBlockquote;
263        let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard);
264        assert!(rule.should_skip(&ctx1));
265
266        let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard);
267        assert!(!rule.should_skip(&ctx2));
268    }
269
270    #[test]
271    fn test_get_replacement() {
272        assert_eq!(MD028NoBlanksBlockquote::get_replacement("", 1), "> ");
273        assert_eq!(MD028NoBlanksBlockquote::get_replacement("  ", 1), "  > ");
274        assert_eq!(MD028NoBlanksBlockquote::get_replacement("", 2), ">> ");
275        assert_eq!(MD028NoBlanksBlockquote::get_replacement("  ", 3), "  >>> ");
276    }
277
278    #[test]
279    fn test_empty_content() {
280        let rule = MD028NoBlanksBlockquote;
281        let content = "";
282        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
283        let result = rule.check(&ctx).unwrap();
284        assert!(result.is_empty());
285    }
286
287    #[test]
288    fn test_blank_after_blockquote() {
289        let rule = MD028NoBlanksBlockquote;
290        let content = "> Quote\n\nNot a quote";
291        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
292        let result = rule.check(&ctx).unwrap();
293        assert!(result.is_empty(), "Blank line after blockquote is valid");
294    }
295
296    #[test]
297    fn test_preserve_trailing_newline() {
298        let rule = MD028NoBlanksBlockquote;
299        let content = "> Quote\n>\n> More\n";
300        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
301        let fixed = rule.fix(&ctx).unwrap();
302        assert!(fixed.ends_with('\n'));
303
304        let content_no_newline = "> Quote\n>\n> More";
305        let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard);
306        let fixed2 = rule.fix(&ctx2).unwrap();
307        assert!(!fixed2.ends_with('\n'));
308    }
309
310    #[test]
311    fn test_document_structure_extension() {
312        let rule = MD028NoBlanksBlockquote;
313        let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard);
314        let doc_structure = DocumentStructure::new("> test");
315        assert!(rule.has_relevant_elements(&ctx, &doc_structure));
316
317        let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard);
318        let doc_structure2 = DocumentStructure::new("no blockquote");
319        assert!(!rule.has_relevant_elements(&ctx2, &doc_structure2));
320    }
321
322    #[test]
323    fn test_deeply_nested_blank() {
324        let rule = MD028NoBlanksBlockquote;
325        let content = ">>> Deep nest\n>>>\n>>> More deep";
326        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
327        let result = rule.check(&ctx).unwrap();
328        assert_eq!(result.len(), 1);
329
330        let fixed = rule.fix(&ctx).unwrap();
331        assert_eq!(fixed, ">>> Deep nest\n>>> \n>>> More deep");
332    }
333
334    #[test]
335    fn test_complex_blockquote_structure() {
336        let rule = MD028NoBlanksBlockquote;
337        let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
339        let result = rule.check(&ctx).unwrap();
340        assert_eq!(result.len(), 1);
341        assert_eq!(result[0].line, 3);
342    }
343}