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