rumdl_lib/rules/
md028_no_blanks_blockquote.rs

1/// Rule MD028: No blank lines inside blockquotes
2///
3/// This rule flags blank lines that appear to be inside a blockquote but lack the > marker.
4/// It uses heuristics to distinguish between paragraph breaks within a blockquote
5/// and intentional separators between distinct blockquotes.
6/// See [docs/md028.md](../../docs/md028.md) for full documentation, configuration, and examples.
7use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
8use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
9use crate::utils::range_utils::{LineIndex, calculate_line_range};
10
11#[derive(Clone)]
12pub struct MD028NoBlanksBlockquote;
13
14impl MD028NoBlanksBlockquote {
15    /// Check if a line is a blockquote line (has > markers)
16    fn is_blockquote_line(line: &str) -> bool {
17        line.trim_start().starts_with('>')
18    }
19
20    /// Get the blockquote level (number of > markers)
21    fn get_blockquote_level(line: &str) -> usize {
22        let trimmed = line.trim_start();
23        let mut level = 0;
24        let chars = trimmed.chars();
25
26        for ch in chars {
27            if ch == '>' {
28                level += 1;
29            } else if ch != ' ' && ch != '\t' {
30                break;
31            }
32        }
33
34        level
35    }
36
37    /// Get the leading whitespace before the first >
38    fn get_leading_whitespace(line: &str) -> &str {
39        let trimmed_len = line.trim_start().len();
40        let total_len = line.len();
41        &line[..total_len - trimmed_len]
42    }
43
44    /// Check if there's substantive content between two blockquote sections
45    /// This helps distinguish between paragraph breaks and separate blockquotes
46    fn has_content_between(lines: &[&str], start: usize, end: usize) -> bool {
47        for line in lines.iter().take(end).skip(start) {
48            let trimmed = line.trim();
49            // If there's any non-blank, non-blockquote content, these are separate quotes
50            if !trimmed.is_empty() && !trimmed.starts_with('>') {
51                return true;
52            }
53        }
54        false
55    }
56
57    /// Analyze context to determine if quotes are likely the same or different
58    fn are_likely_same_blockquote(lines: &[&str], blank_idx: usize) -> bool {
59        // Look for patterns that suggest these are the same blockquote:
60        // 1. Only one blank line between them (multiple blanks suggest separation)
61        // 2. Same indentation level
62        // 3. No content between them
63        // 4. Similar blockquote levels
64
65        // Note: We flag ALL blank lines between blockquotes, matching markdownlint behavior.
66        // Even multiple consecutive blank lines are flagged as they can be ambiguous
67        // (some parsers treat them as one blockquote, others as separate blockquotes).
68
69        // Find previous and next blockquote lines
70        let mut prev_quote_idx = None;
71        let mut next_quote_idx = None;
72
73        for i in (0..blank_idx).rev() {
74            if Self::is_blockquote_line(lines[i]) {
75                prev_quote_idx = Some(i);
76                break;
77            }
78        }
79
80        for (i, line) in lines.iter().enumerate().skip(blank_idx + 1) {
81            if Self::is_blockquote_line(line) {
82                next_quote_idx = Some(i);
83                break;
84            }
85        }
86
87        let (prev_idx, next_idx) = match (prev_quote_idx, next_quote_idx) {
88            (Some(p), Some(n)) => (p, n),
89            _ => return false,
90        };
91
92        // Check for content between blockquotes
93        if Self::has_content_between(lines, prev_idx + 1, next_idx) {
94            return false;
95        }
96
97        // Check if levels match
98        let prev_level = Self::get_blockquote_level(lines[prev_idx]);
99        let next_level = Self::get_blockquote_level(lines[next_idx]);
100
101        // Different levels suggest different contexts
102        // But next_level > prev_level could be nested continuation
103        if next_level < prev_level {
104            return false;
105        }
106
107        // Check indentation consistency
108        let prev_indent = Self::get_leading_whitespace(lines[prev_idx]);
109        let next_indent = Self::get_leading_whitespace(lines[next_idx]);
110
111        // Different indentation indicates separate blockquote contexts
112        // Same indentation with no content between = same blockquote (blank line inside)
113        prev_indent == next_indent
114    }
115
116    /// Check if a blank line is problematic (inside a blockquote)
117    fn is_problematic_blank_line(lines: &[&str], index: usize) -> Option<(usize, String)> {
118        let current_line = lines[index];
119
120        // Must be a blank line (no content, no > markers)
121        if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
122            return None;
123        }
124
125        // Use heuristics to determine if this blank line is inside a blockquote
126        // or if it's an intentional separator between blockquotes
127        if !Self::are_likely_same_blockquote(lines, index) {
128            return None;
129        }
130
131        // This blank line appears to be inside a blockquote
132        // Find the appropriate fix
133        for i in (0..index).rev() {
134            if Self::is_blockquote_line(lines[i]) {
135                let level = Self::get_blockquote_level(lines[i]);
136                let indent = Self::get_leading_whitespace(lines[i]);
137                let mut fix = indent.to_string();
138                for _ in 0..level {
139                    fix.push('>');
140                }
141                return Some((level, fix));
142            }
143        }
144
145        None
146    }
147}
148
149impl Default for MD028NoBlanksBlockquote {
150    fn default() -> Self {
151        Self
152    }
153}
154
155impl Rule for MD028NoBlanksBlockquote {
156    fn name(&self) -> &'static str {
157        "MD028"
158    }
159
160    fn description(&self) -> &'static str {
161        "Blank line inside blockquote"
162    }
163
164    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
165        Some(self)
166    }
167
168    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
169        // Early return for content without blockquotes
170        if !ctx.content.contains('>') {
171            return Ok(Vec::new());
172        }
173
174        let line_index = LineIndex::new(ctx.content.to_string());
175        let mut warnings = Vec::new();
176
177        // Get all lines
178        let lines: Vec<&str> = ctx.content.lines().collect();
179
180        // Check each line
181        for (line_idx, line) in lines.iter().enumerate() {
182            let line_num = line_idx + 1;
183
184            // Skip lines in code blocks
185            if line_idx < ctx.lines.len() && ctx.lines[line_idx].in_code_block {
186                continue;
187            }
188
189            // Check if this is a problematic blank line inside a blockquote
190            if let Some((level, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
191                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
192
193                warnings.push(LintWarning {
194                    rule_name: Some(self.name()),
195                    message: format!("Blank line inside blockquote (level {level})"),
196                    line: start_line,
197                    column: start_col,
198                    end_line,
199                    end_column: end_col,
200                    severity: Severity::Warning,
201                    fix: Some(Fix {
202                        range: line_index.line_col_to_byte_range_with_length(line_num, 1, line.len()),
203                        replacement: fix_content,
204                    }),
205                });
206            }
207        }
208
209        Ok(warnings)
210    }
211
212    /// Optimized check using document structure
213    fn check_with_structure(
214        &self,
215        ctx: &crate::lint_context::LintContext,
216        _structure: &DocumentStructure,
217    ) -> LintResult {
218        // Just delegate to the main check method
219        self.check(ctx)
220    }
221
222    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
223        let mut result = Vec::with_capacity(ctx.lines.len());
224        let lines: Vec<&str> = ctx.content.lines().collect();
225
226        for (line_idx, line) in lines.iter().enumerate() {
227            // Check if this blank line needs fixing
228            if let Some((_, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
229                result.push(fix_content);
230            } else {
231                result.push(line.to_string());
232            }
233        }
234
235        Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
236    }
237
238    /// Get the category of this rule for selective processing
239    fn category(&self) -> RuleCategory {
240        RuleCategory::Blockquote
241    }
242
243    /// Check if this rule should be skipped
244    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
245        !ctx.content.contains('>')
246    }
247
248    fn as_any(&self) -> &dyn std::any::Any {
249        self
250    }
251
252    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
253    where
254        Self: Sized,
255    {
256        Box::new(MD028NoBlanksBlockquote)
257    }
258}
259
260impl DocumentStructureExtensions for MD028NoBlanksBlockquote {
261    fn has_relevant_elements(
262        &self,
263        _ctx: &crate::lint_context::LintContext,
264        doc_structure: &DocumentStructure,
265    ) -> bool {
266        !doc_structure.blockquotes.is_empty()
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::lint_context::LintContext;
274
275    #[test]
276    fn test_no_blockquotes() {
277        let rule = MD028NoBlanksBlockquote;
278        let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
279        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
280        let result = rule.check(&ctx).unwrap();
281        assert!(result.is_empty(), "Should not flag content without blockquotes");
282    }
283
284    #[test]
285    fn test_valid_blockquote_no_blanks() {
286        let rule = MD028NoBlanksBlockquote;
287        let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
288        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
289        let result = rule.check(&ctx).unwrap();
290        assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
291    }
292
293    #[test]
294    fn test_blockquote_with_empty_line_marker() {
295        let rule = MD028NoBlanksBlockquote;
296        // Lines with just > are valid and should NOT be flagged
297        let content = "> First line\n>\n> Third line";
298        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
299        let result = rule.check(&ctx).unwrap();
300        assert!(result.is_empty(), "Should not flag lines with just > marker");
301    }
302
303    #[test]
304    fn test_blockquote_with_empty_line_marker_and_space() {
305        let rule = MD028NoBlanksBlockquote;
306        // Lines with > and space are also valid
307        let content = "> First line\n> \n> Third line";
308        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
309        let result = rule.check(&ctx).unwrap();
310        assert!(result.is_empty(), "Should not flag lines with > and space");
311    }
312
313    #[test]
314    fn test_blank_line_in_blockquote() {
315        let rule = MD028NoBlanksBlockquote;
316        // Truly blank line (no >) inside blockquote should be flagged
317        let content = "> First line\n\n> Third line";
318        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
319        let result = rule.check(&ctx).unwrap();
320        assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
321        assert_eq!(result[0].line, 2);
322        assert!(result[0].message.contains("Blank line inside blockquote"));
323    }
324
325    #[test]
326    fn test_multiple_blank_lines() {
327        let rule = MD028NoBlanksBlockquote;
328        let content = "> First\n\n\n> Fourth";
329        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
330        let result = rule.check(&ctx).unwrap();
331        // With proper indentation checking, both blank lines are flagged as they're within the same blockquote
332        assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
333        assert_eq!(result[0].line, 2);
334        assert_eq!(result[1].line, 3);
335    }
336
337    #[test]
338    fn test_nested_blockquote_blank() {
339        let rule = MD028NoBlanksBlockquote;
340        let content = ">> Nested quote\n\n>> More nested";
341        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
342        let result = rule.check(&ctx).unwrap();
343        assert_eq!(result.len(), 1);
344        assert_eq!(result[0].line, 2);
345    }
346
347    #[test]
348    fn test_nested_blockquote_with_marker() {
349        let rule = MD028NoBlanksBlockquote;
350        // Lines with >> are valid
351        let content = ">> Nested quote\n>>\n>> More nested";
352        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
353        let result = rule.check(&ctx).unwrap();
354        assert!(result.is_empty(), "Should not flag lines with >> marker");
355    }
356
357    #[test]
358    fn test_fix_single_blank() {
359        let rule = MD028NoBlanksBlockquote;
360        let content = "> First\n\n> Third";
361        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
362        let fixed = rule.fix(&ctx).unwrap();
363        assert_eq!(fixed, "> First\n>\n> Third");
364    }
365
366    #[test]
367    fn test_fix_nested_blank() {
368        let rule = MD028NoBlanksBlockquote;
369        let content = ">> Nested\n\n>> More";
370        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
371        let fixed = rule.fix(&ctx).unwrap();
372        assert_eq!(fixed, ">> Nested\n>>\n>> More");
373    }
374
375    #[test]
376    fn test_fix_with_indentation() {
377        let rule = MD028NoBlanksBlockquote;
378        let content = "  > Indented quote\n\n  > More";
379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
380        let fixed = rule.fix(&ctx).unwrap();
381        assert_eq!(fixed, "  > Indented quote\n  >\n  > More");
382    }
383
384    #[test]
385    fn test_mixed_levels() {
386        let rule = MD028NoBlanksBlockquote;
387        // Blank lines between different levels
388        let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
389        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
390        let result = rule.check(&ctx).unwrap();
391        // Line 2 is a blank between > and >>, level 1 to level 2, considered inside level 1
392        // Line 4 is a blank between >> and >, level 2 to level 1, NOT inside blockquote
393        assert_eq!(result.len(), 1);
394        assert_eq!(result[0].line, 2);
395    }
396
397    #[test]
398    fn test_blockquote_with_code_block() {
399        let rule = MD028NoBlanksBlockquote;
400        let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
401        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
402        let result = rule.check(&ctx).unwrap();
403        // Line 5 has > marker, so it's not a blank line
404        assert!(result.is_empty(), "Should not flag line with > marker");
405    }
406
407    #[test]
408    fn test_category() {
409        let rule = MD028NoBlanksBlockquote;
410        assert_eq!(rule.category(), RuleCategory::Blockquote);
411    }
412
413    #[test]
414    fn test_should_skip() {
415        let rule = MD028NoBlanksBlockquote;
416        let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard);
417        assert!(rule.should_skip(&ctx1));
418
419        let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard);
420        assert!(!rule.should_skip(&ctx2));
421    }
422
423    #[test]
424    fn test_empty_content() {
425        let rule = MD028NoBlanksBlockquote;
426        let content = "";
427        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428        let result = rule.check(&ctx).unwrap();
429        assert!(result.is_empty());
430    }
431
432    #[test]
433    fn test_blank_after_blockquote() {
434        let rule = MD028NoBlanksBlockquote;
435        let content = "> Quote\n\nNot a quote";
436        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
437        let result = rule.check(&ctx).unwrap();
438        assert!(result.is_empty(), "Blank line after blockquote ends is valid");
439    }
440
441    #[test]
442    fn test_blank_before_blockquote() {
443        let rule = MD028NoBlanksBlockquote;
444        let content = "Not a quote\n\n> Quote";
445        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
446        let result = rule.check(&ctx).unwrap();
447        assert!(result.is_empty(), "Blank line before blockquote starts is valid");
448    }
449
450    #[test]
451    fn test_preserve_trailing_newline() {
452        let rule = MD028NoBlanksBlockquote;
453        let content = "> Quote\n\n> More\n";
454        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
455        let fixed = rule.fix(&ctx).unwrap();
456        assert!(fixed.ends_with('\n'));
457
458        let content_no_newline = "> Quote\n\n> More";
459        let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard);
460        let fixed2 = rule.fix(&ctx2).unwrap();
461        assert!(!fixed2.ends_with('\n'));
462    }
463
464    #[test]
465    fn test_document_structure_extension() {
466        let rule = MD028NoBlanksBlockquote;
467        let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard);
468        let doc_structure = DocumentStructure::new("> test");
469        assert!(rule.has_relevant_elements(&ctx, &doc_structure));
470
471        let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard);
472        let doc_structure2 = DocumentStructure::new("no blockquote");
473        assert!(!rule.has_relevant_elements(&ctx2, &doc_structure2));
474    }
475
476    #[test]
477    fn test_deeply_nested_blank() {
478        let rule = MD028NoBlanksBlockquote;
479        let content = ">>> Deep nest\n\n>>> More deep";
480        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
481        let result = rule.check(&ctx).unwrap();
482        assert_eq!(result.len(), 1);
483
484        let fixed = rule.fix(&ctx).unwrap();
485        assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
486    }
487
488    #[test]
489    fn test_deeply_nested_with_marker() {
490        let rule = MD028NoBlanksBlockquote;
491        // Lines with >>> are valid
492        let content = ">>> Deep nest\n>>>\n>>> More deep";
493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
494        let result = rule.check(&ctx).unwrap();
495        assert!(result.is_empty(), "Should not flag lines with >>> marker");
496    }
497
498    #[test]
499    fn test_complex_blockquote_structure() {
500        let rule = MD028NoBlanksBlockquote;
501        // Line with > is valid, not a blank line
502        let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
503        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
504        let result = rule.check(&ctx).unwrap();
505        assert!(result.is_empty(), "Should not flag line with > marker");
506    }
507
508    #[test]
509    fn test_complex_with_blank() {
510        let rule = MD028NoBlanksBlockquote;
511        // Blank line between different nesting levels is not flagged
512        // (going from >> back to > is a context change)
513        let content = "> Level 1\n> > Nested\n\n> Back to level 1";
514        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
515        let result = rule.check(&ctx).unwrap();
516        assert_eq!(
517            result.len(),
518            0,
519            "Blank between different nesting levels is not inside blockquote"
520        );
521    }
522}