Skip to main content

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/// Obsidian Callouts are also supported when using the Obsidian flavor:
12/// - Any `> [!TYPE]` pattern is recognized as a callout
13/// - Foldable syntax is supported: `> [!NOTE]+` (expanded) or `> [!NOTE]-` (collapsed)
14///
15/// See [docs/md028.md](../../docs/md028.md) for full documentation, configuration, and examples.
16use crate::config::MarkdownFlavor;
17use crate::lint_context::LineInfo;
18use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
19use crate::utils::range_utils::calculate_line_range;
20
21/// GFM Alert types supported by GitHub
22/// 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
23const GFM_ALERT_TYPES: &[&str] = &["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"];
24
25#[derive(Clone)]
26pub struct MD028NoBlanksBlockquote;
27
28impl MD028NoBlanksBlockquote {
29    /// Check if a line is a blockquote line (has > markers)
30    #[inline]
31    fn is_blockquote_line(line: &str) -> bool {
32        // Fast path: check for '>' character before doing any string operations
33        if !line.as_bytes().contains(&b'>') {
34            return false;
35        }
36        line.trim_start().starts_with('>')
37    }
38
39    /// Get the blockquote level (number of > markers) and leading whitespace
40    /// Returns (level, whitespace_end_idx)
41    fn get_blockquote_info(line: &str) -> (usize, usize) {
42        let bytes = line.as_bytes();
43        let mut i = 0;
44
45        // Skip leading whitespace
46        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
47            i += 1;
48        }
49
50        let whitespace_end = i;
51        let mut level = 0;
52
53        // Count '>' markers
54        while i < bytes.len() {
55            if bytes[i] == b'>' {
56                level += 1;
57                i += 1;
58            } else if bytes[i] == b' ' || bytes[i] == b'\t' {
59                i += 1;
60            } else {
61                break;
62            }
63        }
64
65        (level, whitespace_end)
66    }
67
68    /// Check if a line is in a skip context (HTML comment, code block, HTML block, or frontmatter)
69    #[inline]
70    fn is_in_skip_context(line_infos: &[LineInfo], idx: usize) -> bool {
71        if let Some(li) = line_infos.get(idx) {
72            li.in_html_comment || li.in_mdx_comment || li.in_code_block || li.in_html_block || li.in_front_matter
73        } else {
74            false
75        }
76    }
77
78    /// Check if there's substantive content between two blockquote sections
79    /// This helps distinguish between paragraph breaks and separate blockquotes.
80    /// Lines in skip contexts (HTML comments, code blocks, frontmatter) count as
81    /// separating content because they represent non-blockquote material between quotes.
82    fn has_content_between(lines: &[&str], line_infos: &[LineInfo], start: usize, end: usize) -> bool {
83        for (offset, line) in lines[start..end].iter().enumerate() {
84            let idx = start + offset;
85            // Non-blank lines in skip contexts (HTML comments, code blocks, frontmatter)
86            // are separating content between blockquotes
87            if Self::is_in_skip_context(line_infos, idx) {
88                if !line.trim().is_empty() {
89                    return true;
90                }
91                continue;
92            }
93            let trimmed = line.trim();
94            // If there's any non-blank, non-blockquote content, these are separate quotes
95            if !trimmed.is_empty() && !trimmed.starts_with('>') {
96                return true;
97            }
98        }
99        false
100    }
101
102    /// Check if a blockquote line is a GFM alert start
103    /// GFM alerts have the format: `> [!TYPE]` where TYPE is NOTE, TIP, IMPORTANT, WARNING, or CAUTION
104    /// 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
105    #[inline]
106    fn is_gfm_alert_line(line: &str) -> bool {
107        // Fast path: must contain '[!' pattern
108        if !line.contains("[!") {
109            return false;
110        }
111
112        // Extract content after the > marker(s)
113        let trimmed = line.trim_start();
114        if !trimmed.starts_with('>') {
115            return false;
116        }
117
118        // Skip all > markers and whitespace to get to content
119        let content = trimmed
120            .trim_start_matches('>')
121            .trim_start_matches([' ', '\t'])
122            .trim_start_matches('>')
123            .trim_start();
124
125        // Check for GFM alert pattern: [!TYPE]
126        if !content.starts_with("[!") {
127            return false;
128        }
129
130        // Extract the alert type
131        if let Some(end_bracket) = content.find(']') {
132            let alert_type = &content[2..end_bracket];
133            return GFM_ALERT_TYPES.iter().any(|&t| t.eq_ignore_ascii_case(alert_type));
134        }
135
136        false
137    }
138
139    /// Check if a blockquote line is an Obsidian callout
140    /// Obsidian callouts have the format: `> [!TYPE]` where TYPE can be any string
141    /// Obsidian also supports foldable callouts: `> [!TYPE]+` (expanded) or `> [!TYPE]-` (collapsed)
142    /// Reference: https://help.obsidian.md/callouts
143    #[inline]
144    fn is_obsidian_callout_line(line: &str) -> bool {
145        // Fast path: must contain '[!' pattern
146        if !line.contains("[!") {
147            return false;
148        }
149
150        // Extract content after the > marker(s)
151        let trimmed = line.trim_start();
152        if !trimmed.starts_with('>') {
153            return false;
154        }
155
156        // Skip all > markers and whitespace to get to content
157        let content = trimmed
158            .trim_start_matches('>')
159            .trim_start_matches([' ', '\t'])
160            .trim_start_matches('>')
161            .trim_start();
162
163        // Check for Obsidian callout pattern: [!TYPE] or [!TYPE]+ or [!TYPE]-
164        if !content.starts_with("[!") {
165            return false;
166        }
167
168        // Find the closing bracket - must have at least one char for TYPE
169        if let Some(end_bracket) = content.find(']') {
170            // TYPE must be at least one character
171            if end_bracket > 2 {
172                // Verify the type contains only valid characters (alphanumeric, hyphen, underscore)
173                let alert_type = &content[2..end_bracket];
174                return !alert_type.is_empty()
175                    && alert_type.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_');
176            }
177        }
178
179        false
180    }
181
182    /// Check if a line is a callout/alert based on the flavor
183    /// For Obsidian flavor: accepts any [!TYPE] pattern
184    /// For other flavors: only accepts GFM alert types
185    #[inline]
186    fn is_callout_line(line: &str, flavor: MarkdownFlavor) -> bool {
187        match flavor {
188            MarkdownFlavor::Obsidian => Self::is_obsidian_callout_line(line),
189            _ => Self::is_gfm_alert_line(line),
190        }
191    }
192
193    /// Find the first line of a blockquote block starting from a given line
194    /// Scans backwards to find where this blockquote block begins
195    fn find_blockquote_start(lines: &[&str], line_infos: &[LineInfo], from_idx: usize) -> Option<usize> {
196        if from_idx >= lines.len() {
197            return None;
198        }
199
200        // Start from the given line and scan backwards
201        let mut start_idx = from_idx;
202
203        for i in (0..=from_idx).rev() {
204            // Skip lines in skip contexts
205            if Self::is_in_skip_context(line_infos, i) {
206                continue;
207            }
208
209            let line = lines[i];
210
211            // If it's a blockquote line, update start
212            if Self::is_blockquote_line(line) {
213                start_idx = i;
214            } else if line.trim().is_empty() {
215                // Blank line - check if previous content was blockquote
216                // If we haven't found any blockquote yet, continue
217                if start_idx == from_idx && !Self::is_blockquote_line(lines[from_idx]) {
218                    continue;
219                }
220                // Otherwise, blank line ends this blockquote block
221                break;
222            } else {
223                // Non-blockquote, non-blank line - this ends the blockquote block
224                break;
225            }
226        }
227
228        // Return start only if it's actually a blockquote line and not in a skip context
229        if Self::is_blockquote_line(lines[start_idx]) && !Self::is_in_skip_context(line_infos, start_idx) {
230            Some(start_idx)
231        } else {
232            None
233        }
234    }
235
236    /// Check if a blockquote block (starting at given index) is a callout/alert
237    /// For Obsidian flavor: accepts any [!TYPE] pattern
238    /// For other flavors: only accepts GFM alert types
239    fn is_callout_block(
240        lines: &[&str],
241        line_infos: &[LineInfo],
242        blockquote_line_idx: usize,
243        flavor: MarkdownFlavor,
244    ) -> bool {
245        // Find the start of this blockquote block
246        if let Some(start_idx) = Self::find_blockquote_start(lines, line_infos, blockquote_line_idx) {
247            // Check if the first line of the block is a callout/alert
248            return Self::is_callout_line(lines[start_idx], flavor);
249        }
250        false
251    }
252
253    /// Analyze context to determine if quotes are likely the same or different
254    fn are_likely_same_blockquote(
255        lines: &[&str],
256        line_infos: &[LineInfo],
257        blank_idx: usize,
258        flavor: MarkdownFlavor,
259    ) -> bool {
260        // Look for patterns that suggest these are the same blockquote:
261        // 1. Only one blank line between them (multiple blanks suggest separation)
262        // 2. Same indentation level
263        // 3. No content between them
264        // 4. Similar blockquote levels
265
266        // Note: We flag ALL blank lines between blockquotes, matching markdownlint behavior.
267        // Even multiple consecutive blank lines are flagged as they can be ambiguous
268        // (some parsers treat them as one blockquote, others as separate blockquotes).
269
270        // Find previous and next blockquote lines using fast byte scanning
271        let mut prev_quote_idx = None;
272        let mut next_quote_idx = None;
273
274        // Scan backwards for previous blockquote, skipping lines in skip contexts
275        for i in (0..blank_idx).rev() {
276            if Self::is_in_skip_context(line_infos, i) {
277                continue;
278            }
279            let line = lines[i];
280            // Fast check: if no '>' character, skip
281            if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
282                prev_quote_idx = Some(i);
283                break;
284            }
285        }
286
287        // Scan forwards for next blockquote, skipping lines in skip contexts
288        for (i, line) in lines.iter().enumerate().skip(blank_idx + 1) {
289            if Self::is_in_skip_context(line_infos, i) {
290                continue;
291            }
292            // Fast check: if no '>' character, skip
293            if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
294                next_quote_idx = Some(i);
295                break;
296            }
297        }
298
299        let (Some(prev_idx), Some(next_idx)) = (prev_quote_idx, next_quote_idx) else {
300            return false;
301        };
302
303        // Callout/Alert check: If either blockquote is a callout/alert, treat them as
304        // intentionally separate blockquotes. Callouts MUST be separated by blank lines
305        // to render correctly.
306        // For Obsidian flavor: any [!TYPE] is a callout
307        // For other flavors: only GFM alert types (NOTE, TIP, IMPORTANT, WARNING, CAUTION)
308        let prev_is_callout = Self::is_callout_block(lines, line_infos, prev_idx, flavor);
309        let next_is_callout = Self::is_callout_block(lines, line_infos, next_idx, flavor);
310        if prev_is_callout || next_is_callout {
311            return false;
312        }
313
314        // Check for content between blockquotes
315        if Self::has_content_between(lines, line_infos, prev_idx + 1, next_idx) {
316            return false;
317        }
318
319        // Get blockquote info once per line to avoid repeated parsing
320        let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
321        let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
322
323        // Different levels suggest different contexts
324        // But next_level > prev_level could be nested continuation
325        if next_level < prev_level {
326            return false;
327        }
328
329        // Check indentation consistency using byte indices
330        let prev_line = lines[prev_idx];
331        let next_line = lines[next_idx];
332        let prev_indent = &prev_line[..prev_whitespace_end];
333        let next_indent = &next_line[..next_whitespace_end];
334
335        // Different indentation indicates separate blockquote contexts
336        // Same indentation with no content between = same blockquote (blank line inside)
337        prev_indent == next_indent
338    }
339
340    /// Check if a blank line is problematic (inside a blockquote)
341    fn is_problematic_blank_line(
342        lines: &[&str],
343        line_infos: &[LineInfo],
344        index: usize,
345        flavor: MarkdownFlavor,
346    ) -> Option<(usize, String)> {
347        let current_line = lines[index];
348
349        // Must be a blank line (no content, no > markers)
350        if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
351            return None;
352        }
353
354        // Use heuristics to determine if this blank line is inside a blockquote
355        // or if it's an intentional separator between blockquotes
356        if !Self::are_likely_same_blockquote(lines, line_infos, index, flavor) {
357            return None;
358        }
359
360        // This blank line appears to be inside a blockquote
361        // Find the appropriate fix using optimized parsing, skipping lines in skip contexts
362        for i in (0..index).rev() {
363            if Self::is_in_skip_context(line_infos, i) {
364                continue;
365            }
366            let line = lines[i];
367            // Fast check: if no '>' character, skip
368            if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
369                let (level, whitespace_end) = Self::get_blockquote_info(line);
370                let indent = &line[..whitespace_end];
371                let mut fix = String::with_capacity(indent.len() + level);
372                fix.push_str(indent);
373                for _ in 0..level {
374                    fix.push('>');
375                }
376                return Some((level, fix));
377            }
378        }
379
380        None
381    }
382}
383
384impl Default for MD028NoBlanksBlockquote {
385    fn default() -> Self {
386        Self
387    }
388}
389
390impl Rule for MD028NoBlanksBlockquote {
391    fn name(&self) -> &'static str {
392        "MD028"
393    }
394
395    fn description(&self) -> &'static str {
396        "Blank line inside blockquote"
397    }
398
399    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
400        // Early return for content without blockquotes
401        if !ctx.content.contains('>') {
402            return Ok(Vec::new());
403        }
404
405        let mut warnings = Vec::new();
406
407        // Get all lines
408        let lines = ctx.raw_lines();
409
410        // Pre-scan to find blank lines and blockquote lines for faster processing
411        let mut blank_line_indices = Vec::new();
412        let mut has_blockquotes = false;
413
414        for (line_idx, line) in lines.iter().enumerate() {
415            // Skip lines in non-markdown content contexts
416            if line_idx < ctx.lines.len() {
417                let li = &ctx.lines[line_idx];
418                if li.in_code_block || li.in_html_comment || li.in_mdx_comment || li.in_html_block || li.in_front_matter
419                {
420                    continue;
421                }
422            }
423
424            if line.trim().is_empty() {
425                blank_line_indices.push(line_idx);
426            } else if Self::is_blockquote_line(line) {
427                has_blockquotes = true;
428            }
429        }
430
431        // If no blockquotes found, no need to check blank lines
432        if !has_blockquotes {
433            return Ok(Vec::new());
434        }
435
436        // Only check blank lines that could be problematic
437        for &line_idx in &blank_line_indices {
438            let line_num = line_idx + 1;
439
440            // Check if this is a problematic blank line inside a blockquote
441            if let Some((level, fix_content)) = Self::is_problematic_blank_line(lines, &ctx.lines, line_idx, ctx.flavor)
442            {
443                let line = lines[line_idx];
444                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
445
446                warnings.push(LintWarning {
447                    rule_name: Some(self.name().to_string()),
448                    message: format!("Blank line inside blockquote (level {level})"),
449                    line: start_line,
450                    column: start_col,
451                    end_line,
452                    end_column: end_col,
453                    severity: Severity::Warning,
454                    fix: Some(Fix::new(
455                        ctx.line_index
456                            .line_col_to_byte_range_with_length(line_num, 1, line.len()),
457                        fix_content,
458                    )),
459                });
460            }
461        }
462
463        Ok(warnings)
464    }
465
466    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
467        if self.should_skip(ctx) {
468            return Ok(ctx.content.to_string());
469        }
470        let warnings = self.check(ctx)?;
471        if warnings.is_empty() {
472            return Ok(ctx.content.to_string());
473        }
474        let warnings =
475            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
476        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
477            .map_err(crate::rule::LintError::InvalidInput)
478    }
479
480    /// Get the category of this rule for selective processing
481    fn category(&self) -> RuleCategory {
482        RuleCategory::Blockquote
483    }
484
485    /// Check if this rule should be skipped
486    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
487        !ctx.likely_has_blockquotes()
488    }
489
490    fn as_any(&self) -> &dyn std::any::Any {
491        self
492    }
493
494    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
495    where
496        Self: Sized,
497    {
498        Box::new(MD028NoBlanksBlockquote)
499    }
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::lint_context::LintContext;
506
507    #[test]
508    fn test_no_blockquotes() {
509        let rule = MD028NoBlanksBlockquote;
510        let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512        let result = rule.check(&ctx).unwrap();
513        assert!(result.is_empty(), "Should not flag content without blockquotes");
514    }
515
516    #[test]
517    fn test_valid_blockquote_no_blanks() {
518        let rule = MD028NoBlanksBlockquote;
519        let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
520        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521        let result = rule.check(&ctx).unwrap();
522        assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
523    }
524
525    #[test]
526    fn test_blockquote_with_empty_line_marker() {
527        let rule = MD028NoBlanksBlockquote;
528        // Lines with just > are valid and should NOT be flagged
529        let content = "> First line\n>\n> Third line";
530        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531        let result = rule.check(&ctx).unwrap();
532        assert!(result.is_empty(), "Should not flag lines with just > marker");
533    }
534
535    #[test]
536    fn test_blockquote_with_empty_line_marker_and_space() {
537        let rule = MD028NoBlanksBlockquote;
538        // Lines with > and space are also valid
539        let content = "> First line\n> \n> Third line";
540        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541        let result = rule.check(&ctx).unwrap();
542        assert!(result.is_empty(), "Should not flag lines with > and space");
543    }
544
545    #[test]
546    fn test_blank_line_in_blockquote() {
547        let rule = MD028NoBlanksBlockquote;
548        // Truly blank line (no >) inside blockquote should be flagged
549        let content = "> First line\n\n> Third line";
550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551        let result = rule.check(&ctx).unwrap();
552        assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
553        assert_eq!(result[0].line, 2);
554        assert!(result[0].message.contains("Blank line inside blockquote"));
555    }
556
557    #[test]
558    fn test_multiple_blank_lines() {
559        let rule = MD028NoBlanksBlockquote;
560        let content = "> First\n\n\n> Fourth";
561        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562        let result = rule.check(&ctx).unwrap();
563        // With proper indentation checking, both blank lines are flagged as they're within the same blockquote
564        assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
565        assert_eq!(result[0].line, 2);
566        assert_eq!(result[1].line, 3);
567    }
568
569    #[test]
570    fn test_nested_blockquote_blank() {
571        let rule = MD028NoBlanksBlockquote;
572        let content = ">> Nested quote\n\n>> More nested";
573        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574        let result = rule.check(&ctx).unwrap();
575        assert_eq!(result.len(), 1);
576        assert_eq!(result[0].line, 2);
577    }
578
579    #[test]
580    fn test_nested_blockquote_with_marker() {
581        let rule = MD028NoBlanksBlockquote;
582        // Lines with >> are valid
583        let content = ">> Nested quote\n>>\n>> More nested";
584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585        let result = rule.check(&ctx).unwrap();
586        assert!(result.is_empty(), "Should not flag lines with >> marker");
587    }
588
589    #[test]
590    fn test_fix_single_blank() {
591        let rule = MD028NoBlanksBlockquote;
592        let content = "> First\n\n> Third";
593        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594        let fixed = rule.fix(&ctx).unwrap();
595        assert_eq!(fixed, "> First\n>\n> Third");
596    }
597
598    #[test]
599    fn test_fix_nested_blank() {
600        let rule = MD028NoBlanksBlockquote;
601        let content = ">> Nested\n\n>> More";
602        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603        let fixed = rule.fix(&ctx).unwrap();
604        assert_eq!(fixed, ">> Nested\n>>\n>> More");
605    }
606
607    #[test]
608    fn test_fix_with_indentation() {
609        let rule = MD028NoBlanksBlockquote;
610        let content = "  > Indented quote\n\n  > More";
611        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612        let fixed = rule.fix(&ctx).unwrap();
613        assert_eq!(fixed, "  > Indented quote\n  >\n  > More");
614    }
615
616    #[test]
617    fn test_mixed_levels() {
618        let rule = MD028NoBlanksBlockquote;
619        // Blank lines between different levels
620        let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
621        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622        let result = rule.check(&ctx).unwrap();
623        // Line 2 is a blank between > and >>, level 1 to level 2, considered inside level 1
624        // Line 4 is a blank between >> and >, level 2 to level 1, NOT inside blockquote
625        assert_eq!(result.len(), 1);
626        assert_eq!(result[0].line, 2);
627    }
628
629    #[test]
630    fn test_blockquote_with_code_block() {
631        let rule = MD028NoBlanksBlockquote;
632        let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
633        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634        let result = rule.check(&ctx).unwrap();
635        // Line 5 has > marker, so it's not a blank line
636        assert!(result.is_empty(), "Should not flag line with > marker");
637    }
638
639    #[test]
640    fn test_category() {
641        let rule = MD028NoBlanksBlockquote;
642        assert_eq!(rule.category(), RuleCategory::Blockquote);
643    }
644
645    #[test]
646    fn test_should_skip() {
647        let rule = MD028NoBlanksBlockquote;
648        let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
649        assert!(rule.should_skip(&ctx1));
650
651        let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
652        assert!(!rule.should_skip(&ctx2));
653    }
654
655    #[test]
656    fn test_empty_content() {
657        let rule = MD028NoBlanksBlockquote;
658        let content = "";
659        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660        let result = rule.check(&ctx).unwrap();
661        assert!(result.is_empty());
662    }
663
664    #[test]
665    fn test_blank_after_blockquote() {
666        let rule = MD028NoBlanksBlockquote;
667        let content = "> Quote\n\nNot a quote";
668        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669        let result = rule.check(&ctx).unwrap();
670        assert!(result.is_empty(), "Blank line after blockquote ends is valid");
671    }
672
673    #[test]
674    fn test_blank_before_blockquote() {
675        let rule = MD028NoBlanksBlockquote;
676        let content = "Not a quote\n\n> Quote";
677        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678        let result = rule.check(&ctx).unwrap();
679        assert!(result.is_empty(), "Blank line before blockquote starts is valid");
680    }
681
682    #[test]
683    fn test_preserve_trailing_newline() {
684        let rule = MD028NoBlanksBlockquote;
685        let content = "> Quote\n\n> More\n";
686        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
687        let fixed = rule.fix(&ctx).unwrap();
688        assert!(fixed.ends_with('\n'));
689
690        let content_no_newline = "> Quote\n\n> More";
691        let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
692        let fixed2 = rule.fix(&ctx2).unwrap();
693        assert!(!fixed2.ends_with('\n'));
694    }
695
696    #[test]
697    fn test_document_structure_extension() {
698        let rule = MD028NoBlanksBlockquote;
699        let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
700        // Test that the rule works correctly with blockquotes
701        let result = rule.check(&ctx).unwrap();
702        assert!(result.is_empty(), "Should not flag valid blockquote");
703
704        // Test that rule skips content without blockquotes
705        let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
706        assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
707    }
708
709    #[test]
710    fn test_deeply_nested_blank() {
711        let rule = MD028NoBlanksBlockquote;
712        let content = ">>> Deep nest\n\n>>> More deep";
713        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714        let result = rule.check(&ctx).unwrap();
715        assert_eq!(result.len(), 1);
716
717        let fixed = rule.fix(&ctx).unwrap();
718        assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
719    }
720
721    #[test]
722    fn test_deeply_nested_with_marker() {
723        let rule = MD028NoBlanksBlockquote;
724        // Lines with >>> are valid
725        let content = ">>> Deep nest\n>>>\n>>> More deep";
726        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
727        let result = rule.check(&ctx).unwrap();
728        assert!(result.is_empty(), "Should not flag lines with >>> marker");
729    }
730
731    #[test]
732    fn test_complex_blockquote_structure() {
733        let rule = MD028NoBlanksBlockquote;
734        // Line with > is valid, not a blank line
735        let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
736        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737        let result = rule.check(&ctx).unwrap();
738        assert!(result.is_empty(), "Should not flag line with > marker");
739    }
740
741    #[test]
742    fn test_complex_with_blank() {
743        let rule = MD028NoBlanksBlockquote;
744        // Blank line between different nesting levels is not flagged
745        // (going from >> back to > is a context change)
746        let content = "> Level 1\n> > Nested\n\n> Back to level 1";
747        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
748        let result = rule.check(&ctx).unwrap();
749        assert_eq!(
750            result.len(),
751            0,
752            "Blank between different nesting levels is not inside blockquote"
753        );
754    }
755
756    // ==================== GFM Alert Tests ====================
757    // GitHub Flavored Markdown alerts use the syntax > [!TYPE] where TYPE is
758    // NOTE, TIP, IMPORTANT, WARNING, or CAUTION. These alerts MUST be separated
759    // by blank lines to render correctly on GitHub.
760    // 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
761
762    #[test]
763    fn test_gfm_alert_detection_note() {
764        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
765        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
766        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line(">  [!NOTE]"));
767        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); // case insensitive
768        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); // mixed case
769    }
770
771    #[test]
772    fn test_gfm_alert_detection_all_types() {
773        // All five GFM alert types
774        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
775        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
776        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
777        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
778        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
779    }
780
781    #[test]
782    fn test_gfm_alert_detection_not_alert() {
783        // These should NOT be detected as GFM alerts
784        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
785        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
786        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [NOTE]")); // missing !
787        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!]")); // empty type
788        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("Regular text [!NOTE]")); // not blockquote
789        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("")); // empty
790        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> ")); // empty blockquote
791    }
792
793    #[test]
794    fn test_gfm_alerts_separated_by_blank_line() {
795        // Issue #126 use case: Two GFM alerts separated by blank line should NOT be flagged
796        let rule = MD028NoBlanksBlockquote;
797        let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
798        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799        let result = rule.check(&ctx).unwrap();
800        assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
801    }
802
803    #[test]
804    fn test_gfm_alerts_all_five_types_separated() {
805        // All five alert types in sequence, each separated by blank lines
806        let rule = MD028NoBlanksBlockquote;
807        let content = r#"> [!NOTE]
808> Note content
809
810> [!TIP]
811> Tip content
812
813> [!IMPORTANT]
814> Important content
815
816> [!WARNING]
817> Warning content
818
819> [!CAUTION]
820> Caution content"#;
821        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822        let result = rule.check(&ctx).unwrap();
823        assert!(
824            result.is_empty(),
825            "Should not flag blank lines between any GFM alert types"
826        );
827    }
828
829    #[test]
830    fn test_gfm_alert_with_multiple_lines() {
831        // GFM alert with multiple content lines, then another alert
832        let rule = MD028NoBlanksBlockquote;
833        let content = r#"> [!WARNING]
834> This is a warning
835> with multiple lines
836> of content
837
838> [!NOTE]
839> This is a note"#;
840        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841        let result = rule.check(&ctx).unwrap();
842        assert!(
843            result.is_empty(),
844            "Should not flag blank line between multi-line GFM alerts"
845        );
846    }
847
848    #[test]
849    fn test_gfm_alert_followed_by_regular_blockquote() {
850        // GFM alert followed by regular blockquote - should NOT flag
851        let rule = MD028NoBlanksBlockquote;
852        let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
853        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854        let result = rule.check(&ctx).unwrap();
855        assert!(result.is_empty(), "Should not flag blank line after GFM alert");
856    }
857
858    #[test]
859    fn test_regular_blockquote_followed_by_gfm_alert() {
860        // Regular blockquote followed by GFM alert - should NOT flag
861        let rule = MD028NoBlanksBlockquote;
862        let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
863        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864        let result = rule.check(&ctx).unwrap();
865        assert!(result.is_empty(), "Should not flag blank line before GFM alert");
866    }
867
868    #[test]
869    fn test_regular_blockquotes_still_flagged() {
870        // Regular blockquotes (not GFM alerts) should still be flagged
871        let rule = MD028NoBlanksBlockquote;
872        let content = "> First blockquote\n\n> Second blockquote";
873        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874        let result = rule.check(&ctx).unwrap();
875        assert_eq!(
876            result.len(),
877            1,
878            "Should still flag blank line between regular blockquotes"
879        );
880    }
881
882    #[test]
883    fn test_gfm_alert_blank_line_within_same_alert() {
884        // Blank line WITHIN a single GFM alert should still be flagged
885        // (this is a missing > marker inside the alert)
886        let rule = MD028NoBlanksBlockquote;
887        let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889        let result = rule.check(&ctx).unwrap();
890        // The second > line is NOT a new alert, so this is a blank within the same blockquote
891        // However, since the first blockquote is a GFM alert, and the second is just continuation,
892        // this could be ambiguous. Current implementation: if first is alert, don't flag.
893        // This is acceptable - user can use > marker on blank line if they want continuation.
894        assert!(
895            result.is_empty(),
896            "GFM alert status propagates to subsequent blockquote lines"
897        );
898    }
899
900    #[test]
901    fn test_gfm_alert_case_insensitive() {
902        let rule = MD028NoBlanksBlockquote;
903        let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
904        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905        let result = rule.check(&ctx).unwrap();
906        assert!(result.is_empty(), "GFM alert detection should be case insensitive");
907    }
908
909    #[test]
910    fn test_gfm_alert_with_nested_blockquote() {
911        // GFM alert doesn't support nesting, but test behavior
912        let rule = MD028NoBlanksBlockquote;
913        let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
914        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915        let result = rule.check(&ctx).unwrap();
916        assert!(
917            result.is_empty(),
918            "Should not flag blank between alerts even with nested content"
919        );
920    }
921
922    #[test]
923    fn test_gfm_alert_indented() {
924        let rule = MD028NoBlanksBlockquote;
925        // Indented GFM alerts (e.g., in a list context)
926        let content = "  > [!NOTE]\n  > Indented note\n\n  > [!TIP]\n  > Indented tip";
927        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928        let result = rule.check(&ctx).unwrap();
929        assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
930    }
931
932    #[test]
933    fn test_gfm_alert_mixed_with_regular_content() {
934        // Mixed document with GFM alerts and regular content
935        let rule = MD028NoBlanksBlockquote;
936        let content = r#"# Heading
937
938Some paragraph.
939
940> [!NOTE]
941> Important note
942
943More paragraph text.
944
945> [!WARNING]
946> Be careful!
947
948Final text."#;
949        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950        let result = rule.check(&ctx).unwrap();
951        assert!(
952            result.is_empty(),
953            "GFM alerts in mixed document should not trigger warnings"
954        );
955    }
956
957    #[test]
958    fn test_gfm_alert_fix_not_applied() {
959        // When we have GFM alerts, fix should not modify the blank lines
960        let rule = MD028NoBlanksBlockquote;
961        let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
962        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963        let fixed = rule.fix(&ctx).unwrap();
964        assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
965    }
966
967    #[test]
968    fn test_gfm_alert_multiple_blank_lines_between() {
969        // Multiple blank lines between GFM alerts should not be flagged
970        let rule = MD028NoBlanksBlockquote;
971        let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
972        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
973        let result = rule.check(&ctx).unwrap();
974        assert!(
975            result.is_empty(),
976            "Should not flag multiple blank lines between GFM alerts"
977        );
978    }
979
980    // ==================== Obsidian Callout Tests ====================
981    // Obsidian callouts use the same > [!TYPE] syntax as GFM alerts, but support
982    // any custom type (not just NOTE, TIP, IMPORTANT, WARNING, CAUTION).
983    // They also support foldable callouts with + or - suffix.
984    // Reference: https://help.obsidian.md/callouts
985
986    #[test]
987    fn test_obsidian_callout_detection() {
988        // Obsidian callouts should be detected
989        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
990        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
991        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
992        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
993        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
994        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
995        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
996        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
997        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
998        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
999        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
1000    }
1001
1002    #[test]
1003    fn test_obsidian_callout_custom_types() {
1004        // Obsidian supports custom callout types
1005        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
1006        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
1007        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
1008        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
1009        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
1010    }
1011
1012    #[test]
1013    fn test_obsidian_callout_foldable() {
1014        // Obsidian supports foldable callouts with + or -
1015        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
1016        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1017            "> [!NOTE]- Collapsed"
1018        ));
1019        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
1020        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
1021    }
1022
1023    #[test]
1024    fn test_obsidian_callout_with_title() {
1025        // Obsidian callouts can have custom titles
1026        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1027            "> [!NOTE] Custom Title"
1028        ));
1029        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1030            "> [!WARNING]+ Be Careful!"
1031        ));
1032    }
1033
1034    #[test]
1035    fn test_obsidian_callout_invalid() {
1036        // Invalid callout patterns
1037        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1038            "> Regular blockquote"
1039        ));
1040        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); // missing !
1041        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); // empty type
1042        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1043            "Regular text [!NOTE]"
1044        )); // not blockquote
1045        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); // empty
1046    }
1047
1048    #[test]
1049    fn test_obsidian_callouts_separated_by_blank_line() {
1050        // Obsidian callouts separated by blank line should NOT be flagged
1051        let rule = MD028NoBlanksBlockquote;
1052        let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
1053        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1054        let result = rule.check(&ctx).unwrap();
1055        assert!(
1056            result.is_empty(),
1057            "Should not flag blank line between Obsidian callouts"
1058        );
1059    }
1060
1061    #[test]
1062    fn test_obsidian_custom_callouts_separated() {
1063        // Custom Obsidian callouts should also be recognized
1064        let rule = MD028NoBlanksBlockquote;
1065        let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
1066        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1067        let result = rule.check(&ctx).unwrap();
1068        assert!(
1069            result.is_empty(),
1070            "Should not flag blank line between custom Obsidian callouts"
1071        );
1072    }
1073
1074    #[test]
1075    fn test_obsidian_foldable_callouts_separated() {
1076        // Foldable Obsidian callouts should also be recognized
1077        let rule = MD028NoBlanksBlockquote;
1078        let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1079        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1080        let result = rule.check(&ctx).unwrap();
1081        assert!(
1082            result.is_empty(),
1083            "Should not flag blank line between foldable Obsidian callouts"
1084        );
1085    }
1086
1087    #[test]
1088    fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1089        // Custom callout types should NOT be recognized in Standard flavor
1090        // (only GFM alert types are recognized)
1091        let rule = MD028NoBlanksBlockquote;
1092        let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1093        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1094        let result = rule.check(&ctx).unwrap();
1095        // In Standard flavor, [!info] and [!todo] are NOT GFM alerts, so this is flagged
1096        assert_eq!(
1097            result.len(),
1098            1,
1099            "Custom callout types should be flagged in Standard flavor"
1100        );
1101    }
1102
1103    #[test]
1104    fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1105        // GFM alert types should work in both Standard and Obsidian flavors
1106        let rule = MD028NoBlanksBlockquote;
1107        let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1108
1109        // Standard flavor
1110        let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1111        let result_standard = rule.check(&ctx_standard).unwrap();
1112        assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1113
1114        // Obsidian flavor
1115        let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1116        let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1117        assert!(
1118            result_obsidian.is_empty(),
1119            "GFM alerts should also work in Obsidian flavor"
1120        );
1121    }
1122
1123    #[test]
1124    fn test_obsidian_callout_all_builtin_types() {
1125        // Test all built-in Obsidian callout types
1126        let rule = MD028NoBlanksBlockquote;
1127        let content = r#"> [!note]
1128> Note
1129
1130> [!abstract]
1131> Abstract
1132
1133> [!summary]
1134> Summary
1135
1136> [!info]
1137> Info
1138
1139> [!todo]
1140> Todo
1141
1142> [!tip]
1143> Tip
1144
1145> [!success]
1146> Success
1147
1148> [!question]
1149> Question
1150
1151> [!warning]
1152> Warning
1153
1154> [!failure]
1155> Failure
1156
1157> [!danger]
1158> Danger
1159
1160> [!bug]
1161> Bug
1162
1163> [!example]
1164> Example
1165
1166> [!quote]
1167> Quote"#;
1168        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1169        let result = rule.check(&ctx).unwrap();
1170        assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1171    }
1172
1173    #[test]
1174    fn test_obsidian_fix_not_applied_to_callouts() {
1175        // Fix should not modify blank lines between Obsidian callouts
1176        let rule = MD028NoBlanksBlockquote;
1177        let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1178        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1179        let fixed = rule.fix(&ctx).unwrap();
1180        assert_eq!(
1181            fixed, content,
1182            "Fix should not modify blank lines between Obsidian callouts"
1183        );
1184    }
1185
1186    #[test]
1187    fn test_obsidian_regular_blockquotes_still_flagged() {
1188        // Regular blockquotes (not callouts) should still be flagged in Obsidian flavor
1189        let rule = MD028NoBlanksBlockquote;
1190        let content = "> First blockquote\n\n> Second blockquote";
1191        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1192        let result = rule.check(&ctx).unwrap();
1193        assert_eq!(
1194            result.len(),
1195            1,
1196            "Regular blockquotes should still be flagged in Obsidian flavor"
1197        );
1198    }
1199
1200    #[test]
1201    fn test_obsidian_callout_mixed_with_regular_blockquote() {
1202        // Callout followed by regular blockquote - should NOT flag (callout takes precedence)
1203        let rule = MD028NoBlanksBlockquote;
1204        let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1205        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1206        let result = rule.check(&ctx).unwrap();
1207        assert!(
1208            result.is_empty(),
1209            "Should not flag blank after callout even if followed by regular blockquote"
1210        );
1211    }
1212
1213    // ==================== HTML Comment Skip Tests ====================
1214    // Blockquote-like content inside HTML comments should not be linted.
1215
1216    #[test]
1217    fn test_html_comment_blockquotes_not_flagged() {
1218        let rule = MD028NoBlanksBlockquote;
1219        let content = "## Responses\n\n<!--\n> First response text here.\n> <br>— Person One\n\n> Second response text here.\n> <br>— Person Two\n-->\n\nThe above responses are currently disabled.\n";
1220        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1221        let result = rule.check(&ctx).unwrap();
1222        assert!(
1223            result.is_empty(),
1224            "Should not flag blank lines inside HTML comments, got: {result:?}"
1225        );
1226    }
1227
1228    #[test]
1229    fn test_fix_preserves_html_comment_content() {
1230        let rule = MD028NoBlanksBlockquote;
1231        let content = "<!--\n> First quote\n\n> Second quote\n-->\n";
1232        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1233        let fixed = rule.fix(&ctx).unwrap();
1234        assert_eq!(fixed, content, "Fix should not modify content inside HTML comments");
1235    }
1236
1237    #[test]
1238    fn test_multiline_html_comment_with_blockquotes() {
1239        let rule = MD028NoBlanksBlockquote;
1240        let content = "# Title\n\n<!--\n> Quote A\n> Line 2\n\n> Quote B\n> Line 2\n\n> Quote C\n-->\n\nSome text\n";
1241        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1242        let result = rule.check(&ctx).unwrap();
1243        assert!(
1244            result.is_empty(),
1245            "Should not flag any blank lines inside HTML comments, got: {result:?}"
1246        );
1247    }
1248
1249    #[test]
1250    fn test_blockquotes_outside_html_comment_still_flagged() {
1251        let rule = MD028NoBlanksBlockquote;
1252        let content = "> First quote\n\n> Second quote\n\n<!--\n> Commented quote A\n\n> Commented quote B\n-->\n";
1253        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1254        let result = rule.check(&ctx).unwrap();
1255        // The blank line between the first two blockquotes (outside comment) should be flagged
1256        // but none inside the HTML comment (lines 7 is the blank between commented quotes)
1257        for w in &result {
1258            assert!(
1259                w.line < 5,
1260                "Warning at line {} should not be inside HTML comment",
1261                w.line
1262            );
1263        }
1264        assert!(
1265            !result.is_empty(),
1266            "Should still flag blank line between blockquotes outside HTML comment"
1267        );
1268    }
1269
1270    #[test]
1271    fn test_frontmatter_blockquote_like_content_not_flagged() {
1272        let rule = MD028NoBlanksBlockquote;
1273        let content = "---\n> not a real blockquote\n\n> also not real\n---\n\n# Title\n";
1274        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1275        let result = rule.check(&ctx).unwrap();
1276        assert!(
1277            result.is_empty(),
1278            "Should not flag content inside frontmatter, got: {result:?}"
1279        );
1280    }
1281
1282    #[test]
1283    fn test_comment_boundary_does_not_leak_into_adjacent_blockquotes() {
1284        // A real blockquote before a comment should not be matched with
1285        // a blockquote inside the comment across the <!-- boundary
1286        let rule = MD028NoBlanksBlockquote;
1287        let content = "> real quote\n\n<!--\n> commented quote\n-->\n";
1288        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1289        let result = rule.check(&ctx).unwrap();
1290        assert!(
1291            result.is_empty(),
1292            "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1293        );
1294    }
1295
1296    #[test]
1297    fn test_blockquote_after_comment_boundary_not_matched() {
1298        // A blockquote inside a comment should not be matched with
1299        // a blockquote after the comment across the --> boundary
1300        let rule = MD028NoBlanksBlockquote;
1301        let content = "<!--\n> commented quote\n-->\n\n> real quote\n";
1302        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1303        let result = rule.check(&ctx).unwrap();
1304        assert!(
1305            result.is_empty(),
1306            "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1307        );
1308    }
1309
1310    #[test]
1311    fn test_fix_preserves_comment_boundary_content() {
1312        // Verify fix doesn't modify content when blockquotes straddle a comment boundary
1313        let rule = MD028NoBlanksBlockquote;
1314        let content = "> real quote\n\n<!--\n> commented quote A\n\n> commented quote B\n-->\n\n> another real quote\n";
1315        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1316        let fixed = rule.fix(&ctx).unwrap();
1317        assert_eq!(
1318            fixed, content,
1319            "Fix should not modify content when blockquotes are separated by comment boundaries"
1320        );
1321    }
1322
1323    #[test]
1324    fn test_inline_html_comment_does_not_suppress_warning() {
1325        // Inline HTML comments on a blockquote line should NOT suppress warnings -
1326        // only multi-line HTML comment blocks should
1327        let rule = MD028NoBlanksBlockquote;
1328        let content = "> quote with <!-- inline comment -->\n\n> continuation\n";
1329        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1330        let result = rule.check(&ctx).unwrap();
1331        // This should still be flagged since the blockquotes are not inside an HTML comment block
1332        assert!(
1333            !result.is_empty(),
1334            "Should still flag blank lines between blockquotes with inline HTML comments"
1335        );
1336    }
1337
1338    // ==================== Skip Context Scanning Tests ====================
1339    // Verify that backward/forward scanning in are_likely_same_blockquote()
1340    // and is_problematic_blank_line() properly skips lines in HTML comments,
1341    // code blocks, and frontmatter.
1342
1343    #[test]
1344    fn test_comment_with_blockquote_markers_on_delimiters() {
1345        // The backward scan should not find blockquote lines on HTML comment
1346        // delimiter lines, preventing false positives
1347        let rule = MD028NoBlanksBlockquote;
1348        let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1349        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1350        let result = rule.check(&ctx).unwrap();
1351        // Only the blank between "real quote A" and "real quote B" (line 6) should be flagged
1352        assert_eq!(
1353            result.len(),
1354            1,
1355            "Should only warn about blank between real quotes, got: {result:?}"
1356        );
1357        assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1358    }
1359
1360    #[test]
1361    fn test_commented_blockquote_between_real_blockquotes() {
1362        // A commented-out blockquote between two real blockquotes should act
1363        // as non-blockquote content, preventing them from being considered
1364        // the same blockquote
1365        let rule = MD028NoBlanksBlockquote;
1366        let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1367        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1368        let result = rule.check(&ctx).unwrap();
1369        assert!(
1370            result.is_empty(),
1371            "Should NOT warn when non-blockquote content (HTML comment) separates blockquotes, got: {result:?}"
1372        );
1373    }
1374
1375    #[test]
1376    fn test_code_block_with_blockquote_markers_between_real_blockquotes() {
1377        // Blockquote markers inside code blocks should be ignored by scanning
1378        let rule = MD028NoBlanksBlockquote;
1379        let content = "> real A\n\n```\n> not a blockquote\n```\n\n> real B";
1380        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1381        let result = rule.check(&ctx).unwrap();
1382        assert!(
1383            result.is_empty(),
1384            "Should NOT warn when code block with > markers separates blockquotes, got: {result:?}"
1385        );
1386    }
1387
1388    #[test]
1389    fn test_frontmatter_with_blockquote_markers_does_not_cause_false_positive() {
1390        // Blockquote-like lines in frontmatter should be ignored by scanning
1391        let rule = MD028NoBlanksBlockquote;
1392        let content = "---\n> frontmatter value\n---\n\n> real quote A\n\n> real quote B";
1393        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1394        let result = rule.check(&ctx).unwrap();
1395        // Only the blank between the two real blockquotes should be flagged
1396        assert_eq!(
1397            result.len(),
1398            1,
1399            "Should only flag the blank between real quotes, got: {result:?}"
1400        );
1401        assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1402    }
1403
1404    #[test]
1405    fn test_fix_does_not_modify_comment_separated_blockquotes() {
1406        // Fix should not add > markers when blockquotes are separated by HTML comments
1407        let rule = MD028NoBlanksBlockquote;
1408        let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1409        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1410        let fixed = rule.fix(&ctx).unwrap();
1411        assert_eq!(
1412            fixed, content,
1413            "Fix should not modify content when blockquotes are separated by HTML comment"
1414        );
1415    }
1416
1417    #[test]
1418    fn test_fix_works_correctly_with_comment_before_real_blockquotes() {
1419        // Fix should correctly handle the case where a comment with > markers
1420        // precedes two real blockquotes that have a blank between them
1421        let rule = MD028NoBlanksBlockquote;
1422        let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1423        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1424        let fixed = rule.fix(&ctx).unwrap();
1425        // The blank between the two real quotes should be fixed
1426        assert!(
1427            fixed.contains("> real quote A\n>\n> real quote B"),
1428            "Fix should add > marker between real quotes, got: {fixed}"
1429        );
1430        // The content inside the comment should be untouched
1431        assert!(
1432            fixed.contains("<!-- > not a real blockquote"),
1433            "Fix should not modify comment content"
1434        );
1435    }
1436
1437    #[test]
1438    fn test_html_block_with_angle_brackets_not_flagged() {
1439        // HTML blocks can contain `>` characters (e.g., in nested tags or template syntax)
1440        // that look like blockquote markers. These should be skipped.
1441        let rule = MD028NoBlanksBlockquote;
1442        let content = "<div>\n> not a real blockquote\n\n> also not real\n</div>";
1443        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1444        let result = rule.check(&ctx).unwrap();
1445
1446        assert!(
1447            result.is_empty(),
1448            "Lines inside HTML blocks should not trigger MD028. Got: {result:?}"
1449        );
1450    }
1451
1452    // ==================== Roundtrip Safety Tests ====================
1453    // Verify that fix() output, when re-checked, produces zero warnings.
1454
1455    #[test]
1456    fn test_roundtrip_single_blank() {
1457        let rule = MD028NoBlanksBlockquote;
1458        let content = "> First\n\n> Third";
1459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460        let fixed = rule.fix(&ctx).unwrap();
1461        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1462        let warnings = rule.check(&ctx2).unwrap();
1463        assert!(
1464            warnings.is_empty(),
1465            "Roundtrip should produce zero warnings, got: {warnings:?}"
1466        );
1467    }
1468
1469    #[test]
1470    fn test_roundtrip_multiple_blanks() {
1471        let rule = MD028NoBlanksBlockquote;
1472        let content = "> First\n\n\n> Fourth";
1473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474        let fixed = rule.fix(&ctx).unwrap();
1475        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1476        let warnings = rule.check(&ctx2).unwrap();
1477        assert!(
1478            warnings.is_empty(),
1479            "Roundtrip should produce zero warnings, got: {warnings:?}"
1480        );
1481    }
1482
1483    #[test]
1484    fn test_roundtrip_nested() {
1485        let rule = MD028NoBlanksBlockquote;
1486        let content = ">> Nested\n\n>> More";
1487        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1488        let fixed = rule.fix(&ctx).unwrap();
1489        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1490        let warnings = rule.check(&ctx2).unwrap();
1491        assert!(
1492            warnings.is_empty(),
1493            "Roundtrip should produce zero warnings, got: {warnings:?}"
1494        );
1495    }
1496
1497    #[test]
1498    fn test_roundtrip_indented() {
1499        let rule = MD028NoBlanksBlockquote;
1500        let content = "  > Indented\n\n  > More";
1501        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502        let fixed = rule.fix(&ctx).unwrap();
1503        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1504        let warnings = rule.check(&ctx2).unwrap();
1505        assert!(
1506            warnings.is_empty(),
1507            "Roundtrip should produce zero warnings, got: {warnings:?}"
1508        );
1509    }
1510
1511    #[test]
1512    fn test_roundtrip_deeply_nested() {
1513        let rule = MD028NoBlanksBlockquote;
1514        let content = ">>> Deep\n\n>>> More";
1515        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516        let fixed = rule.fix(&ctx).unwrap();
1517        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1518        let warnings = rule.check(&ctx2).unwrap();
1519        assert!(
1520            warnings.is_empty(),
1521            "Roundtrip should produce zero warnings, got: {warnings:?}"
1522        );
1523    }
1524
1525    #[test]
1526    fn test_roundtrip_multi_blockquotes() {
1527        let rule = MD028NoBlanksBlockquote;
1528        let content = "> First\n> Line\n\n> Second\n> Line\n\n> Third\n";
1529        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1530        let fixed = rule.fix(&ctx).unwrap();
1531        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1532        let warnings = rule.check(&ctx2).unwrap();
1533        assert!(
1534            warnings.is_empty(),
1535            "Roundtrip should produce zero warnings, got: {warnings:?}"
1536        );
1537    }
1538
1539    #[test]
1540    fn test_roundtrip_idempotent() {
1541        let rule = MD028NoBlanksBlockquote;
1542        let content = "> First\n\n> Second\n\n> Third\n";
1543        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544        let fixed1 = rule.fix(&ctx).unwrap();
1545        let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
1546        let fixed2 = rule.fix(&ctx2).unwrap();
1547        assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1548    }
1549
1550    #[test]
1551    fn test_html_block_does_not_leak_into_adjacent_blockquotes() {
1552        // Blockquotes after an HTML block should still be checked
1553        let rule = MD028NoBlanksBlockquote;
1554        let content =
1555            "<details>\n<summary>Click</summary>\n> inside html block\n</details>\n\n> real quote A\n\n> real quote B";
1556        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1557        let result = rule.check(&ctx).unwrap();
1558
1559        // Only the blank between "real quote A" and "real quote B" should be flagged
1560        assert_eq!(
1561            result.len(),
1562            1,
1563            "Expected 1 warning for blank between real blockquotes after HTML block. Got: {result:?}"
1564        );
1565    }
1566}