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 (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_mdx_comment || li.in_html_block || li.in_front_matter
420                {
421                    continue;
422                }
423            }
424
425            if line.trim().is_empty() {
426                blank_line_indices.push(line_idx);
427            } else if Self::is_blockquote_line(line) {
428                has_blockquotes = true;
429            }
430        }
431
432        // If no blockquotes found, no need to check blank lines
433        if !has_blockquotes {
434            return Ok(Vec::new());
435        }
436
437        // Only check blank lines that could be problematic
438        for &line_idx in &blank_line_indices {
439            let line_num = line_idx + 1;
440
441            // Check if this is a problematic blank line inside a blockquote
442            if let Some((level, fix_content)) = Self::is_problematic_blank_line(lines, &ctx.lines, line_idx, ctx.flavor)
443            {
444                let line = lines[line_idx];
445                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
446
447                warnings.push(LintWarning {
448                    rule_name: Some(self.name().to_string()),
449                    message: format!("Blank line inside blockquote (level {level})"),
450                    line: start_line,
451                    column: start_col,
452                    end_line,
453                    end_column: end_col,
454                    severity: Severity::Warning,
455                    fix: Some(Fix {
456                        range: ctx
457                            .line_index
458                            .line_col_to_byte_range_with_length(line_num, 1, line.len()),
459                        replacement: fix_content,
460                    }),
461                });
462            }
463        }
464
465        Ok(warnings)
466    }
467
468    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
469        if self.should_skip(ctx) {
470            return Ok(ctx.content.to_string());
471        }
472        let warnings = self.check(ctx)?;
473        if warnings.is_empty() {
474            return Ok(ctx.content.to_string());
475        }
476        let warnings =
477            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
478        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
479            .map_err(crate::rule::LintError::InvalidInput)
480    }
481
482    /// Get the category of this rule for selective processing
483    fn category(&self) -> RuleCategory {
484        RuleCategory::Blockquote
485    }
486
487    /// Check if this rule should be skipped
488    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
489        !ctx.likely_has_blockquotes()
490    }
491
492    fn as_any(&self) -> &dyn std::any::Any {
493        self
494    }
495
496    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
497    where
498        Self: Sized,
499    {
500        Box::new(MD028NoBlanksBlockquote)
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::lint_context::LintContext;
508
509    #[test]
510    fn test_no_blockquotes() {
511        let rule = MD028NoBlanksBlockquote;
512        let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
513        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514        let result = rule.check(&ctx).unwrap();
515        assert!(result.is_empty(), "Should not flag content without blockquotes");
516    }
517
518    #[test]
519    fn test_valid_blockquote_no_blanks() {
520        let rule = MD028NoBlanksBlockquote;
521        let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
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 blockquotes without blank lines");
525    }
526
527    #[test]
528    fn test_blockquote_with_empty_line_marker() {
529        let rule = MD028NoBlanksBlockquote;
530        // Lines with just > are valid and should NOT be flagged
531        let content = "> First line\n>\n> Third line";
532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533        let result = rule.check(&ctx).unwrap();
534        assert!(result.is_empty(), "Should not flag lines with just > marker");
535    }
536
537    #[test]
538    fn test_blockquote_with_empty_line_marker_and_space() {
539        let rule = MD028NoBlanksBlockquote;
540        // Lines with > and space are also valid
541        let content = "> First line\n> \n> Third line";
542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543        let result = rule.check(&ctx).unwrap();
544        assert!(result.is_empty(), "Should not flag lines with > and space");
545    }
546
547    #[test]
548    fn test_blank_line_in_blockquote() {
549        let rule = MD028NoBlanksBlockquote;
550        // Truly blank line (no >) inside blockquote should be flagged
551        let content = "> First line\n\n> Third line";
552        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553        let result = rule.check(&ctx).unwrap();
554        assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
555        assert_eq!(result[0].line, 2);
556        assert!(result[0].message.contains("Blank line inside blockquote"));
557    }
558
559    #[test]
560    fn test_multiple_blank_lines() {
561        let rule = MD028NoBlanksBlockquote;
562        let content = "> First\n\n\n> Fourth";
563        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564        let result = rule.check(&ctx).unwrap();
565        // With proper indentation checking, both blank lines are flagged as they're within the same blockquote
566        assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
567        assert_eq!(result[0].line, 2);
568        assert_eq!(result[1].line, 3);
569    }
570
571    #[test]
572    fn test_nested_blockquote_blank() {
573        let rule = MD028NoBlanksBlockquote;
574        let content = ">> Nested quote\n\n>> More nested";
575        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576        let result = rule.check(&ctx).unwrap();
577        assert_eq!(result.len(), 1);
578        assert_eq!(result[0].line, 2);
579    }
580
581    #[test]
582    fn test_nested_blockquote_with_marker() {
583        let rule = MD028NoBlanksBlockquote;
584        // Lines with >> are valid
585        let content = ">> Nested quote\n>>\n>> More nested";
586        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587        let result = rule.check(&ctx).unwrap();
588        assert!(result.is_empty(), "Should not flag lines with >> marker");
589    }
590
591    #[test]
592    fn test_fix_single_blank() {
593        let rule = MD028NoBlanksBlockquote;
594        let content = "> First\n\n> Third";
595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596        let fixed = rule.fix(&ctx).unwrap();
597        assert_eq!(fixed, "> First\n>\n> Third");
598    }
599
600    #[test]
601    fn test_fix_nested_blank() {
602        let rule = MD028NoBlanksBlockquote;
603        let content = ">> Nested\n\n>> More";
604        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605        let fixed = rule.fix(&ctx).unwrap();
606        assert_eq!(fixed, ">> Nested\n>>\n>> More");
607    }
608
609    #[test]
610    fn test_fix_with_indentation() {
611        let rule = MD028NoBlanksBlockquote;
612        let content = "  > Indented quote\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, "  > Indented quote\n  >\n  > More");
616    }
617
618    #[test]
619    fn test_mixed_levels() {
620        let rule = MD028NoBlanksBlockquote;
621        // Blank lines between different levels
622        let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
623        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624        let result = rule.check(&ctx).unwrap();
625        // Line 2 is a blank between > and >>, level 1 to level 2, considered inside level 1
626        // Line 4 is a blank between >> and >, level 2 to level 1, NOT inside blockquote
627        assert_eq!(result.len(), 1);
628        assert_eq!(result[0].line, 2);
629    }
630
631    #[test]
632    fn test_blockquote_with_code_block() {
633        let rule = MD028NoBlanksBlockquote;
634        let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
635        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636        let result = rule.check(&ctx).unwrap();
637        // Line 5 has > marker, so it's not a blank line
638        assert!(result.is_empty(), "Should not flag line with > marker");
639    }
640
641    #[test]
642    fn test_category() {
643        let rule = MD028NoBlanksBlockquote;
644        assert_eq!(rule.category(), RuleCategory::Blockquote);
645    }
646
647    #[test]
648    fn test_should_skip() {
649        let rule = MD028NoBlanksBlockquote;
650        let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
651        assert!(rule.should_skip(&ctx1));
652
653        let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
654        assert!(!rule.should_skip(&ctx2));
655    }
656
657    #[test]
658    fn test_empty_content() {
659        let rule = MD028NoBlanksBlockquote;
660        let content = "";
661        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662        let result = rule.check(&ctx).unwrap();
663        assert!(result.is_empty());
664    }
665
666    #[test]
667    fn test_blank_after_blockquote() {
668        let rule = MD028NoBlanksBlockquote;
669        let content = "> Quote\n\nNot a quote";
670        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671        let result = rule.check(&ctx).unwrap();
672        assert!(result.is_empty(), "Blank line after blockquote ends is valid");
673    }
674
675    #[test]
676    fn test_blank_before_blockquote() {
677        let rule = MD028NoBlanksBlockquote;
678        let content = "Not a quote\n\n> 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 before blockquote starts is valid");
682    }
683
684    #[test]
685    fn test_preserve_trailing_newline() {
686        let rule = MD028NoBlanksBlockquote;
687        let content = "> Quote\n\n> More\n";
688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689        let fixed = rule.fix(&ctx).unwrap();
690        assert!(fixed.ends_with('\n'));
691
692        let content_no_newline = "> Quote\n\n> More";
693        let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
694        let fixed2 = rule.fix(&ctx2).unwrap();
695        assert!(!fixed2.ends_with('\n'));
696    }
697
698    #[test]
699    fn test_document_structure_extension() {
700        let rule = MD028NoBlanksBlockquote;
701        let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
702        // Test that the rule works correctly with blockquotes
703        let result = rule.check(&ctx).unwrap();
704        assert!(result.is_empty(), "Should not flag valid blockquote");
705
706        // Test that rule skips content without blockquotes
707        let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
708        assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
709    }
710
711    #[test]
712    fn test_deeply_nested_blank() {
713        let rule = MD028NoBlanksBlockquote;
714        let content = ">>> Deep nest\n\n>>> More deep";
715        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716        let result = rule.check(&ctx).unwrap();
717        assert_eq!(result.len(), 1);
718
719        let fixed = rule.fix(&ctx).unwrap();
720        assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
721    }
722
723    #[test]
724    fn test_deeply_nested_with_marker() {
725        let rule = MD028NoBlanksBlockquote;
726        // Lines with >>> are valid
727        let content = ">>> Deep nest\n>>>\n>>> More deep";
728        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729        let result = rule.check(&ctx).unwrap();
730        assert!(result.is_empty(), "Should not flag lines with >>> marker");
731    }
732
733    #[test]
734    fn test_complex_blockquote_structure() {
735        let rule = MD028NoBlanksBlockquote;
736        // Line with > is valid, not a blank line
737        let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
738        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739        let result = rule.check(&ctx).unwrap();
740        assert!(result.is_empty(), "Should not flag line with > marker");
741    }
742
743    #[test]
744    fn test_complex_with_blank() {
745        let rule = MD028NoBlanksBlockquote;
746        // Blank line between different nesting levels is not flagged
747        // (going from >> back to > is a context change)
748        let content = "> Level 1\n> > Nested\n\n> Back to level 1";
749        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750        let result = rule.check(&ctx).unwrap();
751        assert_eq!(
752            result.len(),
753            0,
754            "Blank between different nesting levels is not inside blockquote"
755        );
756    }
757
758    // ==================== GFM Alert Tests ====================
759    // GitHub Flavored Markdown alerts use the syntax > [!TYPE] where TYPE is
760    // NOTE, TIP, IMPORTANT, WARNING, or CAUTION. These alerts MUST be separated
761    // by blank lines to render correctly on GitHub.
762    // 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
763
764    #[test]
765    fn test_gfm_alert_detection_note() {
766        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
767        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
768        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line(">  [!NOTE]"));
769        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); // case insensitive
770        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); // mixed case
771    }
772
773    #[test]
774    fn test_gfm_alert_detection_all_types() {
775        // All five GFM alert types
776        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
777        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
778        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
779        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
780        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
781    }
782
783    #[test]
784    fn test_gfm_alert_detection_not_alert() {
785        // These should NOT be detected as GFM alerts
786        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
787        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
788        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [NOTE]")); // missing !
789        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!]")); // empty type
790        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("Regular text [!NOTE]")); // not blockquote
791        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("")); // empty
792        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> ")); // empty blockquote
793    }
794
795    #[test]
796    fn test_gfm_alerts_separated_by_blank_line() {
797        // Issue #126 use case: Two GFM alerts separated by blank line should NOT be flagged
798        let rule = MD028NoBlanksBlockquote;
799        let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
800        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801        let result = rule.check(&ctx).unwrap();
802        assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
803    }
804
805    #[test]
806    fn test_gfm_alerts_all_five_types_separated() {
807        // All five alert types in sequence, each separated by blank lines
808        let rule = MD028NoBlanksBlockquote;
809        let content = r#"> [!NOTE]
810> Note content
811
812> [!TIP]
813> Tip content
814
815> [!IMPORTANT]
816> Important content
817
818> [!WARNING]
819> Warning content
820
821> [!CAUTION]
822> Caution content"#;
823        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824        let result = rule.check(&ctx).unwrap();
825        assert!(
826            result.is_empty(),
827            "Should not flag blank lines between any GFM alert types"
828        );
829    }
830
831    #[test]
832    fn test_gfm_alert_with_multiple_lines() {
833        // GFM alert with multiple content lines, then another alert
834        let rule = MD028NoBlanksBlockquote;
835        let content = r#"> [!WARNING]
836> This is a warning
837> with multiple lines
838> of content
839
840> [!NOTE]
841> This is a note"#;
842        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843        let result = rule.check(&ctx).unwrap();
844        assert!(
845            result.is_empty(),
846            "Should not flag blank line between multi-line GFM alerts"
847        );
848    }
849
850    #[test]
851    fn test_gfm_alert_followed_by_regular_blockquote() {
852        // GFM alert followed by regular blockquote - should NOT flag
853        let rule = MD028NoBlanksBlockquote;
854        let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
855        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856        let result = rule.check(&ctx).unwrap();
857        assert!(result.is_empty(), "Should not flag blank line after GFM alert");
858    }
859
860    #[test]
861    fn test_regular_blockquote_followed_by_gfm_alert() {
862        // Regular blockquote followed by GFM alert - should NOT flag
863        let rule = MD028NoBlanksBlockquote;
864        let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
865        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866        let result = rule.check(&ctx).unwrap();
867        assert!(result.is_empty(), "Should not flag blank line before GFM alert");
868    }
869
870    #[test]
871    fn test_regular_blockquotes_still_flagged() {
872        // Regular blockquotes (not GFM alerts) should still be flagged
873        let rule = MD028NoBlanksBlockquote;
874        let content = "> First blockquote\n\n> Second blockquote";
875        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876        let result = rule.check(&ctx).unwrap();
877        assert_eq!(
878            result.len(),
879            1,
880            "Should still flag blank line between regular blockquotes"
881        );
882    }
883
884    #[test]
885    fn test_gfm_alert_blank_line_within_same_alert() {
886        // Blank line WITHIN a single GFM alert should still be flagged
887        // (this is a missing > marker inside the alert)
888        let rule = MD028NoBlanksBlockquote;
889        let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
890        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891        let result = rule.check(&ctx).unwrap();
892        // The second > line is NOT a new alert, so this is a blank within the same blockquote
893        // However, since the first blockquote is a GFM alert, and the second is just continuation,
894        // this could be ambiguous. Current implementation: if first is alert, don't flag.
895        // This is acceptable - user can use > marker on blank line if they want continuation.
896        assert!(
897            result.is_empty(),
898            "GFM alert status propagates to subsequent blockquote lines"
899        );
900    }
901
902    #[test]
903    fn test_gfm_alert_case_insensitive() {
904        let rule = MD028NoBlanksBlockquote;
905        let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
906        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907        let result = rule.check(&ctx).unwrap();
908        assert!(result.is_empty(), "GFM alert detection should be case insensitive");
909    }
910
911    #[test]
912    fn test_gfm_alert_with_nested_blockquote() {
913        // GFM alert doesn't support nesting, but test behavior
914        let rule = MD028NoBlanksBlockquote;
915        let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
916        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917        let result = rule.check(&ctx).unwrap();
918        assert!(
919            result.is_empty(),
920            "Should not flag blank between alerts even with nested content"
921        );
922    }
923
924    #[test]
925    fn test_gfm_alert_indented() {
926        let rule = MD028NoBlanksBlockquote;
927        // Indented GFM alerts (e.g., in a list context)
928        let content = "  > [!NOTE]\n  > Indented note\n\n  > [!TIP]\n  > Indented tip";
929        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930        let result = rule.check(&ctx).unwrap();
931        assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
932    }
933
934    #[test]
935    fn test_gfm_alert_mixed_with_regular_content() {
936        // Mixed document with GFM alerts and regular content
937        let rule = MD028NoBlanksBlockquote;
938        let content = r#"# Heading
939
940Some paragraph.
941
942> [!NOTE]
943> Important note
944
945More paragraph text.
946
947> [!WARNING]
948> Be careful!
949
950Final text."#;
951        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952        let result = rule.check(&ctx).unwrap();
953        assert!(
954            result.is_empty(),
955            "GFM alerts in mixed document should not trigger warnings"
956        );
957    }
958
959    #[test]
960    fn test_gfm_alert_fix_not_applied() {
961        // When we have GFM alerts, fix should not modify the blank lines
962        let rule = MD028NoBlanksBlockquote;
963        let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
964        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965        let fixed = rule.fix(&ctx).unwrap();
966        assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
967    }
968
969    #[test]
970    fn test_gfm_alert_multiple_blank_lines_between() {
971        // Multiple blank lines between GFM alerts should not be flagged
972        let rule = MD028NoBlanksBlockquote;
973        let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
974        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975        let result = rule.check(&ctx).unwrap();
976        assert!(
977            result.is_empty(),
978            "Should not flag multiple blank lines between GFM alerts"
979        );
980    }
981
982    // ==================== Obsidian Callout Tests ====================
983    // Obsidian callouts use the same > [!TYPE] syntax as GFM alerts, but support
984    // any custom type (not just NOTE, TIP, IMPORTANT, WARNING, CAUTION).
985    // They also support foldable callouts with + or - suffix.
986    // Reference: https://help.obsidian.md/callouts
987
988    #[test]
989    fn test_obsidian_callout_detection() {
990        // Obsidian callouts should be detected
991        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
992        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
993        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
994        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
995        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
996        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
997        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
998        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
999        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
1000        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
1001        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
1002    }
1003
1004    #[test]
1005    fn test_obsidian_callout_custom_types() {
1006        // Obsidian supports custom callout types
1007        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
1008        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
1009        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
1010        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
1011        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
1012    }
1013
1014    #[test]
1015    fn test_obsidian_callout_foldable() {
1016        // Obsidian supports foldable callouts with + or -
1017        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
1018        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1019            "> [!NOTE]- Collapsed"
1020        ));
1021        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
1022        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
1023    }
1024
1025    #[test]
1026    fn test_obsidian_callout_with_title() {
1027        // Obsidian callouts can have custom titles
1028        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1029            "> [!NOTE] Custom Title"
1030        ));
1031        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1032            "> [!WARNING]+ Be Careful!"
1033        ));
1034    }
1035
1036    #[test]
1037    fn test_obsidian_callout_invalid() {
1038        // Invalid callout patterns
1039        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1040            "> Regular blockquote"
1041        ));
1042        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); // missing !
1043        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); // empty type
1044        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1045            "Regular text [!NOTE]"
1046        )); // not blockquote
1047        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); // empty
1048    }
1049
1050    #[test]
1051    fn test_obsidian_callouts_separated_by_blank_line() {
1052        // Obsidian callouts separated by blank line should NOT be flagged
1053        let rule = MD028NoBlanksBlockquote;
1054        let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
1055        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1056        let result = rule.check(&ctx).unwrap();
1057        assert!(
1058            result.is_empty(),
1059            "Should not flag blank line between Obsidian callouts"
1060        );
1061    }
1062
1063    #[test]
1064    fn test_obsidian_custom_callouts_separated() {
1065        // Custom Obsidian callouts should also be recognized
1066        let rule = MD028NoBlanksBlockquote;
1067        let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
1068        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1069        let result = rule.check(&ctx).unwrap();
1070        assert!(
1071            result.is_empty(),
1072            "Should not flag blank line between custom Obsidian callouts"
1073        );
1074    }
1075
1076    #[test]
1077    fn test_obsidian_foldable_callouts_separated() {
1078        // Foldable Obsidian callouts should also be recognized
1079        let rule = MD028NoBlanksBlockquote;
1080        let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1081        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1082        let result = rule.check(&ctx).unwrap();
1083        assert!(
1084            result.is_empty(),
1085            "Should not flag blank line between foldable Obsidian callouts"
1086        );
1087    }
1088
1089    #[test]
1090    fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1091        // Custom callout types should NOT be recognized in Standard flavor
1092        // (only GFM alert types are recognized)
1093        let rule = MD028NoBlanksBlockquote;
1094        let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1095        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1096        let result = rule.check(&ctx).unwrap();
1097        // In Standard flavor, [!info] and [!todo] are NOT GFM alerts, so this is flagged
1098        assert_eq!(
1099            result.len(),
1100            1,
1101            "Custom callout types should be flagged in Standard flavor"
1102        );
1103    }
1104
1105    #[test]
1106    fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1107        // GFM alert types should work in both Standard and Obsidian flavors
1108        let rule = MD028NoBlanksBlockquote;
1109        let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1110
1111        // Standard flavor
1112        let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1113        let result_standard = rule.check(&ctx_standard).unwrap();
1114        assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1115
1116        // Obsidian flavor
1117        let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1118        let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1119        assert!(
1120            result_obsidian.is_empty(),
1121            "GFM alerts should also work in Obsidian flavor"
1122        );
1123    }
1124
1125    #[test]
1126    fn test_obsidian_callout_all_builtin_types() {
1127        // Test all built-in Obsidian callout types
1128        let rule = MD028NoBlanksBlockquote;
1129        let content = r#"> [!note]
1130> Note
1131
1132> [!abstract]
1133> Abstract
1134
1135> [!summary]
1136> Summary
1137
1138> [!info]
1139> Info
1140
1141> [!todo]
1142> Todo
1143
1144> [!tip]
1145> Tip
1146
1147> [!success]
1148> Success
1149
1150> [!question]
1151> Question
1152
1153> [!warning]
1154> Warning
1155
1156> [!failure]
1157> Failure
1158
1159> [!danger]
1160> Danger
1161
1162> [!bug]
1163> Bug
1164
1165> [!example]
1166> Example
1167
1168> [!quote]
1169> Quote"#;
1170        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1171        let result = rule.check(&ctx).unwrap();
1172        assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1173    }
1174
1175    #[test]
1176    fn test_obsidian_fix_not_applied_to_callouts() {
1177        // Fix should not modify blank lines between Obsidian callouts
1178        let rule = MD028NoBlanksBlockquote;
1179        let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1180        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1181        let fixed = rule.fix(&ctx).unwrap();
1182        assert_eq!(
1183            fixed, content,
1184            "Fix should not modify blank lines between Obsidian callouts"
1185        );
1186    }
1187
1188    #[test]
1189    fn test_obsidian_regular_blockquotes_still_flagged() {
1190        // Regular blockquotes (not callouts) should still be flagged in Obsidian flavor
1191        let rule = MD028NoBlanksBlockquote;
1192        let content = "> First blockquote\n\n> Second blockquote";
1193        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1194        let result = rule.check(&ctx).unwrap();
1195        assert_eq!(
1196            result.len(),
1197            1,
1198            "Regular blockquotes should still be flagged in Obsidian flavor"
1199        );
1200    }
1201
1202    #[test]
1203    fn test_obsidian_callout_mixed_with_regular_blockquote() {
1204        // Callout followed by regular blockquote - should NOT flag (callout takes precedence)
1205        let rule = MD028NoBlanksBlockquote;
1206        let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1207        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1208        let result = rule.check(&ctx).unwrap();
1209        assert!(
1210            result.is_empty(),
1211            "Should not flag blank after callout even if followed by regular blockquote"
1212        );
1213    }
1214
1215    // ==================== HTML Comment Skip Tests ====================
1216    // Blockquote-like content inside HTML comments should not be linted.
1217
1218    #[test]
1219    fn test_html_comment_blockquotes_not_flagged() {
1220        let rule = MD028NoBlanksBlockquote;
1221        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";
1222        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1223        let result = rule.check(&ctx).unwrap();
1224        assert!(
1225            result.is_empty(),
1226            "Should not flag blank lines inside HTML comments, got: {result:?}"
1227        );
1228    }
1229
1230    #[test]
1231    fn test_fix_preserves_html_comment_content() {
1232        let rule = MD028NoBlanksBlockquote;
1233        let content = "<!--\n> First quote\n\n> Second quote\n-->\n";
1234        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1235        let fixed = rule.fix(&ctx).unwrap();
1236        assert_eq!(fixed, content, "Fix should not modify content inside HTML comments");
1237    }
1238
1239    #[test]
1240    fn test_multiline_html_comment_with_blockquotes() {
1241        let rule = MD028NoBlanksBlockquote;
1242        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";
1243        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1244        let result = rule.check(&ctx).unwrap();
1245        assert!(
1246            result.is_empty(),
1247            "Should not flag any blank lines inside HTML comments, got: {result:?}"
1248        );
1249    }
1250
1251    #[test]
1252    fn test_blockquotes_outside_html_comment_still_flagged() {
1253        let rule = MD028NoBlanksBlockquote;
1254        let content = "> First quote\n\n> Second quote\n\n<!--\n> Commented quote A\n\n> Commented quote B\n-->\n";
1255        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1256        let result = rule.check(&ctx).unwrap();
1257        // The blank line between the first two blockquotes (outside comment) should be flagged
1258        // but none inside the HTML comment (lines 7 is the blank between commented quotes)
1259        for w in &result {
1260            assert!(
1261                w.line < 5,
1262                "Warning at line {} should not be inside HTML comment",
1263                w.line
1264            );
1265        }
1266        assert!(
1267            !result.is_empty(),
1268            "Should still flag blank line between blockquotes outside HTML comment"
1269        );
1270    }
1271
1272    #[test]
1273    fn test_frontmatter_blockquote_like_content_not_flagged() {
1274        let rule = MD028NoBlanksBlockquote;
1275        let content = "---\n> not a real blockquote\n\n> also not real\n---\n\n# Title\n";
1276        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1277        let result = rule.check(&ctx).unwrap();
1278        assert!(
1279            result.is_empty(),
1280            "Should not flag content inside frontmatter, got: {result:?}"
1281        );
1282    }
1283
1284    #[test]
1285    fn test_comment_boundary_does_not_leak_into_adjacent_blockquotes() {
1286        // A real blockquote before a comment should not be matched with
1287        // a blockquote inside the comment across the <!-- boundary
1288        let rule = MD028NoBlanksBlockquote;
1289        let content = "> real quote\n\n<!--\n> commented quote\n-->\n";
1290        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1291        let result = rule.check(&ctx).unwrap();
1292        assert!(
1293            result.is_empty(),
1294            "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1295        );
1296    }
1297
1298    #[test]
1299    fn test_blockquote_after_comment_boundary_not_matched() {
1300        // A blockquote inside a comment should not be matched with
1301        // a blockquote after the comment across the --> boundary
1302        let rule = MD028NoBlanksBlockquote;
1303        let content = "<!--\n> commented quote\n-->\n\n> real quote\n";
1304        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1305        let result = rule.check(&ctx).unwrap();
1306        assert!(
1307            result.is_empty(),
1308            "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1309        );
1310    }
1311
1312    #[test]
1313    fn test_fix_preserves_comment_boundary_content() {
1314        // Verify fix doesn't modify content when blockquotes straddle a comment boundary
1315        let rule = MD028NoBlanksBlockquote;
1316        let content = "> real quote\n\n<!--\n> commented quote A\n\n> commented quote B\n-->\n\n> another real quote\n";
1317        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1318        let fixed = rule.fix(&ctx).unwrap();
1319        assert_eq!(
1320            fixed, content,
1321            "Fix should not modify content when blockquotes are separated by comment boundaries"
1322        );
1323    }
1324
1325    #[test]
1326    fn test_inline_html_comment_does_not_suppress_warning() {
1327        // Inline HTML comments on a blockquote line should NOT suppress warnings -
1328        // only multi-line HTML comment blocks should
1329        let rule = MD028NoBlanksBlockquote;
1330        let content = "> quote with <!-- inline comment -->\n\n> continuation\n";
1331        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1332        let result = rule.check(&ctx).unwrap();
1333        // This should still be flagged since the blockquotes are not inside an HTML comment block
1334        assert!(
1335            !result.is_empty(),
1336            "Should still flag blank lines between blockquotes with inline HTML comments"
1337        );
1338    }
1339
1340    // ==================== Skip Context Scanning Tests ====================
1341    // Verify that backward/forward scanning in are_likely_same_blockquote()
1342    // and is_problematic_blank_line() properly skips lines in HTML comments,
1343    // code blocks, and frontmatter.
1344
1345    #[test]
1346    fn test_comment_with_blockquote_markers_on_delimiters() {
1347        // The backward scan should not find blockquote lines on HTML comment
1348        // delimiter lines, preventing false positives
1349        let rule = MD028NoBlanksBlockquote;
1350        let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1351        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1352        let result = rule.check(&ctx).unwrap();
1353        // Only the blank between "real quote A" and "real quote B" (line 6) should be flagged
1354        assert_eq!(
1355            result.len(),
1356            1,
1357            "Should only warn about blank between real quotes, got: {result:?}"
1358        );
1359        assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1360    }
1361
1362    #[test]
1363    fn test_commented_blockquote_between_real_blockquotes() {
1364        // A commented-out blockquote between two real blockquotes should act
1365        // as non-blockquote content, preventing them from being considered
1366        // the same blockquote
1367        let rule = MD028NoBlanksBlockquote;
1368        let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1369        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1370        let result = rule.check(&ctx).unwrap();
1371        assert!(
1372            result.is_empty(),
1373            "Should NOT warn when non-blockquote content (HTML comment) separates blockquotes, got: {result:?}"
1374        );
1375    }
1376
1377    #[test]
1378    fn test_code_block_with_blockquote_markers_between_real_blockquotes() {
1379        // Blockquote markers inside code blocks should be ignored by scanning
1380        let rule = MD028NoBlanksBlockquote;
1381        let content = "> real A\n\n```\n> not a blockquote\n```\n\n> real B";
1382        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1383        let result = rule.check(&ctx).unwrap();
1384        assert!(
1385            result.is_empty(),
1386            "Should NOT warn when code block with > markers separates blockquotes, got: {result:?}"
1387        );
1388    }
1389
1390    #[test]
1391    fn test_frontmatter_with_blockquote_markers_does_not_cause_false_positive() {
1392        // Blockquote-like lines in frontmatter should be ignored by scanning
1393        let rule = MD028NoBlanksBlockquote;
1394        let content = "---\n> frontmatter value\n---\n\n> real quote A\n\n> real quote B";
1395        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1396        let result = rule.check(&ctx).unwrap();
1397        // Only the blank between the two real blockquotes should be flagged
1398        assert_eq!(
1399            result.len(),
1400            1,
1401            "Should only flag the blank between real quotes, got: {result:?}"
1402        );
1403        assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1404    }
1405
1406    #[test]
1407    fn test_fix_does_not_modify_comment_separated_blockquotes() {
1408        // Fix should not add > markers when blockquotes are separated by HTML comments
1409        let rule = MD028NoBlanksBlockquote;
1410        let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1411        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1412        let fixed = rule.fix(&ctx).unwrap();
1413        assert_eq!(
1414            fixed, content,
1415            "Fix should not modify content when blockquotes are separated by HTML comment"
1416        );
1417    }
1418
1419    #[test]
1420    fn test_fix_works_correctly_with_comment_before_real_blockquotes() {
1421        // Fix should correctly handle the case where a comment with > markers
1422        // precedes two real blockquotes that have a blank between them
1423        let rule = MD028NoBlanksBlockquote;
1424        let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1425        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1426        let fixed = rule.fix(&ctx).unwrap();
1427        // The blank between the two real quotes should be fixed
1428        assert!(
1429            fixed.contains("> real quote A\n>\n> real quote B"),
1430            "Fix should add > marker between real quotes, got: {fixed}"
1431        );
1432        // The content inside the comment should be untouched
1433        assert!(
1434            fixed.contains("<!-- > not a real blockquote"),
1435            "Fix should not modify comment content"
1436        );
1437    }
1438
1439    #[test]
1440    fn test_html_block_with_angle_brackets_not_flagged() {
1441        // HTML blocks can contain `>` characters (e.g., in nested tags or template syntax)
1442        // that look like blockquote markers. These should be skipped.
1443        let rule = MD028NoBlanksBlockquote;
1444        let content = "<div>\n> not a real blockquote\n\n> also not real\n</div>";
1445        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1446        let result = rule.check(&ctx).unwrap();
1447
1448        assert!(
1449            result.is_empty(),
1450            "Lines inside HTML blocks should not trigger MD028. Got: {result:?}"
1451        );
1452    }
1453
1454    // ==================== Roundtrip Safety Tests ====================
1455    // Verify that fix() output, when re-checked, produces zero warnings.
1456
1457    #[test]
1458    fn test_roundtrip_single_blank() {
1459        let rule = MD028NoBlanksBlockquote;
1460        let content = "> First\n\n> Third";
1461        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462        let fixed = rule.fix(&ctx).unwrap();
1463        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1464        let warnings = rule.check(&ctx2).unwrap();
1465        assert!(
1466            warnings.is_empty(),
1467            "Roundtrip should produce zero warnings, got: {warnings:?}"
1468        );
1469    }
1470
1471    #[test]
1472    fn test_roundtrip_multiple_blanks() {
1473        let rule = MD028NoBlanksBlockquote;
1474        let content = "> First\n\n\n> Fourth";
1475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476        let fixed = rule.fix(&ctx).unwrap();
1477        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1478        let warnings = rule.check(&ctx2).unwrap();
1479        assert!(
1480            warnings.is_empty(),
1481            "Roundtrip should produce zero warnings, got: {warnings:?}"
1482        );
1483    }
1484
1485    #[test]
1486    fn test_roundtrip_nested() {
1487        let rule = MD028NoBlanksBlockquote;
1488        let content = ">> Nested\n\n>> More";
1489        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490        let fixed = rule.fix(&ctx).unwrap();
1491        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1492        let warnings = rule.check(&ctx2).unwrap();
1493        assert!(
1494            warnings.is_empty(),
1495            "Roundtrip should produce zero warnings, got: {warnings:?}"
1496        );
1497    }
1498
1499    #[test]
1500    fn test_roundtrip_indented() {
1501        let rule = MD028NoBlanksBlockquote;
1502        let content = "  > Indented\n\n  > More";
1503        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1504        let fixed = rule.fix(&ctx).unwrap();
1505        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1506        let warnings = rule.check(&ctx2).unwrap();
1507        assert!(
1508            warnings.is_empty(),
1509            "Roundtrip should produce zero warnings, got: {warnings:?}"
1510        );
1511    }
1512
1513    #[test]
1514    fn test_roundtrip_deeply_nested() {
1515        let rule = MD028NoBlanksBlockquote;
1516        let content = ">>> Deep\n\n>>> More";
1517        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1518        let fixed = rule.fix(&ctx).unwrap();
1519        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1520        let warnings = rule.check(&ctx2).unwrap();
1521        assert!(
1522            warnings.is_empty(),
1523            "Roundtrip should produce zero warnings, got: {warnings:?}"
1524        );
1525    }
1526
1527    #[test]
1528    fn test_roundtrip_multi_blockquotes() {
1529        let rule = MD028NoBlanksBlockquote;
1530        let content = "> First\n> Line\n\n> Second\n> Line\n\n> Third\n";
1531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1532        let fixed = rule.fix(&ctx).unwrap();
1533        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1534        let warnings = rule.check(&ctx2).unwrap();
1535        assert!(
1536            warnings.is_empty(),
1537            "Roundtrip should produce zero warnings, got: {warnings:?}"
1538        );
1539    }
1540
1541    #[test]
1542    fn test_roundtrip_idempotent() {
1543        let rule = MD028NoBlanksBlockquote;
1544        let content = "> First\n\n> Second\n\n> Third\n";
1545        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1546        let fixed1 = rule.fix(&ctx).unwrap();
1547        let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
1548        let fixed2 = rule.fix(&ctx2).unwrap();
1549        assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1550    }
1551
1552    #[test]
1553    fn test_html_block_does_not_leak_into_adjacent_blockquotes() {
1554        // Blockquotes after an HTML block should still be checked
1555        let rule = MD028NoBlanksBlockquote;
1556        let content =
1557            "<details>\n<summary>Click</summary>\n> inside html block\n</details>\n\n> real quote A\n\n> real quote B";
1558        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1559        let result = rule.check(&ctx).unwrap();
1560
1561        // Only the blank between "real quote A" and "real quote B" should be flagged
1562        assert_eq!(
1563            result.len(),
1564            1,
1565            "Expected 1 warning for blank between real blockquotes after HTML block. Got: {result:?}"
1566        );
1567    }
1568}