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