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///
7/// GFM Alerts (GitHub Flavored Markdown) are automatically detected and excluded:
8/// - `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`
9///   These alerts MUST be separated by blank lines to render correctly on GitHub.
10///
11/// See [docs/md028.md](../../docs/md028.md) for full documentation, configuration, and examples.
12use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
13use crate::utils::range_utils::calculate_line_range;
14
15/// GFM Alert types supported by GitHub
16/// Reference: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
17const GFM_ALERT_TYPES: &[&str] = &["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"];
18
19#[derive(Clone)]
20pub struct MD028NoBlanksBlockquote;
21
22impl MD028NoBlanksBlockquote {
23    /// Check if a line is a blockquote line (has > markers)
24    #[inline]
25    fn is_blockquote_line(line: &str) -> bool {
26        // Fast path: check for '>' character before doing any string operations
27        if !line.as_bytes().contains(&b'>') {
28            return false;
29        }
30        line.trim_start().starts_with('>')
31    }
32
33    /// Get the blockquote level (number of > markers) and leading whitespace
34    /// Returns (level, whitespace_end_idx)
35    fn get_blockquote_info(line: &str) -> (usize, usize) {
36        let bytes = line.as_bytes();
37        let mut i = 0;
38
39        // Skip leading whitespace
40        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
41            i += 1;
42        }
43
44        let whitespace_end = i;
45        let mut level = 0;
46
47        // Count '>' markers
48        while i < bytes.len() {
49            if bytes[i] == b'>' {
50                level += 1;
51                i += 1;
52            } else if bytes[i] == b' ' || bytes[i] == b'\t' {
53                i += 1;
54            } else {
55                break;
56            }
57        }
58
59        (level, whitespace_end)
60    }
61
62    /// Check if there's substantive content between two blockquote sections
63    /// This helps distinguish between paragraph breaks and separate blockquotes
64    fn has_content_between(lines: &[&str], start: usize, end: usize) -> bool {
65        for line in lines.iter().take(end).skip(start) {
66            let trimmed = line.trim();
67            // If there's any non-blank, non-blockquote content, these are separate quotes
68            if !trimmed.is_empty() && !trimmed.starts_with('>') {
69                return true;
70            }
71        }
72        false
73    }
74
75    /// Check if a blockquote line is a GFM alert start
76    /// GFM alerts have the format: `> [!TYPE]` where TYPE is NOTE, TIP, IMPORTANT, WARNING, or CAUTION
77    /// Reference: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
78    #[inline]
79    fn is_gfm_alert_line(line: &str) -> bool {
80        // Fast path: must contain '[!' pattern
81        if !line.contains("[!") {
82            return false;
83        }
84
85        // Extract content after the > marker(s)
86        let trimmed = line.trim_start();
87        if !trimmed.starts_with('>') {
88            return false;
89        }
90
91        // Skip all > markers and whitespace to get to content
92        let content = trimmed
93            .trim_start_matches('>')
94            .trim_start_matches([' ', '\t'])
95            .trim_start_matches('>')
96            .trim_start();
97
98        // Check for GFM alert pattern: [!TYPE]
99        if !content.starts_with("[!") {
100            return false;
101        }
102
103        // Extract the alert type
104        if let Some(end_bracket) = content.find(']') {
105            let alert_type = &content[2..end_bracket];
106            return GFM_ALERT_TYPES.iter().any(|&t| t.eq_ignore_ascii_case(alert_type));
107        }
108
109        false
110    }
111
112    /// Find the first line of a blockquote block starting from a given line
113    /// Scans backwards to find where this blockquote block begins
114    fn find_blockquote_start(lines: &[&str], from_idx: usize) -> Option<usize> {
115        if from_idx >= lines.len() {
116            return None;
117        }
118
119        // Start from the given line and scan backwards
120        let mut start_idx = from_idx;
121
122        for i in (0..=from_idx).rev() {
123            let line = lines[i];
124
125            // If it's a blockquote line, update start
126            if Self::is_blockquote_line(line) {
127                start_idx = i;
128            } else if line.trim().is_empty() {
129                // Blank line - check if previous content was blockquote
130                // If we haven't found any blockquote yet, continue
131                if start_idx == from_idx && !Self::is_blockquote_line(lines[from_idx]) {
132                    continue;
133                }
134                // Otherwise, blank line ends this blockquote block
135                break;
136            } else {
137                // Non-blockquote, non-blank line - this ends the blockquote block
138                break;
139            }
140        }
141
142        // Return start only if it's actually a blockquote line
143        if Self::is_blockquote_line(lines[start_idx]) {
144            Some(start_idx)
145        } else {
146            None
147        }
148    }
149
150    /// Check if a blockquote block (starting at given index) is a GFM alert
151    fn is_gfm_alert_block(lines: &[&str], blockquote_line_idx: usize) -> bool {
152        // Find the start of this blockquote block
153        if let Some(start_idx) = Self::find_blockquote_start(lines, blockquote_line_idx) {
154            // Check if the first line of the block is a GFM alert
155            return Self::is_gfm_alert_line(lines[start_idx]);
156        }
157        false
158    }
159
160    /// Analyze context to determine if quotes are likely the same or different
161    fn are_likely_same_blockquote(lines: &[&str], blank_idx: usize) -> bool {
162        // Look for patterns that suggest these are the same blockquote:
163        // 1. Only one blank line between them (multiple blanks suggest separation)
164        // 2. Same indentation level
165        // 3. No content between them
166        // 4. Similar blockquote levels
167
168        // Note: We flag ALL blank lines between blockquotes, matching markdownlint behavior.
169        // Even multiple consecutive blank lines are flagged as they can be ambiguous
170        // (some parsers treat them as one blockquote, others as separate blockquotes).
171
172        // Find previous and next blockquote lines using fast byte scanning
173        let mut prev_quote_idx = None;
174        let mut next_quote_idx = None;
175
176        // Scan backwards for previous blockquote
177        for i in (0..blank_idx).rev() {
178            let line = lines[i];
179            // Fast check: if no '>' character, skip
180            if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
181                prev_quote_idx = Some(i);
182                break;
183            }
184        }
185
186        // Scan forwards for next blockquote
187        for (i, line) in lines.iter().enumerate().skip(blank_idx + 1) {
188            // Fast check: if no '>' character, skip
189            if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
190                next_quote_idx = Some(i);
191                break;
192            }
193        }
194
195        let (prev_idx, next_idx) = match (prev_quote_idx, next_quote_idx) {
196            (Some(p), Some(n)) => (p, n),
197            _ => return false,
198        };
199
200        // GFM Alert check: If either blockquote is a GFM alert (> [!NOTE], > [!TIP], etc.),
201        // treat them as intentionally separate blockquotes. GFM alerts MUST be separated
202        // by blank lines to render correctly on GitHub.
203        let prev_is_alert = Self::is_gfm_alert_block(lines, prev_idx);
204        let next_is_alert = Self::is_gfm_alert_block(lines, next_idx);
205        if prev_is_alert || next_is_alert {
206            return false;
207        }
208
209        // Check for content between blockquotes
210        if Self::has_content_between(lines, prev_idx + 1, next_idx) {
211            return false;
212        }
213
214        // Get blockquote info once per line to avoid repeated parsing
215        let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
216        let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
217
218        // Different levels suggest different contexts
219        // But next_level > prev_level could be nested continuation
220        if next_level < prev_level {
221            return false;
222        }
223
224        // Check indentation consistency using byte indices
225        let prev_line = lines[prev_idx];
226        let next_line = lines[next_idx];
227        let prev_indent = &prev_line[..prev_whitespace_end];
228        let next_indent = &next_line[..next_whitespace_end];
229
230        // Different indentation indicates separate blockquote contexts
231        // Same indentation with no content between = same blockquote (blank line inside)
232        prev_indent == next_indent
233    }
234
235    /// Check if a blank line is problematic (inside a blockquote)
236    fn is_problematic_blank_line(lines: &[&str], index: usize) -> Option<(usize, String)> {
237        let current_line = lines[index];
238
239        // Must be a blank line (no content, no > markers)
240        if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
241            return None;
242        }
243
244        // Use heuristics to determine if this blank line is inside a blockquote
245        // or if it's an intentional separator between blockquotes
246        if !Self::are_likely_same_blockquote(lines, index) {
247            return None;
248        }
249
250        // This blank line appears to be inside a blockquote
251        // Find the appropriate fix using optimized parsing
252        for i in (0..index).rev() {
253            let line = lines[i];
254            // Fast check: if no '>' character, skip
255            if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
256                let (level, whitespace_end) = Self::get_blockquote_info(line);
257                let indent = &line[..whitespace_end];
258                let mut fix = String::with_capacity(indent.len() + level);
259                fix.push_str(indent);
260                for _ in 0..level {
261                    fix.push('>');
262                }
263                return Some((level, fix));
264            }
265        }
266
267        None
268    }
269}
270
271impl Default for MD028NoBlanksBlockquote {
272    fn default() -> Self {
273        Self
274    }
275}
276
277impl Rule for MD028NoBlanksBlockquote {
278    fn name(&self) -> &'static str {
279        "MD028"
280    }
281
282    fn description(&self) -> &'static str {
283        "Blank line inside blockquote"
284    }
285
286    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
287        // Early return for content without blockquotes
288        if !ctx.content.contains('>') {
289            return Ok(Vec::new());
290        }
291
292        let mut warnings = Vec::new();
293
294        // Get all lines
295        let lines: Vec<&str> = ctx.content.lines().collect();
296
297        // Pre-scan to find blank lines and blockquote lines for faster processing
298        let mut blank_line_indices = Vec::new();
299        let mut has_blockquotes = false;
300
301        for (line_idx, line) in lines.iter().enumerate() {
302            // Skip lines in code blocks
303            if line_idx < ctx.lines.len() && ctx.lines[line_idx].in_code_block {
304                continue;
305            }
306
307            if line.trim().is_empty() {
308                blank_line_indices.push(line_idx);
309            } else if Self::is_blockquote_line(line) {
310                has_blockquotes = true;
311            }
312        }
313
314        // If no blockquotes found, no need to check blank lines
315        if !has_blockquotes {
316            return Ok(Vec::new());
317        }
318
319        // Only check blank lines that could be problematic
320        for &line_idx in &blank_line_indices {
321            let line_num = line_idx + 1;
322
323            // Check if this is a problematic blank line inside a blockquote
324            if let Some((level, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
325                let line = lines[line_idx];
326                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
327
328                warnings.push(LintWarning {
329                    rule_name: Some(self.name().to_string()),
330                    message: format!("Blank line inside blockquote (level {level})"),
331                    line: start_line,
332                    column: start_col,
333                    end_line,
334                    end_column: end_col,
335                    severity: Severity::Warning,
336                    fix: Some(Fix {
337                        range: ctx
338                            .line_index
339                            .line_col_to_byte_range_with_length(line_num, 1, line.len()),
340                        replacement: fix_content,
341                    }),
342                });
343            }
344        }
345
346        Ok(warnings)
347    }
348
349    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
350        let mut result = Vec::with_capacity(ctx.lines.len());
351        let lines: Vec<&str> = ctx.content.lines().collect();
352
353        for (line_idx, line) in lines.iter().enumerate() {
354            // Check if this blank line needs fixing
355            if let Some((_, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
356                result.push(fix_content);
357            } else {
358                result.push(line.to_string());
359            }
360        }
361
362        Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
363    }
364
365    /// Get the category of this rule for selective processing
366    fn category(&self) -> RuleCategory {
367        RuleCategory::Blockquote
368    }
369
370    /// Check if this rule should be skipped
371    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
372        !ctx.likely_has_blockquotes()
373    }
374
375    fn as_any(&self) -> &dyn std::any::Any {
376        self
377    }
378
379    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
380    where
381        Self: Sized,
382    {
383        Box::new(MD028NoBlanksBlockquote)
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::lint_context::LintContext;
391
392    #[test]
393    fn test_no_blockquotes() {
394        let rule = MD028NoBlanksBlockquote;
395        let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
396        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
397        let result = rule.check(&ctx).unwrap();
398        assert!(result.is_empty(), "Should not flag content without blockquotes");
399    }
400
401    #[test]
402    fn test_valid_blockquote_no_blanks() {
403        let rule = MD028NoBlanksBlockquote;
404        let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
405        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
406        let result = rule.check(&ctx).unwrap();
407        assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
408    }
409
410    #[test]
411    fn test_blockquote_with_empty_line_marker() {
412        let rule = MD028NoBlanksBlockquote;
413        // Lines with just > are valid and should NOT be flagged
414        let content = "> First line\n>\n> Third line";
415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416        let result = rule.check(&ctx).unwrap();
417        assert!(result.is_empty(), "Should not flag lines with just > marker");
418    }
419
420    #[test]
421    fn test_blockquote_with_empty_line_marker_and_space() {
422        let rule = MD028NoBlanksBlockquote;
423        // Lines with > and space are also valid
424        let content = "> First line\n> \n> Third line";
425        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426        let result = rule.check(&ctx).unwrap();
427        assert!(result.is_empty(), "Should not flag lines with > and space");
428    }
429
430    #[test]
431    fn test_blank_line_in_blockquote() {
432        let rule = MD028NoBlanksBlockquote;
433        // Truly blank line (no >) inside blockquote should be flagged
434        let content = "> First line\n\n> Third line";
435        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436        let result = rule.check(&ctx).unwrap();
437        assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
438        assert_eq!(result[0].line, 2);
439        assert!(result[0].message.contains("Blank line inside blockquote"));
440    }
441
442    #[test]
443    fn test_multiple_blank_lines() {
444        let rule = MD028NoBlanksBlockquote;
445        let content = "> First\n\n\n> Fourth";
446        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
447        let result = rule.check(&ctx).unwrap();
448        // With proper indentation checking, both blank lines are flagged as they're within the same blockquote
449        assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
450        assert_eq!(result[0].line, 2);
451        assert_eq!(result[1].line, 3);
452    }
453
454    #[test]
455    fn test_nested_blockquote_blank() {
456        let rule = MD028NoBlanksBlockquote;
457        let content = ">> Nested quote\n\n>> More nested";
458        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
459        let result = rule.check(&ctx).unwrap();
460        assert_eq!(result.len(), 1);
461        assert_eq!(result[0].line, 2);
462    }
463
464    #[test]
465    fn test_nested_blockquote_with_marker() {
466        let rule = MD028NoBlanksBlockquote;
467        // Lines with >> are valid
468        let content = ">> Nested quote\n>>\n>> More nested";
469        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470        let result = rule.check(&ctx).unwrap();
471        assert!(result.is_empty(), "Should not flag lines with >> marker");
472    }
473
474    #[test]
475    fn test_fix_single_blank() {
476        let rule = MD028NoBlanksBlockquote;
477        let content = "> First\n\n> Third";
478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479        let fixed = rule.fix(&ctx).unwrap();
480        assert_eq!(fixed, "> First\n>\n> Third");
481    }
482
483    #[test]
484    fn test_fix_nested_blank() {
485        let rule = MD028NoBlanksBlockquote;
486        let content = ">> Nested\n\n>> More";
487        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
488        let fixed = rule.fix(&ctx).unwrap();
489        assert_eq!(fixed, ">> Nested\n>>\n>> More");
490    }
491
492    #[test]
493    fn test_fix_with_indentation() {
494        let rule = MD028NoBlanksBlockquote;
495        let content = "  > Indented quote\n\n  > More";
496        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497        let fixed = rule.fix(&ctx).unwrap();
498        assert_eq!(fixed, "  > Indented quote\n  >\n  > More");
499    }
500
501    #[test]
502    fn test_mixed_levels() {
503        let rule = MD028NoBlanksBlockquote;
504        // Blank lines between different levels
505        let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
506        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507        let result = rule.check(&ctx).unwrap();
508        // Line 2 is a blank between > and >>, level 1 to level 2, considered inside level 1
509        // Line 4 is a blank between >> and >, level 2 to level 1, NOT inside blockquote
510        assert_eq!(result.len(), 1);
511        assert_eq!(result[0].line, 2);
512    }
513
514    #[test]
515    fn test_blockquote_with_code_block() {
516        let rule = MD028NoBlanksBlockquote;
517        let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
518        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519        let result = rule.check(&ctx).unwrap();
520        // Line 5 has > marker, so it's not a blank line
521        assert!(result.is_empty(), "Should not flag line with > marker");
522    }
523
524    #[test]
525    fn test_category() {
526        let rule = MD028NoBlanksBlockquote;
527        assert_eq!(rule.category(), RuleCategory::Blockquote);
528    }
529
530    #[test]
531    fn test_should_skip() {
532        let rule = MD028NoBlanksBlockquote;
533        let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
534        assert!(rule.should_skip(&ctx1));
535
536        let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
537        assert!(!rule.should_skip(&ctx2));
538    }
539
540    #[test]
541    fn test_empty_content() {
542        let rule = MD028NoBlanksBlockquote;
543        let content = "";
544        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545        let result = rule.check(&ctx).unwrap();
546        assert!(result.is_empty());
547    }
548
549    #[test]
550    fn test_blank_after_blockquote() {
551        let rule = MD028NoBlanksBlockquote;
552        let content = "> Quote\n\nNot a quote";
553        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554        let result = rule.check(&ctx).unwrap();
555        assert!(result.is_empty(), "Blank line after blockquote ends is valid");
556    }
557
558    #[test]
559    fn test_blank_before_blockquote() {
560        let rule = MD028NoBlanksBlockquote;
561        let content = "Not a quote\n\n> Quote";
562        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563        let result = rule.check(&ctx).unwrap();
564        assert!(result.is_empty(), "Blank line before blockquote starts is valid");
565    }
566
567    #[test]
568    fn test_preserve_trailing_newline() {
569        let rule = MD028NoBlanksBlockquote;
570        let content = "> Quote\n\n> More\n";
571        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572        let fixed = rule.fix(&ctx).unwrap();
573        assert!(fixed.ends_with('\n'));
574
575        let content_no_newline = "> Quote\n\n> More";
576        let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
577        let fixed2 = rule.fix(&ctx2).unwrap();
578        assert!(!fixed2.ends_with('\n'));
579    }
580
581    #[test]
582    fn test_document_structure_extension() {
583        let rule = MD028NoBlanksBlockquote;
584        let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
585        // Test that the rule works correctly with blockquotes
586        let result = rule.check(&ctx).unwrap();
587        assert!(result.is_empty(), "Should not flag valid blockquote");
588
589        // Test that rule skips content without blockquotes
590        let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
591        assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
592    }
593
594    #[test]
595    fn test_deeply_nested_blank() {
596        let rule = MD028NoBlanksBlockquote;
597        let content = ">>> Deep nest\n\n>>> More deep";
598        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599        let result = rule.check(&ctx).unwrap();
600        assert_eq!(result.len(), 1);
601
602        let fixed = rule.fix(&ctx).unwrap();
603        assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
604    }
605
606    #[test]
607    fn test_deeply_nested_with_marker() {
608        let rule = MD028NoBlanksBlockquote;
609        // Lines with >>> are valid
610        let content = ">>> Deep nest\n>>>\n>>> More deep";
611        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612        let result = rule.check(&ctx).unwrap();
613        assert!(result.is_empty(), "Should not flag lines with >>> marker");
614    }
615
616    #[test]
617    fn test_complex_blockquote_structure() {
618        let rule = MD028NoBlanksBlockquote;
619        // Line with > is valid, not a blank line
620        let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
621        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622        let result = rule.check(&ctx).unwrap();
623        assert!(result.is_empty(), "Should not flag line with > marker");
624    }
625
626    #[test]
627    fn test_complex_with_blank() {
628        let rule = MD028NoBlanksBlockquote;
629        // Blank line between different nesting levels is not flagged
630        // (going from >> back to > is a context change)
631        let content = "> Level 1\n> > Nested\n\n> Back to level 1";
632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633        let result = rule.check(&ctx).unwrap();
634        assert_eq!(
635            result.len(),
636            0,
637            "Blank between different nesting levels is not inside blockquote"
638        );
639    }
640
641    // ==================== GFM Alert Tests ====================
642    // GitHub Flavored Markdown alerts use the syntax > [!TYPE] where TYPE is
643    // NOTE, TIP, IMPORTANT, WARNING, or CAUTION. These alerts MUST be separated
644    // by blank lines to render correctly on GitHub.
645    // Reference: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
646
647    #[test]
648    fn test_gfm_alert_detection_note() {
649        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
650        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
651        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line(">  [!NOTE]"));
652        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); // case insensitive
653        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); // mixed case
654    }
655
656    #[test]
657    fn test_gfm_alert_detection_all_types() {
658        // All five GFM alert types
659        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
660        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
661        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
662        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
663        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
664    }
665
666    #[test]
667    fn test_gfm_alert_detection_not_alert() {
668        // These should NOT be detected as GFM alerts
669        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
670        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
671        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [NOTE]")); // missing !
672        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!]")); // empty type
673        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("Regular text [!NOTE]")); // not blockquote
674        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("")); // empty
675        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> ")); // empty blockquote
676    }
677
678    #[test]
679    fn test_gfm_alerts_separated_by_blank_line() {
680        // Issue #126 use case: Two GFM alerts separated by blank line should NOT be flagged
681        let rule = MD028NoBlanksBlockquote;
682        let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
683        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684        let result = rule.check(&ctx).unwrap();
685        assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
686    }
687
688    #[test]
689    fn test_gfm_alerts_all_five_types_separated() {
690        // All five alert types in sequence, each separated by blank lines
691        let rule = MD028NoBlanksBlockquote;
692        let content = r#"> [!NOTE]
693> Note content
694
695> [!TIP]
696> Tip content
697
698> [!IMPORTANT]
699> Important content
700
701> [!WARNING]
702> Warning content
703
704> [!CAUTION]
705> Caution content"#;
706        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707        let result = rule.check(&ctx).unwrap();
708        assert!(
709            result.is_empty(),
710            "Should not flag blank lines between any GFM alert types"
711        );
712    }
713
714    #[test]
715    fn test_gfm_alert_with_multiple_lines() {
716        // GFM alert with multiple content lines, then another alert
717        let rule = MD028NoBlanksBlockquote;
718        let content = r#"> [!WARNING]
719> This is a warning
720> with multiple lines
721> of content
722
723> [!NOTE]
724> This is a note"#;
725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726        let result = rule.check(&ctx).unwrap();
727        assert!(
728            result.is_empty(),
729            "Should not flag blank line between multi-line GFM alerts"
730        );
731    }
732
733    #[test]
734    fn test_gfm_alert_followed_by_regular_blockquote() {
735        // GFM alert followed by regular blockquote - should NOT flag
736        let rule = MD028NoBlanksBlockquote;
737        let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
738        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739        let result = rule.check(&ctx).unwrap();
740        assert!(result.is_empty(), "Should not flag blank line after GFM alert");
741    }
742
743    #[test]
744    fn test_regular_blockquote_followed_by_gfm_alert() {
745        // Regular blockquote followed by GFM alert - should NOT flag
746        let rule = MD028NoBlanksBlockquote;
747        let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
748        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749        let result = rule.check(&ctx).unwrap();
750        assert!(result.is_empty(), "Should not flag blank line before GFM alert");
751    }
752
753    #[test]
754    fn test_regular_blockquotes_still_flagged() {
755        // Regular blockquotes (not GFM alerts) should still be flagged
756        let rule = MD028NoBlanksBlockquote;
757        let content = "> First blockquote\n\n> Second blockquote";
758        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759        let result = rule.check(&ctx).unwrap();
760        assert_eq!(
761            result.len(),
762            1,
763            "Should still flag blank line between regular blockquotes"
764        );
765    }
766
767    #[test]
768    fn test_gfm_alert_blank_line_within_same_alert() {
769        // Blank line WITHIN a single GFM alert should still be flagged
770        // (this is a missing > marker inside the alert)
771        let rule = MD028NoBlanksBlockquote;
772        let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
773        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774        let result = rule.check(&ctx).unwrap();
775        // The second > line is NOT a new alert, so this is a blank within the same blockquote
776        // However, since the first blockquote is a GFM alert, and the second is just continuation,
777        // this could be ambiguous. Current implementation: if first is alert, don't flag.
778        // This is acceptable - user can use > marker on blank line if they want continuation.
779        assert!(
780            result.is_empty(),
781            "GFM alert status propagates to subsequent blockquote lines"
782        );
783    }
784
785    #[test]
786    fn test_gfm_alert_case_insensitive() {
787        let rule = MD028NoBlanksBlockquote;
788        let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
789        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790        let result = rule.check(&ctx).unwrap();
791        assert!(result.is_empty(), "GFM alert detection should be case insensitive");
792    }
793
794    #[test]
795    fn test_gfm_alert_with_nested_blockquote() {
796        // GFM alert doesn't support nesting, but test behavior
797        let rule = MD028NoBlanksBlockquote;
798        let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
799        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800        let result = rule.check(&ctx).unwrap();
801        assert!(
802            result.is_empty(),
803            "Should not flag blank between alerts even with nested content"
804        );
805    }
806
807    #[test]
808    fn test_gfm_alert_indented() {
809        let rule = MD028NoBlanksBlockquote;
810        // Indented GFM alerts (e.g., in a list context)
811        let content = "  > [!NOTE]\n  > Indented note\n\n  > [!TIP]\n  > Indented tip";
812        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813        let result = rule.check(&ctx).unwrap();
814        assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
815    }
816
817    #[test]
818    fn test_gfm_alert_mixed_with_regular_content() {
819        // Mixed document with GFM alerts and regular content
820        let rule = MD028NoBlanksBlockquote;
821        let content = r#"# Heading
822
823Some paragraph.
824
825> [!NOTE]
826> Important note
827
828More paragraph text.
829
830> [!WARNING]
831> Be careful!
832
833Final text."#;
834        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835        let result = rule.check(&ctx).unwrap();
836        assert!(
837            result.is_empty(),
838            "GFM alerts in mixed document should not trigger warnings"
839        );
840    }
841
842    #[test]
843    fn test_gfm_alert_fix_not_applied() {
844        // When we have GFM alerts, fix should not modify the blank lines
845        let rule = MD028NoBlanksBlockquote;
846        let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
847        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848        let fixed = rule.fix(&ctx).unwrap();
849        assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
850    }
851
852    #[test]
853    fn test_gfm_alert_multiple_blank_lines_between() {
854        // Multiple blank lines between GFM alerts should not be flagged
855        let rule = MD028NoBlanksBlockquote;
856        let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
857        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
858        let result = rule.check(&ctx).unwrap();
859        assert!(
860            result.is_empty(),
861            "Should not flag multiple blank lines between GFM alerts"
862        );
863    }
864}