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_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 (prev_idx, next_idx) = match (prev_quote_idx, next_quote_idx) {
300            (Some(p), Some(n)) => (p, n),
301            _ => return false,
302        };
303
304        // Callout/Alert check: If either blockquote is a callout/alert, treat them as
305        // intentionally separate blockquotes. Callouts MUST be separated by blank lines
306        // to render correctly.
307        // For Obsidian flavor: any [!TYPE] is a callout
308        // For other flavors: only GFM alert types (NOTE, TIP, IMPORTANT, WARNING, CAUTION)
309        let prev_is_callout = Self::is_callout_block(lines, line_infos, prev_idx, flavor);
310        let next_is_callout = Self::is_callout_block(lines, line_infos, next_idx, flavor);
311        if prev_is_callout || next_is_callout {
312            return false;
313        }
314
315        // Check for content between blockquotes
316        if Self::has_content_between(lines, line_infos, prev_idx + 1, next_idx) {
317            return false;
318        }
319
320        // Get blockquote info once per line to avoid repeated parsing
321        let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
322        let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
323
324        // Different levels suggest different contexts
325        // But next_level > prev_level could be nested continuation
326        if next_level < prev_level {
327            return false;
328        }
329
330        // Check indentation consistency using byte indices
331        let prev_line = lines[prev_idx];
332        let next_line = lines[next_idx];
333        let prev_indent = &prev_line[..prev_whitespace_end];
334        let next_indent = &next_line[..next_whitespace_end];
335
336        // Different indentation indicates separate blockquote contexts
337        // Same indentation with no content between = same blockquote (blank line inside)
338        prev_indent == next_indent
339    }
340
341    /// Check if a blank line is problematic (inside a blockquote)
342    fn is_problematic_blank_line(
343        lines: &[&str],
344        line_infos: &[LineInfo],
345        index: usize,
346        flavor: MarkdownFlavor,
347    ) -> Option<(usize, String)> {
348        let current_line = lines[index];
349
350        // Must be a blank line (no content, no > markers)
351        if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
352            return None;
353        }
354
355        // Use heuristics to determine if this blank line is inside a blockquote
356        // or if it's an intentional separator between blockquotes
357        if !Self::are_likely_same_blockquote(lines, line_infos, index, flavor) {
358            return None;
359        }
360
361        // This blank line appears to be inside a blockquote
362        // Find the appropriate fix using optimized parsing, skipping lines in skip contexts
363        for i in (0..index).rev() {
364            if Self::is_in_skip_context(line_infos, i) {
365                continue;
366            }
367            let line = lines[i];
368            // Fast check: if no '>' character, skip
369            if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
370                let (level, whitespace_end) = Self::get_blockquote_info(line);
371                let indent = &line[..whitespace_end];
372                let mut fix = String::with_capacity(indent.len() + level);
373                fix.push_str(indent);
374                for _ in 0..level {
375                    fix.push('>');
376                }
377                return Some((level, fix));
378            }
379        }
380
381        None
382    }
383}
384
385impl Default for MD028NoBlanksBlockquote {
386    fn default() -> Self {
387        Self
388    }
389}
390
391impl Rule for MD028NoBlanksBlockquote {
392    fn name(&self) -> &'static str {
393        "MD028"
394    }
395
396    fn description(&self) -> &'static str {
397        "Blank line inside blockquote"
398    }
399
400    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
401        // Early return for content without blockquotes
402        if !ctx.content.contains('>') {
403            return Ok(Vec::new());
404        }
405
406        let mut warnings = Vec::new();
407
408        // Get all lines
409        let lines = ctx.raw_lines();
410
411        // Pre-scan to find blank lines and blockquote lines for faster processing
412        let mut blank_line_indices = Vec::new();
413        let mut has_blockquotes = false;
414
415        for (line_idx, line) in lines.iter().enumerate() {
416            // Skip lines in non-markdown content contexts
417            if line_idx < ctx.lines.len() {
418                let li = &ctx.lines[line_idx];
419                if li.in_code_block || li.in_html_comment || li.in_html_block || li.in_front_matter {
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 {
455                        range: ctx
456                            .line_index
457                            .line_col_to_byte_range_with_length(line_num, 1, line.len()),
458                        replacement: fix_content,
459                    }),
460                });
461            }
462        }
463
464        Ok(warnings)
465    }
466
467    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
468        let mut result = Vec::with_capacity(ctx.lines.len());
469        let lines = ctx.raw_lines();
470
471        for (line_idx, line) in lines.iter().enumerate() {
472            // Skip lines in non-markdown content contexts
473            if line_idx < ctx.lines.len() {
474                let li = &ctx.lines[line_idx];
475                if li.in_code_block || li.in_html_comment || li.in_html_block || li.in_front_matter {
476                    result.push(line.to_string());
477                    continue;
478                }
479            }
480            // Check if this blank line needs fixing
481            if let Some((_, fix_content)) = Self::is_problematic_blank_line(lines, &ctx.lines, line_idx, ctx.flavor) {
482                result.push(fix_content);
483            } else {
484                result.push(line.to_string());
485            }
486        }
487
488        Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
489    }
490
491    /// Get the category of this rule for selective processing
492    fn category(&self) -> RuleCategory {
493        RuleCategory::Blockquote
494    }
495
496    /// Check if this rule should be skipped
497    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
498        !ctx.likely_has_blockquotes()
499    }
500
501    fn as_any(&self) -> &dyn std::any::Any {
502        self
503    }
504
505    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
506    where
507        Self: Sized,
508    {
509        Box::new(MD028NoBlanksBlockquote)
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::lint_context::LintContext;
517
518    #[test]
519    fn test_no_blockquotes() {
520        let rule = MD028NoBlanksBlockquote;
521        let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
522        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523        let result = rule.check(&ctx).unwrap();
524        assert!(result.is_empty(), "Should not flag content without blockquotes");
525    }
526
527    #[test]
528    fn test_valid_blockquote_no_blanks() {
529        let rule = MD028NoBlanksBlockquote;
530        let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532        let result = rule.check(&ctx).unwrap();
533        assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
534    }
535
536    #[test]
537    fn test_blockquote_with_empty_line_marker() {
538        let rule = MD028NoBlanksBlockquote;
539        // Lines with just > are valid and should NOT be flagged
540        let content = "> First line\n>\n> Third line";
541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542        let result = rule.check(&ctx).unwrap();
543        assert!(result.is_empty(), "Should not flag lines with just > marker");
544    }
545
546    #[test]
547    fn test_blockquote_with_empty_line_marker_and_space() {
548        let rule = MD028NoBlanksBlockquote;
549        // Lines with > and space are also valid
550        let content = "> First line\n> \n> Third line";
551        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552        let result = rule.check(&ctx).unwrap();
553        assert!(result.is_empty(), "Should not flag lines with > and space");
554    }
555
556    #[test]
557    fn test_blank_line_in_blockquote() {
558        let rule = MD028NoBlanksBlockquote;
559        // Truly blank line (no >) inside blockquote should be flagged
560        let content = "> First line\n\n> Third line";
561        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562        let result = rule.check(&ctx).unwrap();
563        assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
564        assert_eq!(result[0].line, 2);
565        assert!(result[0].message.contains("Blank line inside blockquote"));
566    }
567
568    #[test]
569    fn test_multiple_blank_lines() {
570        let rule = MD028NoBlanksBlockquote;
571        let content = "> First\n\n\n> Fourth";
572        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573        let result = rule.check(&ctx).unwrap();
574        // With proper indentation checking, both blank lines are flagged as they're within the same blockquote
575        assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
576        assert_eq!(result[0].line, 2);
577        assert_eq!(result[1].line, 3);
578    }
579
580    #[test]
581    fn test_nested_blockquote_blank() {
582        let rule = MD028NoBlanksBlockquote;
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_eq!(result.len(), 1);
587        assert_eq!(result[0].line, 2);
588    }
589
590    #[test]
591    fn test_nested_blockquote_with_marker() {
592        let rule = MD028NoBlanksBlockquote;
593        // Lines with >> are valid
594        let content = ">> Nested quote\n>>\n>> More nested";
595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596        let result = rule.check(&ctx).unwrap();
597        assert!(result.is_empty(), "Should not flag lines with >> marker");
598    }
599
600    #[test]
601    fn test_fix_single_blank() {
602        let rule = MD028NoBlanksBlockquote;
603        let content = "> First\n\n> Third";
604        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605        let fixed = rule.fix(&ctx).unwrap();
606        assert_eq!(fixed, "> First\n>\n> Third");
607    }
608
609    #[test]
610    fn test_fix_nested_blank() {
611        let rule = MD028NoBlanksBlockquote;
612        let content = ">> Nested\n\n>> More";
613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614        let fixed = rule.fix(&ctx).unwrap();
615        assert_eq!(fixed, ">> Nested\n>>\n>> More");
616    }
617
618    #[test]
619    fn test_fix_with_indentation() {
620        let rule = MD028NoBlanksBlockquote;
621        let content = "  > Indented quote\n\n  > More";
622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623        let fixed = rule.fix(&ctx).unwrap();
624        assert_eq!(fixed, "  > Indented quote\n  >\n  > More");
625    }
626
627    #[test]
628    fn test_mixed_levels() {
629        let rule = MD028NoBlanksBlockquote;
630        // Blank lines between different levels
631        let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633        let result = rule.check(&ctx).unwrap();
634        // Line 2 is a blank between > and >>, level 1 to level 2, considered inside level 1
635        // Line 4 is a blank between >> and >, level 2 to level 1, NOT inside blockquote
636        assert_eq!(result.len(), 1);
637        assert_eq!(result[0].line, 2);
638    }
639
640    #[test]
641    fn test_blockquote_with_code_block() {
642        let rule = MD028NoBlanksBlockquote;
643        let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
644        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645        let result = rule.check(&ctx).unwrap();
646        // Line 5 has > marker, so it's not a blank line
647        assert!(result.is_empty(), "Should not flag line with > marker");
648    }
649
650    #[test]
651    fn test_category() {
652        let rule = MD028NoBlanksBlockquote;
653        assert_eq!(rule.category(), RuleCategory::Blockquote);
654    }
655
656    #[test]
657    fn test_should_skip() {
658        let rule = MD028NoBlanksBlockquote;
659        let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
660        assert!(rule.should_skip(&ctx1));
661
662        let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
663        assert!(!rule.should_skip(&ctx2));
664    }
665
666    #[test]
667    fn test_empty_content() {
668        let rule = MD028NoBlanksBlockquote;
669        let content = "";
670        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671        let result = rule.check(&ctx).unwrap();
672        assert!(result.is_empty());
673    }
674
675    #[test]
676    fn test_blank_after_blockquote() {
677        let rule = MD028NoBlanksBlockquote;
678        let content = "> Quote\n\nNot a quote";
679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680        let result = rule.check(&ctx).unwrap();
681        assert!(result.is_empty(), "Blank line after blockquote ends is valid");
682    }
683
684    #[test]
685    fn test_blank_before_blockquote() {
686        let rule = MD028NoBlanksBlockquote;
687        let content = "Not a quote\n\n> Quote";
688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689        let result = rule.check(&ctx).unwrap();
690        assert!(result.is_empty(), "Blank line before blockquote starts is valid");
691    }
692
693    #[test]
694    fn test_preserve_trailing_newline() {
695        let rule = MD028NoBlanksBlockquote;
696        let content = "> Quote\n\n> More\n";
697        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
698        let fixed = rule.fix(&ctx).unwrap();
699        assert!(fixed.ends_with('\n'));
700
701        let content_no_newline = "> Quote\n\n> More";
702        let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
703        let fixed2 = rule.fix(&ctx2).unwrap();
704        assert!(!fixed2.ends_with('\n'));
705    }
706
707    #[test]
708    fn test_document_structure_extension() {
709        let rule = MD028NoBlanksBlockquote;
710        let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
711        // Test that the rule works correctly with blockquotes
712        let result = rule.check(&ctx).unwrap();
713        assert!(result.is_empty(), "Should not flag valid blockquote");
714
715        // Test that rule skips content without blockquotes
716        let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
717        assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
718    }
719
720    #[test]
721    fn test_deeply_nested_blank() {
722        let rule = MD028NoBlanksBlockquote;
723        let content = ">>> Deep nest\n\n>>> More deep";
724        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725        let result = rule.check(&ctx).unwrap();
726        assert_eq!(result.len(), 1);
727
728        let fixed = rule.fix(&ctx).unwrap();
729        assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
730    }
731
732    #[test]
733    fn test_deeply_nested_with_marker() {
734        let rule = MD028NoBlanksBlockquote;
735        // Lines with >>> are valid
736        let content = ">>> Deep nest\n>>>\n>>> More deep";
737        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738        let result = rule.check(&ctx).unwrap();
739        assert!(result.is_empty(), "Should not flag lines with >>> marker");
740    }
741
742    #[test]
743    fn test_complex_blockquote_structure() {
744        let rule = MD028NoBlanksBlockquote;
745        // Line with > is valid, not a blank line
746        let content = "> Level 1\n> > Nested properly\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!(result.is_empty(), "Should not flag line with > marker");
750    }
751
752    #[test]
753    fn test_complex_with_blank() {
754        let rule = MD028NoBlanksBlockquote;
755        // Blank line between different nesting levels is not flagged
756        // (going from >> back to > is a context change)
757        let content = "> Level 1\n> > Nested\n\n> Back to level 1";
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            0,
763            "Blank between different nesting levels is not inside blockquote"
764        );
765    }
766
767    // ==================== GFM Alert Tests ====================
768    // GitHub Flavored Markdown alerts use the syntax > [!TYPE] where TYPE is
769    // NOTE, TIP, IMPORTANT, WARNING, or CAUTION. These alerts MUST be separated
770    // by blank lines to render correctly on GitHub.
771    // 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
772
773    #[test]
774    fn test_gfm_alert_detection_note() {
775        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
776        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
777        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line(">  [!NOTE]"));
778        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); // case insensitive
779        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); // mixed case
780    }
781
782    #[test]
783    fn test_gfm_alert_detection_all_types() {
784        // All five GFM alert types
785        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
786        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
787        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
788        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
789        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
790    }
791
792    #[test]
793    fn test_gfm_alert_detection_not_alert() {
794        // These should NOT be detected as GFM alerts
795        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
796        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
797        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [NOTE]")); // missing !
798        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!]")); // empty type
799        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("Regular text [!NOTE]")); // not blockquote
800        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("")); // empty
801        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> ")); // empty blockquote
802    }
803
804    #[test]
805    fn test_gfm_alerts_separated_by_blank_line() {
806        // Issue #126 use case: Two GFM alerts separated by blank line should NOT be flagged
807        let rule = MD028NoBlanksBlockquote;
808        let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
809        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
810        let result = rule.check(&ctx).unwrap();
811        assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
812    }
813
814    #[test]
815    fn test_gfm_alerts_all_five_types_separated() {
816        // All five alert types in sequence, each separated by blank lines
817        let rule = MD028NoBlanksBlockquote;
818        let content = r#"> [!NOTE]
819> Note content
820
821> [!TIP]
822> Tip content
823
824> [!IMPORTANT]
825> Important content
826
827> [!WARNING]
828> Warning content
829
830> [!CAUTION]
831> Caution content"#;
832        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833        let result = rule.check(&ctx).unwrap();
834        assert!(
835            result.is_empty(),
836            "Should not flag blank lines between any GFM alert types"
837        );
838    }
839
840    #[test]
841    fn test_gfm_alert_with_multiple_lines() {
842        // GFM alert with multiple content lines, then another alert
843        let rule = MD028NoBlanksBlockquote;
844        let content = r#"> [!WARNING]
845> This is a warning
846> with multiple lines
847> of content
848
849> [!NOTE]
850> This is a note"#;
851        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852        let result = rule.check(&ctx).unwrap();
853        assert!(
854            result.is_empty(),
855            "Should not flag blank line between multi-line GFM alerts"
856        );
857    }
858
859    #[test]
860    fn test_gfm_alert_followed_by_regular_blockquote() {
861        // GFM alert followed by regular blockquote - should NOT flag
862        let rule = MD028NoBlanksBlockquote;
863        let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
864        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865        let result = rule.check(&ctx).unwrap();
866        assert!(result.is_empty(), "Should not flag blank line after GFM alert");
867    }
868
869    #[test]
870    fn test_regular_blockquote_followed_by_gfm_alert() {
871        // Regular blockquote followed by GFM alert - should NOT flag
872        let rule = MD028NoBlanksBlockquote;
873        let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
874        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875        let result = rule.check(&ctx).unwrap();
876        assert!(result.is_empty(), "Should not flag blank line before GFM alert");
877    }
878
879    #[test]
880    fn test_regular_blockquotes_still_flagged() {
881        // Regular blockquotes (not GFM alerts) should still be flagged
882        let rule = MD028NoBlanksBlockquote;
883        let content = "> First blockquote\n\n> Second blockquote";
884        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885        let result = rule.check(&ctx).unwrap();
886        assert_eq!(
887            result.len(),
888            1,
889            "Should still flag blank line between regular blockquotes"
890        );
891    }
892
893    #[test]
894    fn test_gfm_alert_blank_line_within_same_alert() {
895        // Blank line WITHIN a single GFM alert should still be flagged
896        // (this is a missing > marker inside the alert)
897        let rule = MD028NoBlanksBlockquote;
898        let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
899        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900        let result = rule.check(&ctx).unwrap();
901        // The second > line is NOT a new alert, so this is a blank within the same blockquote
902        // However, since the first blockquote is a GFM alert, and the second is just continuation,
903        // this could be ambiguous. Current implementation: if first is alert, don't flag.
904        // This is acceptable - user can use > marker on blank line if they want continuation.
905        assert!(
906            result.is_empty(),
907            "GFM alert status propagates to subsequent blockquote lines"
908        );
909    }
910
911    #[test]
912    fn test_gfm_alert_case_insensitive() {
913        let rule = MD028NoBlanksBlockquote;
914        let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
915        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916        let result = rule.check(&ctx).unwrap();
917        assert!(result.is_empty(), "GFM alert detection should be case insensitive");
918    }
919
920    #[test]
921    fn test_gfm_alert_with_nested_blockquote() {
922        // GFM alert doesn't support nesting, but test behavior
923        let rule = MD028NoBlanksBlockquote;
924        let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
925        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926        let result = rule.check(&ctx).unwrap();
927        assert!(
928            result.is_empty(),
929            "Should not flag blank between alerts even with nested content"
930        );
931    }
932
933    #[test]
934    fn test_gfm_alert_indented() {
935        let rule = MD028NoBlanksBlockquote;
936        // Indented GFM alerts (e.g., in a list context)
937        let content = "  > [!NOTE]\n  > Indented note\n\n  > [!TIP]\n  > Indented tip";
938        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
939        let result = rule.check(&ctx).unwrap();
940        assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
941    }
942
943    #[test]
944    fn test_gfm_alert_mixed_with_regular_content() {
945        // Mixed document with GFM alerts and regular content
946        let rule = MD028NoBlanksBlockquote;
947        let content = r#"# Heading
948
949Some paragraph.
950
951> [!NOTE]
952> Important note
953
954More paragraph text.
955
956> [!WARNING]
957> Be careful!
958
959Final text."#;
960        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961        let result = rule.check(&ctx).unwrap();
962        assert!(
963            result.is_empty(),
964            "GFM alerts in mixed document should not trigger warnings"
965        );
966    }
967
968    #[test]
969    fn test_gfm_alert_fix_not_applied() {
970        // When we have GFM alerts, fix should not modify the blank lines
971        let rule = MD028NoBlanksBlockquote;
972        let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
973        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974        let fixed = rule.fix(&ctx).unwrap();
975        assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
976    }
977
978    #[test]
979    fn test_gfm_alert_multiple_blank_lines_between() {
980        // Multiple blank lines between GFM alerts should not be flagged
981        let rule = MD028NoBlanksBlockquote;
982        let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
983        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984        let result = rule.check(&ctx).unwrap();
985        assert!(
986            result.is_empty(),
987            "Should not flag multiple blank lines between GFM alerts"
988        );
989    }
990
991    // ==================== Obsidian Callout Tests ====================
992    // Obsidian callouts use the same > [!TYPE] syntax as GFM alerts, but support
993    // any custom type (not just NOTE, TIP, IMPORTANT, WARNING, CAUTION).
994    // They also support foldable callouts with + or - suffix.
995    // Reference: https://help.obsidian.md/callouts
996
997    #[test]
998    fn test_obsidian_callout_detection() {
999        // Obsidian callouts should be detected
1000        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
1001        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
1002        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
1003        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
1004        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
1005        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
1006        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
1007        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
1008        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
1009        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
1010        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
1011    }
1012
1013    #[test]
1014    fn test_obsidian_callout_custom_types() {
1015        // Obsidian supports custom callout types
1016        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
1017        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
1018        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
1019        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
1020        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
1021    }
1022
1023    #[test]
1024    fn test_obsidian_callout_foldable() {
1025        // Obsidian supports foldable callouts with + or -
1026        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
1027        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1028            "> [!NOTE]- Collapsed"
1029        ));
1030        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
1031        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
1032    }
1033
1034    #[test]
1035    fn test_obsidian_callout_with_title() {
1036        // Obsidian callouts can have custom titles
1037        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1038            "> [!NOTE] Custom Title"
1039        ));
1040        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1041            "> [!WARNING]+ Be Careful!"
1042        ));
1043    }
1044
1045    #[test]
1046    fn test_obsidian_callout_invalid() {
1047        // Invalid callout patterns
1048        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1049            "> Regular blockquote"
1050        ));
1051        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); // missing !
1052        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); // empty type
1053        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1054            "Regular text [!NOTE]"
1055        )); // not blockquote
1056        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); // empty
1057    }
1058
1059    #[test]
1060    fn test_obsidian_callouts_separated_by_blank_line() {
1061        // Obsidian callouts separated by blank line should NOT be flagged
1062        let rule = MD028NoBlanksBlockquote;
1063        let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
1064        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1065        let result = rule.check(&ctx).unwrap();
1066        assert!(
1067            result.is_empty(),
1068            "Should not flag blank line between Obsidian callouts"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_obsidian_custom_callouts_separated() {
1074        // Custom Obsidian callouts should also be recognized
1075        let rule = MD028NoBlanksBlockquote;
1076        let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
1077        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1078        let result = rule.check(&ctx).unwrap();
1079        assert!(
1080            result.is_empty(),
1081            "Should not flag blank line between custom Obsidian callouts"
1082        );
1083    }
1084
1085    #[test]
1086    fn test_obsidian_foldable_callouts_separated() {
1087        // Foldable Obsidian callouts should also be recognized
1088        let rule = MD028NoBlanksBlockquote;
1089        let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1090        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1091        let result = rule.check(&ctx).unwrap();
1092        assert!(
1093            result.is_empty(),
1094            "Should not flag blank line between foldable Obsidian callouts"
1095        );
1096    }
1097
1098    #[test]
1099    fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1100        // Custom callout types should NOT be recognized in Standard flavor
1101        // (only GFM alert types are recognized)
1102        let rule = MD028NoBlanksBlockquote;
1103        let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1104        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1105        let result = rule.check(&ctx).unwrap();
1106        // In Standard flavor, [!info] and [!todo] are NOT GFM alerts, so this is flagged
1107        assert_eq!(
1108            result.len(),
1109            1,
1110            "Custom callout types should be flagged in Standard flavor"
1111        );
1112    }
1113
1114    #[test]
1115    fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1116        // GFM alert types should work in both Standard and Obsidian flavors
1117        let rule = MD028NoBlanksBlockquote;
1118        let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1119
1120        // Standard flavor
1121        let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1122        let result_standard = rule.check(&ctx_standard).unwrap();
1123        assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1124
1125        // Obsidian flavor
1126        let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1127        let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1128        assert!(
1129            result_obsidian.is_empty(),
1130            "GFM alerts should also work in Obsidian flavor"
1131        );
1132    }
1133
1134    #[test]
1135    fn test_obsidian_callout_all_builtin_types() {
1136        // Test all built-in Obsidian callout types
1137        let rule = MD028NoBlanksBlockquote;
1138        let content = r#"> [!note]
1139> Note
1140
1141> [!abstract]
1142> Abstract
1143
1144> [!summary]
1145> Summary
1146
1147> [!info]
1148> Info
1149
1150> [!todo]
1151> Todo
1152
1153> [!tip]
1154> Tip
1155
1156> [!success]
1157> Success
1158
1159> [!question]
1160> Question
1161
1162> [!warning]
1163> Warning
1164
1165> [!failure]
1166> Failure
1167
1168> [!danger]
1169> Danger
1170
1171> [!bug]
1172> Bug
1173
1174> [!example]
1175> Example
1176
1177> [!quote]
1178> Quote"#;
1179        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1180        let result = rule.check(&ctx).unwrap();
1181        assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1182    }
1183
1184    #[test]
1185    fn test_obsidian_fix_not_applied_to_callouts() {
1186        // Fix should not modify blank lines between Obsidian callouts
1187        let rule = MD028NoBlanksBlockquote;
1188        let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1189        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1190        let fixed = rule.fix(&ctx).unwrap();
1191        assert_eq!(
1192            fixed, content,
1193            "Fix should not modify blank lines between Obsidian callouts"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_obsidian_regular_blockquotes_still_flagged() {
1199        // Regular blockquotes (not callouts) should still be flagged in Obsidian flavor
1200        let rule = MD028NoBlanksBlockquote;
1201        let content = "> First blockquote\n\n> Second blockquote";
1202        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1203        let result = rule.check(&ctx).unwrap();
1204        assert_eq!(
1205            result.len(),
1206            1,
1207            "Regular blockquotes should still be flagged in Obsidian flavor"
1208        );
1209    }
1210
1211    #[test]
1212    fn test_obsidian_callout_mixed_with_regular_blockquote() {
1213        // Callout followed by regular blockquote - should NOT flag (callout takes precedence)
1214        let rule = MD028NoBlanksBlockquote;
1215        let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1216        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1217        let result = rule.check(&ctx).unwrap();
1218        assert!(
1219            result.is_empty(),
1220            "Should not flag blank after callout even if followed by regular blockquote"
1221        );
1222    }
1223
1224    // ==================== HTML Comment Skip Tests ====================
1225    // Blockquote-like content inside HTML comments should not be linted.
1226
1227    #[test]
1228    fn test_html_comment_blockquotes_not_flagged() {
1229        let rule = MD028NoBlanksBlockquote;
1230        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";
1231        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1232        let result = rule.check(&ctx).unwrap();
1233        assert!(
1234            result.is_empty(),
1235            "Should not flag blank lines inside HTML comments, got: {result:?}"
1236        );
1237    }
1238
1239    #[test]
1240    fn test_fix_preserves_html_comment_content() {
1241        let rule = MD028NoBlanksBlockquote;
1242        let content = "<!--\n> First quote\n\n> Second quote\n-->\n";
1243        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1244        let fixed = rule.fix(&ctx).unwrap();
1245        assert_eq!(fixed, content, "Fix should not modify content inside HTML comments");
1246    }
1247
1248    #[test]
1249    fn test_multiline_html_comment_with_blockquotes() {
1250        let rule = MD028NoBlanksBlockquote;
1251        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";
1252        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1253        let result = rule.check(&ctx).unwrap();
1254        assert!(
1255            result.is_empty(),
1256            "Should not flag any blank lines inside HTML comments, got: {result:?}"
1257        );
1258    }
1259
1260    #[test]
1261    fn test_blockquotes_outside_html_comment_still_flagged() {
1262        let rule = MD028NoBlanksBlockquote;
1263        let content = "> First quote\n\n> Second quote\n\n<!--\n> Commented quote A\n\n> Commented quote B\n-->\n";
1264        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1265        let result = rule.check(&ctx).unwrap();
1266        // The blank line between the first two blockquotes (outside comment) should be flagged
1267        // but none inside the HTML comment (lines 7 is the blank between commented quotes)
1268        for w in &result {
1269            assert!(
1270                w.line < 5,
1271                "Warning at line {} should not be inside HTML comment",
1272                w.line
1273            );
1274        }
1275        assert!(
1276            !result.is_empty(),
1277            "Should still flag blank line between blockquotes outside HTML comment"
1278        );
1279    }
1280
1281    #[test]
1282    fn test_frontmatter_blockquote_like_content_not_flagged() {
1283        let rule = MD028NoBlanksBlockquote;
1284        let content = "---\n> not a real blockquote\n\n> also not real\n---\n\n# Title\n";
1285        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1286        let result = rule.check(&ctx).unwrap();
1287        assert!(
1288            result.is_empty(),
1289            "Should not flag content inside frontmatter, got: {result:?}"
1290        );
1291    }
1292
1293    #[test]
1294    fn test_comment_boundary_does_not_leak_into_adjacent_blockquotes() {
1295        // A real blockquote before a comment should not be matched with
1296        // a blockquote inside the comment across the <!-- boundary
1297        let rule = MD028NoBlanksBlockquote;
1298        let content = "> real quote\n\n<!--\n> commented quote\n-->\n";
1299        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1300        let result = rule.check(&ctx).unwrap();
1301        assert!(
1302            result.is_empty(),
1303            "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1304        );
1305    }
1306
1307    #[test]
1308    fn test_blockquote_after_comment_boundary_not_matched() {
1309        // A blockquote inside a comment should not be matched with
1310        // a blockquote after the comment across the --> boundary
1311        let rule = MD028NoBlanksBlockquote;
1312        let content = "<!--\n> commented quote\n-->\n\n> real quote\n";
1313        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1314        let result = rule.check(&ctx).unwrap();
1315        assert!(
1316            result.is_empty(),
1317            "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1318        );
1319    }
1320
1321    #[test]
1322    fn test_fix_preserves_comment_boundary_content() {
1323        // Verify fix doesn't modify content when blockquotes straddle a comment boundary
1324        let rule = MD028NoBlanksBlockquote;
1325        let content = "> real quote\n\n<!--\n> commented quote A\n\n> commented quote B\n-->\n\n> another real quote\n";
1326        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1327        let fixed = rule.fix(&ctx).unwrap();
1328        assert_eq!(
1329            fixed, content,
1330            "Fix should not modify content when blockquotes are separated by comment boundaries"
1331        );
1332    }
1333
1334    #[test]
1335    fn test_inline_html_comment_does_not_suppress_warning() {
1336        // Inline HTML comments on a blockquote line should NOT suppress warnings -
1337        // only multi-line HTML comment blocks should
1338        let rule = MD028NoBlanksBlockquote;
1339        let content = "> quote with <!-- inline comment -->\n\n> continuation\n";
1340        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1341        let result = rule.check(&ctx).unwrap();
1342        // This should still be flagged since the blockquotes are not inside an HTML comment block
1343        assert!(
1344            !result.is_empty(),
1345            "Should still flag blank lines between blockquotes with inline HTML comments"
1346        );
1347    }
1348
1349    // ==================== Skip Context Scanning Tests ====================
1350    // Verify that backward/forward scanning in are_likely_same_blockquote()
1351    // and is_problematic_blank_line() properly skips lines in HTML comments,
1352    // code blocks, and frontmatter.
1353
1354    #[test]
1355    fn test_comment_with_blockquote_markers_on_delimiters() {
1356        // The backward scan should not find blockquote lines on HTML comment
1357        // delimiter lines, preventing false positives
1358        let rule = MD028NoBlanksBlockquote;
1359        let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1360        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1361        let result = rule.check(&ctx).unwrap();
1362        // Only the blank between "real quote A" and "real quote B" (line 6) should be flagged
1363        assert_eq!(
1364            result.len(),
1365            1,
1366            "Should only warn about blank between real quotes, got: {result:?}"
1367        );
1368        assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1369    }
1370
1371    #[test]
1372    fn test_commented_blockquote_between_real_blockquotes() {
1373        // A commented-out blockquote between two real blockquotes should act
1374        // as non-blockquote content, preventing them from being considered
1375        // the same blockquote
1376        let rule = MD028NoBlanksBlockquote;
1377        let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1378        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1379        let result = rule.check(&ctx).unwrap();
1380        assert!(
1381            result.is_empty(),
1382            "Should NOT warn when non-blockquote content (HTML comment) separates blockquotes, got: {result:?}"
1383        );
1384    }
1385
1386    #[test]
1387    fn test_code_block_with_blockquote_markers_between_real_blockquotes() {
1388        // Blockquote markers inside code blocks should be ignored by scanning
1389        let rule = MD028NoBlanksBlockquote;
1390        let content = "> real A\n\n```\n> not a blockquote\n```\n\n> real B";
1391        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1392        let result = rule.check(&ctx).unwrap();
1393        assert!(
1394            result.is_empty(),
1395            "Should NOT warn when code block with > markers separates blockquotes, got: {result:?}"
1396        );
1397    }
1398
1399    #[test]
1400    fn test_frontmatter_with_blockquote_markers_does_not_cause_false_positive() {
1401        // Blockquote-like lines in frontmatter should be ignored by scanning
1402        let rule = MD028NoBlanksBlockquote;
1403        let content = "---\n> frontmatter value\n---\n\n> real quote A\n\n> real quote B";
1404        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1405        let result = rule.check(&ctx).unwrap();
1406        // Only the blank between the two real blockquotes should be flagged
1407        assert_eq!(
1408            result.len(),
1409            1,
1410            "Should only flag the blank between real quotes, got: {result:?}"
1411        );
1412        assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1413    }
1414
1415    #[test]
1416    fn test_fix_does_not_modify_comment_separated_blockquotes() {
1417        // Fix should not add > markers when blockquotes are separated by HTML comments
1418        let rule = MD028NoBlanksBlockquote;
1419        let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1420        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1421        let fixed = rule.fix(&ctx).unwrap();
1422        assert_eq!(
1423            fixed, content,
1424            "Fix should not modify content when blockquotes are separated by HTML comment"
1425        );
1426    }
1427
1428    #[test]
1429    fn test_fix_works_correctly_with_comment_before_real_blockquotes() {
1430        // Fix should correctly handle the case where a comment with > markers
1431        // precedes two real blockquotes that have a blank between them
1432        let rule = MD028NoBlanksBlockquote;
1433        let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1434        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1435        let fixed = rule.fix(&ctx).unwrap();
1436        // The blank between the two real quotes should be fixed
1437        assert!(
1438            fixed.contains("> real quote A\n>\n> real quote B"),
1439            "Fix should add > marker between real quotes, got: {fixed}"
1440        );
1441        // The content inside the comment should be untouched
1442        assert!(
1443            fixed.contains("<!-- > not a real blockquote"),
1444            "Fix should not modify comment content"
1445        );
1446    }
1447
1448    #[test]
1449    fn test_html_block_with_angle_brackets_not_flagged() {
1450        // HTML blocks can contain `>` characters (e.g., in nested tags or template syntax)
1451        // that look like blockquote markers. These should be skipped.
1452        let rule = MD028NoBlanksBlockquote;
1453        let content = "<div>\n> not a real blockquote\n\n> also not real\n</div>";
1454        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1455        let result = rule.check(&ctx).unwrap();
1456
1457        assert!(
1458            result.is_empty(),
1459            "Lines inside HTML blocks should not trigger MD028. Got: {result:?}"
1460        );
1461    }
1462
1463    #[test]
1464    fn test_html_block_does_not_leak_into_adjacent_blockquotes() {
1465        // Blockquotes after an HTML block should still be checked
1466        let rule = MD028NoBlanksBlockquote;
1467        let content =
1468            "<details>\n<summary>Click</summary>\n> inside html block\n</details>\n\n> real quote A\n\n> real quote B";
1469        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1470        let result = rule.check(&ctx).unwrap();
1471
1472        // Only the blank between "real quote A" and "real quote B" should be flagged
1473        assert_eq!(
1474            result.len(),
1475            1,
1476            "Expected 1 warning for blank between real blockquotes after HTML block. Got: {result:?}"
1477        );
1478    }
1479}