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::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 mut warnings = Vec::new();
190
191        // Get all lines
192        let lines: Vec<&str> = ctx.content.lines().collect();
193
194        // Pre-scan to find blank lines and blockquote lines for faster processing
195        let mut blank_line_indices = Vec::new();
196        let mut has_blockquotes = false;
197
198        for (line_idx, line) in lines.iter().enumerate() {
199            // Skip lines in code blocks
200            if line_idx < ctx.lines.len() && ctx.lines[line_idx].in_code_block {
201                continue;
202            }
203
204            if line.trim().is_empty() {
205                blank_line_indices.push(line_idx);
206            } else if Self::is_blockquote_line(line) {
207                has_blockquotes = true;
208            }
209        }
210
211        // If no blockquotes found, no need to check blank lines
212        if !has_blockquotes {
213            return Ok(Vec::new());
214        }
215
216        // Only check blank lines that could be problematic
217        for &line_idx in &blank_line_indices {
218            let line_num = line_idx + 1;
219
220            // Check if this is a problematic blank line inside a blockquote
221            if let Some((level, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
222                let line = lines[line_idx];
223                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
224
225                warnings.push(LintWarning {
226                    rule_name: Some(self.name().to_string()),
227                    message: format!("Blank line inside blockquote (level {level})"),
228                    line: start_line,
229                    column: start_col,
230                    end_line,
231                    end_column: end_col,
232                    severity: Severity::Warning,
233                    fix: Some(Fix {
234                        range: ctx
235                            .line_index
236                            .line_col_to_byte_range_with_length(line_num, 1, line.len()),
237                        replacement: fix_content,
238                    }),
239                });
240            }
241        }
242
243        Ok(warnings)
244    }
245
246    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
247        let mut result = Vec::with_capacity(ctx.lines.len());
248        let lines: Vec<&str> = ctx.content.lines().collect();
249
250        for (line_idx, line) in lines.iter().enumerate() {
251            // Check if this blank line needs fixing
252            if let Some((_, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
253                result.push(fix_content);
254            } else {
255                result.push(line.to_string());
256            }
257        }
258
259        Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
260    }
261
262    /// Get the category of this rule for selective processing
263    fn category(&self) -> RuleCategory {
264        RuleCategory::Blockquote
265    }
266
267    /// Check if this rule should be skipped
268    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
269        !ctx.likely_has_blockquotes()
270    }
271
272    fn as_any(&self) -> &dyn std::any::Any {
273        self
274    }
275
276    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
277    where
278        Self: Sized,
279    {
280        Box::new(MD028NoBlanksBlockquote)
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::lint_context::LintContext;
288
289    #[test]
290    fn test_no_blockquotes() {
291        let rule = MD028NoBlanksBlockquote;
292        let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
293        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
294        let result = rule.check(&ctx).unwrap();
295        assert!(result.is_empty(), "Should not flag content without blockquotes");
296    }
297
298    #[test]
299    fn test_valid_blockquote_no_blanks() {
300        let rule = MD028NoBlanksBlockquote;
301        let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
302        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
303        let result = rule.check(&ctx).unwrap();
304        assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
305    }
306
307    #[test]
308    fn test_blockquote_with_empty_line_marker() {
309        let rule = MD028NoBlanksBlockquote;
310        // Lines with just > are valid and should NOT be flagged
311        let content = "> First line\n>\n> Third line";
312        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
313        let result = rule.check(&ctx).unwrap();
314        assert!(result.is_empty(), "Should not flag lines with just > marker");
315    }
316
317    #[test]
318    fn test_blockquote_with_empty_line_marker_and_space() {
319        let rule = MD028NoBlanksBlockquote;
320        // Lines with > and space are also valid
321        let content = "> First line\n> \n> Third line";
322        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
323        let result = rule.check(&ctx).unwrap();
324        assert!(result.is_empty(), "Should not flag lines with > and space");
325    }
326
327    #[test]
328    fn test_blank_line_in_blockquote() {
329        let rule = MD028NoBlanksBlockquote;
330        // Truly blank line (no >) inside blockquote should be flagged
331        let content = "> First line\n\n> Third line";
332        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
333        let result = rule.check(&ctx).unwrap();
334        assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
335        assert_eq!(result[0].line, 2);
336        assert!(result[0].message.contains("Blank line inside blockquote"));
337    }
338
339    #[test]
340    fn test_multiple_blank_lines() {
341        let rule = MD028NoBlanksBlockquote;
342        let content = "> First\n\n\n> Fourth";
343        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
344        let result = rule.check(&ctx).unwrap();
345        // With proper indentation checking, both blank lines are flagged as they're within the same blockquote
346        assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
347        assert_eq!(result[0].line, 2);
348        assert_eq!(result[1].line, 3);
349    }
350
351    #[test]
352    fn test_nested_blockquote_blank() {
353        let rule = MD028NoBlanksBlockquote;
354        let content = ">> Nested quote\n\n>> More nested";
355        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
356        let result = rule.check(&ctx).unwrap();
357        assert_eq!(result.len(), 1);
358        assert_eq!(result[0].line, 2);
359    }
360
361    #[test]
362    fn test_nested_blockquote_with_marker() {
363        let rule = MD028NoBlanksBlockquote;
364        // Lines with >> are valid
365        let content = ">> Nested quote\n>>\n>> More nested";
366        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
367        let result = rule.check(&ctx).unwrap();
368        assert!(result.is_empty(), "Should not flag lines with >> marker");
369    }
370
371    #[test]
372    fn test_fix_single_blank() {
373        let rule = MD028NoBlanksBlockquote;
374        let content = "> First\n\n> Third";
375        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
376        let fixed = rule.fix(&ctx).unwrap();
377        assert_eq!(fixed, "> First\n>\n> Third");
378    }
379
380    #[test]
381    fn test_fix_nested_blank() {
382        let rule = MD028NoBlanksBlockquote;
383        let content = ">> Nested\n\n>> More";
384        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
385        let fixed = rule.fix(&ctx).unwrap();
386        assert_eq!(fixed, ">> Nested\n>>\n>> More");
387    }
388
389    #[test]
390    fn test_fix_with_indentation() {
391        let rule = MD028NoBlanksBlockquote;
392        let content = "  > Indented quote\n\n  > More";
393        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
394        let fixed = rule.fix(&ctx).unwrap();
395        assert_eq!(fixed, "  > Indented quote\n  >\n  > More");
396    }
397
398    #[test]
399    fn test_mixed_levels() {
400        let rule = MD028NoBlanksBlockquote;
401        // Blank lines between different levels
402        let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
403        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
404        let result = rule.check(&ctx).unwrap();
405        // Line 2 is a blank between > and >>, level 1 to level 2, considered inside level 1
406        // Line 4 is a blank between >> and >, level 2 to level 1, NOT inside blockquote
407        assert_eq!(result.len(), 1);
408        assert_eq!(result[0].line, 2);
409    }
410
411    #[test]
412    fn test_blockquote_with_code_block() {
413        let rule = MD028NoBlanksBlockquote;
414        let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
416        let result = rule.check(&ctx).unwrap();
417        // Line 5 has > marker, so it's not a blank line
418        assert!(result.is_empty(), "Should not flag line with > marker");
419    }
420
421    #[test]
422    fn test_category() {
423        let rule = MD028NoBlanksBlockquote;
424        assert_eq!(rule.category(), RuleCategory::Blockquote);
425    }
426
427    #[test]
428    fn test_should_skip() {
429        let rule = MD028NoBlanksBlockquote;
430        let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard);
431        assert!(rule.should_skip(&ctx1));
432
433        let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard);
434        assert!(!rule.should_skip(&ctx2));
435    }
436
437    #[test]
438    fn test_empty_content() {
439        let rule = MD028NoBlanksBlockquote;
440        let content = "";
441        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
442        let result = rule.check(&ctx).unwrap();
443        assert!(result.is_empty());
444    }
445
446    #[test]
447    fn test_blank_after_blockquote() {
448        let rule = MD028NoBlanksBlockquote;
449        let content = "> Quote\n\nNot a quote";
450        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
451        let result = rule.check(&ctx).unwrap();
452        assert!(result.is_empty(), "Blank line after blockquote ends is valid");
453    }
454
455    #[test]
456    fn test_blank_before_blockquote() {
457        let rule = MD028NoBlanksBlockquote;
458        let content = "Not a quote\n\n> Quote";
459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
460        let result = rule.check(&ctx).unwrap();
461        assert!(result.is_empty(), "Blank line before blockquote starts is valid");
462    }
463
464    #[test]
465    fn test_preserve_trailing_newline() {
466        let rule = MD028NoBlanksBlockquote;
467        let content = "> Quote\n\n> More\n";
468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
469        let fixed = rule.fix(&ctx).unwrap();
470        assert!(fixed.ends_with('\n'));
471
472        let content_no_newline = "> Quote\n\n> More";
473        let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard);
474        let fixed2 = rule.fix(&ctx2).unwrap();
475        assert!(!fixed2.ends_with('\n'));
476    }
477
478    #[test]
479    fn test_document_structure_extension() {
480        let rule = MD028NoBlanksBlockquote;
481        let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard);
482        // Test that the rule works correctly with blockquotes
483        let result = rule.check(&ctx).unwrap();
484        assert!(result.is_empty(), "Should not flag valid blockquote");
485
486        // Test that rule skips content without blockquotes
487        let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard);
488        assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
489    }
490
491    #[test]
492    fn test_deeply_nested_blank() {
493        let rule = MD028NoBlanksBlockquote;
494        let content = ">>> Deep nest\n\n>>> More deep";
495        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
496        let result = rule.check(&ctx).unwrap();
497        assert_eq!(result.len(), 1);
498
499        let fixed = rule.fix(&ctx).unwrap();
500        assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
501    }
502
503    #[test]
504    fn test_deeply_nested_with_marker() {
505        let rule = MD028NoBlanksBlockquote;
506        // Lines with >>> are valid
507        let content = ">>> Deep nest\n>>>\n>>> More deep";
508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
509        let result = rule.check(&ctx).unwrap();
510        assert!(result.is_empty(), "Should not flag lines with >>> marker");
511    }
512
513    #[test]
514    fn test_complex_blockquote_structure() {
515        let rule = MD028NoBlanksBlockquote;
516        // Line with > is valid, not a blank line
517        let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
518        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
519        let result = rule.check(&ctx).unwrap();
520        assert!(result.is_empty(), "Should not flag line with > marker");
521    }
522
523    #[test]
524    fn test_complex_with_blank() {
525        let rule = MD028NoBlanksBlockquote;
526        // Blank line between different nesting levels is not flagged
527        // (going from >> back to > is a context change)
528        let content = "> Level 1\n> > Nested\n\n> Back to level 1";
529        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
530        let result = rule.check(&ctx).unwrap();
531        assert_eq!(
532            result.len(),
533            0,
534            "Blank between different nesting levels is not inside blockquote"
535        );
536    }
537}