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