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::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
18use crate::utils::range_utils::calculate_line_range;
19
20/// GFM Alert types supported by GitHub
21/// 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
22const GFM_ALERT_TYPES: &[&str] = &["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"];
23
24#[derive(Clone)]
25pub struct MD028NoBlanksBlockquote;
26
27impl MD028NoBlanksBlockquote {
28    /// Check if a line is a blockquote line (has > markers)
29    #[inline]
30    fn is_blockquote_line(line: &str) -> bool {
31        // Fast path: check for '>' character before doing any string operations
32        if !line.as_bytes().contains(&b'>') {
33            return false;
34        }
35        line.trim_start().starts_with('>')
36    }
37
38    /// Get the blockquote level (number of > markers) and leading whitespace
39    /// Returns (level, whitespace_end_idx)
40    fn get_blockquote_info(line: &str) -> (usize, usize) {
41        let bytes = line.as_bytes();
42        let mut i = 0;
43
44        // Skip leading whitespace
45        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
46            i += 1;
47        }
48
49        let whitespace_end = i;
50        let mut level = 0;
51
52        // Count '>' markers
53        while i < bytes.len() {
54            if bytes[i] == b'>' {
55                level += 1;
56                i += 1;
57            } else if bytes[i] == b' ' || bytes[i] == b'\t' {
58                i += 1;
59            } else {
60                break;
61            }
62        }
63
64        (level, whitespace_end)
65    }
66
67    /// Check if there's substantive content between two blockquote sections
68    /// This helps distinguish between paragraph breaks and separate blockquotes
69    fn has_content_between(lines: &[&str], start: usize, end: usize) -> bool {
70        for line in lines.iter().take(end).skip(start) {
71            let trimmed = line.trim();
72            // If there's any non-blank, non-blockquote content, these are separate quotes
73            if !trimmed.is_empty() && !trimmed.starts_with('>') {
74                return true;
75            }
76        }
77        false
78    }
79
80    /// Check if a blockquote line is a GFM alert start
81    /// GFM alerts have the format: `> [!TYPE]` where TYPE is NOTE, TIP, IMPORTANT, WARNING, or CAUTION
82    /// 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
83    #[inline]
84    fn is_gfm_alert_line(line: &str) -> bool {
85        // Fast path: must contain '[!' pattern
86        if !line.contains("[!") {
87            return false;
88        }
89
90        // Extract content after the > marker(s)
91        let trimmed = line.trim_start();
92        if !trimmed.starts_with('>') {
93            return false;
94        }
95
96        // Skip all > markers and whitespace to get to content
97        let content = trimmed
98            .trim_start_matches('>')
99            .trim_start_matches([' ', '\t'])
100            .trim_start_matches('>')
101            .trim_start();
102
103        // Check for GFM alert pattern: [!TYPE]
104        if !content.starts_with("[!") {
105            return false;
106        }
107
108        // Extract the alert type
109        if let Some(end_bracket) = content.find(']') {
110            let alert_type = &content[2..end_bracket];
111            return GFM_ALERT_TYPES.iter().any(|&t| t.eq_ignore_ascii_case(alert_type));
112        }
113
114        false
115    }
116
117    /// Check if a blockquote line is an Obsidian callout
118    /// Obsidian callouts have the format: `> [!TYPE]` where TYPE can be any string
119    /// Obsidian also supports foldable callouts: `> [!TYPE]+` (expanded) or `> [!TYPE]-` (collapsed)
120    /// Reference: https://help.obsidian.md/callouts
121    #[inline]
122    fn is_obsidian_callout_line(line: &str) -> bool {
123        // Fast path: must contain '[!' pattern
124        if !line.contains("[!") {
125            return false;
126        }
127
128        // Extract content after the > marker(s)
129        let trimmed = line.trim_start();
130        if !trimmed.starts_with('>') {
131            return false;
132        }
133
134        // Skip all > markers and whitespace to get to content
135        let content = trimmed
136            .trim_start_matches('>')
137            .trim_start_matches([' ', '\t'])
138            .trim_start_matches('>')
139            .trim_start();
140
141        // Check for Obsidian callout pattern: [!TYPE] or [!TYPE]+ or [!TYPE]-
142        if !content.starts_with("[!") {
143            return false;
144        }
145
146        // Find the closing bracket - must have at least one char for TYPE
147        if let Some(end_bracket) = content.find(']') {
148            // TYPE must be at least one character
149            if end_bracket > 2 {
150                // Verify the type contains only valid characters (alphanumeric, hyphen, underscore)
151                let alert_type = &content[2..end_bracket];
152                return !alert_type.is_empty()
153                    && alert_type.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_');
154            }
155        }
156
157        false
158    }
159
160    /// Check if a line is a callout/alert based on the flavor
161    /// For Obsidian flavor: accepts any [!TYPE] pattern
162    /// For other flavors: only accepts GFM alert types
163    #[inline]
164    fn is_callout_line(line: &str, flavor: MarkdownFlavor) -> bool {
165        match flavor {
166            MarkdownFlavor::Obsidian => Self::is_obsidian_callout_line(line),
167            _ => Self::is_gfm_alert_line(line),
168        }
169    }
170
171    /// Find the first line of a blockquote block starting from a given line
172    /// Scans backwards to find where this blockquote block begins
173    fn find_blockquote_start(lines: &[&str], from_idx: usize) -> Option<usize> {
174        if from_idx >= lines.len() {
175            return None;
176        }
177
178        // Start from the given line and scan backwards
179        let mut start_idx = from_idx;
180
181        for i in (0..=from_idx).rev() {
182            let line = lines[i];
183
184            // If it's a blockquote line, update start
185            if Self::is_blockquote_line(line) {
186                start_idx = i;
187            } else if line.trim().is_empty() {
188                // Blank line - check if previous content was blockquote
189                // If we haven't found any blockquote yet, continue
190                if start_idx == from_idx && !Self::is_blockquote_line(lines[from_idx]) {
191                    continue;
192                }
193                // Otherwise, blank line ends this blockquote block
194                break;
195            } else {
196                // Non-blockquote, non-blank line - this ends the blockquote block
197                break;
198            }
199        }
200
201        // Return start only if it's actually a blockquote line
202        if Self::is_blockquote_line(lines[start_idx]) {
203            Some(start_idx)
204        } else {
205            None
206        }
207    }
208
209    /// Check if a blockquote block (starting at given index) is a callout/alert
210    /// For Obsidian flavor: accepts any [!TYPE] pattern
211    /// For other flavors: only accepts GFM alert types
212    fn is_callout_block(lines: &[&str], blockquote_line_idx: usize, flavor: MarkdownFlavor) -> bool {
213        // Find the start of this blockquote block
214        if let Some(start_idx) = Self::find_blockquote_start(lines, blockquote_line_idx) {
215            // Check if the first line of the block is a callout/alert
216            return Self::is_callout_line(lines[start_idx], flavor);
217        }
218        false
219    }
220
221    /// Analyze context to determine if quotes are likely the same or different
222    fn are_likely_same_blockquote(lines: &[&str], blank_idx: usize, flavor: MarkdownFlavor) -> bool {
223        // Look for patterns that suggest these are the same blockquote:
224        // 1. Only one blank line between them (multiple blanks suggest separation)
225        // 2. Same indentation level
226        // 3. No content between them
227        // 4. Similar blockquote levels
228
229        // Note: We flag ALL blank lines between blockquotes, matching markdownlint behavior.
230        // Even multiple consecutive blank lines are flagged as they can be ambiguous
231        // (some parsers treat them as one blockquote, others as separate blockquotes).
232
233        // Find previous and next blockquote lines using fast byte scanning
234        let mut prev_quote_idx = None;
235        let mut next_quote_idx = None;
236
237        // Scan backwards for previous blockquote
238        for i in (0..blank_idx).rev() {
239            let line = lines[i];
240            // Fast check: if no '>' character, skip
241            if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
242                prev_quote_idx = Some(i);
243                break;
244            }
245        }
246
247        // Scan forwards for next blockquote
248        for (i, line) in lines.iter().enumerate().skip(blank_idx + 1) {
249            // Fast check: if no '>' character, skip
250            if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
251                next_quote_idx = Some(i);
252                break;
253            }
254        }
255
256        let (prev_idx, next_idx) = match (prev_quote_idx, next_quote_idx) {
257            (Some(p), Some(n)) => (p, n),
258            _ => return false,
259        };
260
261        // Callout/Alert check: If either blockquote is a callout/alert, treat them as
262        // intentionally separate blockquotes. Callouts MUST be separated by blank lines
263        // to render correctly.
264        // For Obsidian flavor: any [!TYPE] is a callout
265        // For other flavors: only GFM alert types (NOTE, TIP, IMPORTANT, WARNING, CAUTION)
266        let prev_is_callout = Self::is_callout_block(lines, prev_idx, flavor);
267        let next_is_callout = Self::is_callout_block(lines, next_idx, flavor);
268        if prev_is_callout || next_is_callout {
269            return false;
270        }
271
272        // Check for content between blockquotes
273        if Self::has_content_between(lines, prev_idx + 1, next_idx) {
274            return false;
275        }
276
277        // Get blockquote info once per line to avoid repeated parsing
278        let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
279        let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
280
281        // Different levels suggest different contexts
282        // But next_level > prev_level could be nested continuation
283        if next_level < prev_level {
284            return false;
285        }
286
287        // Check indentation consistency using byte indices
288        let prev_line = lines[prev_idx];
289        let next_line = lines[next_idx];
290        let prev_indent = &prev_line[..prev_whitespace_end];
291        let next_indent = &next_line[..next_whitespace_end];
292
293        // Different indentation indicates separate blockquote contexts
294        // Same indentation with no content between = same blockquote (blank line inside)
295        prev_indent == next_indent
296    }
297
298    /// Check if a blank line is problematic (inside a blockquote)
299    fn is_problematic_blank_line(lines: &[&str], index: usize, flavor: MarkdownFlavor) -> Option<(usize, String)> {
300        let current_line = lines[index];
301
302        // Must be a blank line (no content, no > markers)
303        if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
304            return None;
305        }
306
307        // Use heuristics to determine if this blank line is inside a blockquote
308        // or if it's an intentional separator between blockquotes
309        if !Self::are_likely_same_blockquote(lines, index, flavor) {
310            return None;
311        }
312
313        // This blank line appears to be inside a blockquote
314        // Find the appropriate fix using optimized parsing
315        for i in (0..index).rev() {
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                let (level, whitespace_end) = Self::get_blockquote_info(line);
320                let indent = &line[..whitespace_end];
321                let mut fix = String::with_capacity(indent.len() + level);
322                fix.push_str(indent);
323                for _ in 0..level {
324                    fix.push('>');
325                }
326                return Some((level, fix));
327            }
328        }
329
330        None
331    }
332}
333
334impl Default for MD028NoBlanksBlockquote {
335    fn default() -> Self {
336        Self
337    }
338}
339
340impl Rule for MD028NoBlanksBlockquote {
341    fn name(&self) -> &'static str {
342        "MD028"
343    }
344
345    fn description(&self) -> &'static str {
346        "Blank line inside blockquote"
347    }
348
349    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
350        // Early return for content without blockquotes
351        if !ctx.content.contains('>') {
352            return Ok(Vec::new());
353        }
354
355        let mut warnings = Vec::new();
356
357        // Get all lines
358        let lines = ctx.raw_lines();
359
360        // Pre-scan to find blank lines and blockquote lines for faster processing
361        let mut blank_line_indices = Vec::new();
362        let mut has_blockquotes = false;
363
364        for (line_idx, line) in lines.iter().enumerate() {
365            // Skip lines in code blocks
366            if line_idx < ctx.lines.len() && ctx.lines[line_idx].in_code_block {
367                continue;
368            }
369
370            if line.trim().is_empty() {
371                blank_line_indices.push(line_idx);
372            } else if Self::is_blockquote_line(line) {
373                has_blockquotes = true;
374            }
375        }
376
377        // If no blockquotes found, no need to check blank lines
378        if !has_blockquotes {
379            return Ok(Vec::new());
380        }
381
382        // Only check blank lines that could be problematic
383        for &line_idx in &blank_line_indices {
384            let line_num = line_idx + 1;
385
386            // Check if this is a problematic blank line inside a blockquote
387            if let Some((level, fix_content)) = Self::is_problematic_blank_line(lines, line_idx, ctx.flavor) {
388                let line = lines[line_idx];
389                let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
390
391                warnings.push(LintWarning {
392                    rule_name: Some(self.name().to_string()),
393                    message: format!("Blank line inside blockquote (level {level})"),
394                    line: start_line,
395                    column: start_col,
396                    end_line,
397                    end_column: end_col,
398                    severity: Severity::Warning,
399                    fix: Some(Fix {
400                        range: ctx
401                            .line_index
402                            .line_col_to_byte_range_with_length(line_num, 1, line.len()),
403                        replacement: fix_content,
404                    }),
405                });
406            }
407        }
408
409        Ok(warnings)
410    }
411
412    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
413        let mut result = Vec::with_capacity(ctx.lines.len());
414        let lines = ctx.raw_lines();
415
416        for (line_idx, line) in lines.iter().enumerate() {
417            // Check if this blank line needs fixing
418            if let Some((_, fix_content)) = Self::is_problematic_blank_line(lines, line_idx, ctx.flavor) {
419                result.push(fix_content);
420            } else {
421                result.push(line.to_string());
422            }
423        }
424
425        Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
426    }
427
428    /// Get the category of this rule for selective processing
429    fn category(&self) -> RuleCategory {
430        RuleCategory::Blockquote
431    }
432
433    /// Check if this rule should be skipped
434    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
435        !ctx.likely_has_blockquotes()
436    }
437
438    fn as_any(&self) -> &dyn std::any::Any {
439        self
440    }
441
442    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
443    where
444        Self: Sized,
445    {
446        Box::new(MD028NoBlanksBlockquote)
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::lint_context::LintContext;
454
455    #[test]
456    fn test_no_blockquotes() {
457        let rule = MD028NoBlanksBlockquote;
458        let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
459        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460        let result = rule.check(&ctx).unwrap();
461        assert!(result.is_empty(), "Should not flag content without blockquotes");
462    }
463
464    #[test]
465    fn test_valid_blockquote_no_blanks() {
466        let rule = MD028NoBlanksBlockquote;
467        let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
468        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
469        let result = rule.check(&ctx).unwrap();
470        assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
471    }
472
473    #[test]
474    fn test_blockquote_with_empty_line_marker() {
475        let rule = MD028NoBlanksBlockquote;
476        // Lines with just > are valid and should NOT be flagged
477        let content = "> First line\n>\n> Third line";
478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479        let result = rule.check(&ctx).unwrap();
480        assert!(result.is_empty(), "Should not flag lines with just > marker");
481    }
482
483    #[test]
484    fn test_blockquote_with_empty_line_marker_and_space() {
485        let rule = MD028NoBlanksBlockquote;
486        // Lines with > and space are also valid
487        let content = "> First line\n> \n> Third line";
488        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
489        let result = rule.check(&ctx).unwrap();
490        assert!(result.is_empty(), "Should not flag lines with > and space");
491    }
492
493    #[test]
494    fn test_blank_line_in_blockquote() {
495        let rule = MD028NoBlanksBlockquote;
496        // Truly blank line (no >) inside blockquote should be flagged
497        let content = "> First line\n\n> Third line";
498        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499        let result = rule.check(&ctx).unwrap();
500        assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
501        assert_eq!(result[0].line, 2);
502        assert!(result[0].message.contains("Blank line inside blockquote"));
503    }
504
505    #[test]
506    fn test_multiple_blank_lines() {
507        let rule = MD028NoBlanksBlockquote;
508        let content = "> First\n\n\n> Fourth";
509        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510        let result = rule.check(&ctx).unwrap();
511        // With proper indentation checking, both blank lines are flagged as they're within the same blockquote
512        assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
513        assert_eq!(result[0].line, 2);
514        assert_eq!(result[1].line, 3);
515    }
516
517    #[test]
518    fn test_nested_blockquote_blank() {
519        let rule = MD028NoBlanksBlockquote;
520        let content = ">> Nested quote\n\n>> More nested";
521        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522        let result = rule.check(&ctx).unwrap();
523        assert_eq!(result.len(), 1);
524        assert_eq!(result[0].line, 2);
525    }
526
527    #[test]
528    fn test_nested_blockquote_with_marker() {
529        let rule = MD028NoBlanksBlockquote;
530        // Lines with >> are valid
531        let content = ">> Nested quote\n>>\n>> More nested";
532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533        let result = rule.check(&ctx).unwrap();
534        assert!(result.is_empty(), "Should not flag lines with >> marker");
535    }
536
537    #[test]
538    fn test_fix_single_blank() {
539        let rule = MD028NoBlanksBlockquote;
540        let content = "> First\n\n> Third";
541        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542        let fixed = rule.fix(&ctx).unwrap();
543        assert_eq!(fixed, "> First\n>\n> Third");
544    }
545
546    #[test]
547    fn test_fix_nested_blank() {
548        let rule = MD028NoBlanksBlockquote;
549        let content = ">> Nested\n\n>> More";
550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551        let fixed = rule.fix(&ctx).unwrap();
552        assert_eq!(fixed, ">> Nested\n>>\n>> More");
553    }
554
555    #[test]
556    fn test_fix_with_indentation() {
557        let rule = MD028NoBlanksBlockquote;
558        let content = "  > Indented quote\n\n  > More";
559        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560        let fixed = rule.fix(&ctx).unwrap();
561        assert_eq!(fixed, "  > Indented quote\n  >\n  > More");
562    }
563
564    #[test]
565    fn test_mixed_levels() {
566        let rule = MD028NoBlanksBlockquote;
567        // Blank lines between different levels
568        let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
569        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
570        let result = rule.check(&ctx).unwrap();
571        // Line 2 is a blank between > and >>, level 1 to level 2, considered inside level 1
572        // Line 4 is a blank between >> and >, level 2 to level 1, NOT inside blockquote
573        assert_eq!(result.len(), 1);
574        assert_eq!(result[0].line, 2);
575    }
576
577    #[test]
578    fn test_blockquote_with_code_block() {
579        let rule = MD028NoBlanksBlockquote;
580        let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
581        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582        let result = rule.check(&ctx).unwrap();
583        // Line 5 has > marker, so it's not a blank line
584        assert!(result.is_empty(), "Should not flag line with > marker");
585    }
586
587    #[test]
588    fn test_category() {
589        let rule = MD028NoBlanksBlockquote;
590        assert_eq!(rule.category(), RuleCategory::Blockquote);
591    }
592
593    #[test]
594    fn test_should_skip() {
595        let rule = MD028NoBlanksBlockquote;
596        let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
597        assert!(rule.should_skip(&ctx1));
598
599        let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
600        assert!(!rule.should_skip(&ctx2));
601    }
602
603    #[test]
604    fn test_empty_content() {
605        let rule = MD028NoBlanksBlockquote;
606        let content = "";
607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608        let result = rule.check(&ctx).unwrap();
609        assert!(result.is_empty());
610    }
611
612    #[test]
613    fn test_blank_after_blockquote() {
614        let rule = MD028NoBlanksBlockquote;
615        let content = "> Quote\n\nNot a quote";
616        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617        let result = rule.check(&ctx).unwrap();
618        assert!(result.is_empty(), "Blank line after blockquote ends is valid");
619    }
620
621    #[test]
622    fn test_blank_before_blockquote() {
623        let rule = MD028NoBlanksBlockquote;
624        let content = "Not a quote\n\n> Quote";
625        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626        let result = rule.check(&ctx).unwrap();
627        assert!(result.is_empty(), "Blank line before blockquote starts is valid");
628    }
629
630    #[test]
631    fn test_preserve_trailing_newline() {
632        let rule = MD028NoBlanksBlockquote;
633        let content = "> Quote\n\n> More\n";
634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635        let fixed = rule.fix(&ctx).unwrap();
636        assert!(fixed.ends_with('\n'));
637
638        let content_no_newline = "> Quote\n\n> More";
639        let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
640        let fixed2 = rule.fix(&ctx2).unwrap();
641        assert!(!fixed2.ends_with('\n'));
642    }
643
644    #[test]
645    fn test_document_structure_extension() {
646        let rule = MD028NoBlanksBlockquote;
647        let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
648        // Test that the rule works correctly with blockquotes
649        let result = rule.check(&ctx).unwrap();
650        assert!(result.is_empty(), "Should not flag valid blockquote");
651
652        // Test that rule skips content without blockquotes
653        let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
654        assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
655    }
656
657    #[test]
658    fn test_deeply_nested_blank() {
659        let rule = MD028NoBlanksBlockquote;
660        let content = ">>> Deep nest\n\n>>> More deep";
661        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662        let result = rule.check(&ctx).unwrap();
663        assert_eq!(result.len(), 1);
664
665        let fixed = rule.fix(&ctx).unwrap();
666        assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
667    }
668
669    #[test]
670    fn test_deeply_nested_with_marker() {
671        let rule = MD028NoBlanksBlockquote;
672        // Lines with >>> are valid
673        let content = ">>> Deep nest\n>>>\n>>> More deep";
674        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675        let result = rule.check(&ctx).unwrap();
676        assert!(result.is_empty(), "Should not flag lines with >>> marker");
677    }
678
679    #[test]
680    fn test_complex_blockquote_structure() {
681        let rule = MD028NoBlanksBlockquote;
682        // Line with > is valid, not a blank line
683        let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
684        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685        let result = rule.check(&ctx).unwrap();
686        assert!(result.is_empty(), "Should not flag line with > marker");
687    }
688
689    #[test]
690    fn test_complex_with_blank() {
691        let rule = MD028NoBlanksBlockquote;
692        // Blank line between different nesting levels is not flagged
693        // (going from >> back to > is a context change)
694        let content = "> Level 1\n> > Nested\n\n> Back to level 1";
695        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
696        let result = rule.check(&ctx).unwrap();
697        assert_eq!(
698            result.len(),
699            0,
700            "Blank between different nesting levels is not inside blockquote"
701        );
702    }
703
704    // ==================== GFM Alert Tests ====================
705    // GitHub Flavored Markdown alerts use the syntax > [!TYPE] where TYPE is
706    // NOTE, TIP, IMPORTANT, WARNING, or CAUTION. These alerts MUST be separated
707    // by blank lines to render correctly on GitHub.
708    // 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
709
710    #[test]
711    fn test_gfm_alert_detection_note() {
712        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
713        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
714        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line(">  [!NOTE]"));
715        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); // case insensitive
716        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); // mixed case
717    }
718
719    #[test]
720    fn test_gfm_alert_detection_all_types() {
721        // All five GFM alert types
722        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
723        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
724        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
725        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
726        assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
727    }
728
729    #[test]
730    fn test_gfm_alert_detection_not_alert() {
731        // These should NOT be detected as GFM alerts
732        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
733        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
734        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [NOTE]")); // missing !
735        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!]")); // empty type
736        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("Regular text [!NOTE]")); // not blockquote
737        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("")); // empty
738        assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> ")); // empty blockquote
739    }
740
741    #[test]
742    fn test_gfm_alerts_separated_by_blank_line() {
743        // Issue #126 use case: Two GFM alerts separated by blank line should NOT be flagged
744        let rule = MD028NoBlanksBlockquote;
745        let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
746        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747        let result = rule.check(&ctx).unwrap();
748        assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
749    }
750
751    #[test]
752    fn test_gfm_alerts_all_five_types_separated() {
753        // All five alert types in sequence, each separated by blank lines
754        let rule = MD028NoBlanksBlockquote;
755        let content = r#"> [!NOTE]
756> Note content
757
758> [!TIP]
759> Tip content
760
761> [!IMPORTANT]
762> Important content
763
764> [!WARNING]
765> Warning content
766
767> [!CAUTION]
768> Caution content"#;
769        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770        let result = rule.check(&ctx).unwrap();
771        assert!(
772            result.is_empty(),
773            "Should not flag blank lines between any GFM alert types"
774        );
775    }
776
777    #[test]
778    fn test_gfm_alert_with_multiple_lines() {
779        // GFM alert with multiple content lines, then another alert
780        let rule = MD028NoBlanksBlockquote;
781        let content = r#"> [!WARNING]
782> This is a warning
783> with multiple lines
784> of content
785
786> [!NOTE]
787> This is a note"#;
788        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789        let result = rule.check(&ctx).unwrap();
790        assert!(
791            result.is_empty(),
792            "Should not flag blank line between multi-line GFM alerts"
793        );
794    }
795
796    #[test]
797    fn test_gfm_alert_followed_by_regular_blockquote() {
798        // GFM alert followed by regular blockquote - should NOT flag
799        let rule = MD028NoBlanksBlockquote;
800        let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
801        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802        let result = rule.check(&ctx).unwrap();
803        assert!(result.is_empty(), "Should not flag blank line after GFM alert");
804    }
805
806    #[test]
807    fn test_regular_blockquote_followed_by_gfm_alert() {
808        // Regular blockquote followed by GFM alert - should NOT flag
809        let rule = MD028NoBlanksBlockquote;
810        let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
811        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812        let result = rule.check(&ctx).unwrap();
813        assert!(result.is_empty(), "Should not flag blank line before GFM alert");
814    }
815
816    #[test]
817    fn test_regular_blockquotes_still_flagged() {
818        // Regular blockquotes (not GFM alerts) should still be flagged
819        let rule = MD028NoBlanksBlockquote;
820        let content = "> First blockquote\n\n> Second blockquote";
821        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822        let result = rule.check(&ctx).unwrap();
823        assert_eq!(
824            result.len(),
825            1,
826            "Should still flag blank line between regular blockquotes"
827        );
828    }
829
830    #[test]
831    fn test_gfm_alert_blank_line_within_same_alert() {
832        // Blank line WITHIN a single GFM alert should still be flagged
833        // (this is a missing > marker inside the alert)
834        let rule = MD028NoBlanksBlockquote;
835        let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
836        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837        let result = rule.check(&ctx).unwrap();
838        // The second > line is NOT a new alert, so this is a blank within the same blockquote
839        // However, since the first blockquote is a GFM alert, and the second is just continuation,
840        // this could be ambiguous. Current implementation: if first is alert, don't flag.
841        // This is acceptable - user can use > marker on blank line if they want continuation.
842        assert!(
843            result.is_empty(),
844            "GFM alert status propagates to subsequent blockquote lines"
845        );
846    }
847
848    #[test]
849    fn test_gfm_alert_case_insensitive() {
850        let rule = MD028NoBlanksBlockquote;
851        let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
852        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853        let result = rule.check(&ctx).unwrap();
854        assert!(result.is_empty(), "GFM alert detection should be case insensitive");
855    }
856
857    #[test]
858    fn test_gfm_alert_with_nested_blockquote() {
859        // GFM alert doesn't support nesting, but test behavior
860        let rule = MD028NoBlanksBlockquote;
861        let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
862        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
863        let result = rule.check(&ctx).unwrap();
864        assert!(
865            result.is_empty(),
866            "Should not flag blank between alerts even with nested content"
867        );
868    }
869
870    #[test]
871    fn test_gfm_alert_indented() {
872        let rule = MD028NoBlanksBlockquote;
873        // Indented GFM alerts (e.g., in a list context)
874        let content = "  > [!NOTE]\n  > Indented note\n\n  > [!TIP]\n  > Indented tip";
875        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876        let result = rule.check(&ctx).unwrap();
877        assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
878    }
879
880    #[test]
881    fn test_gfm_alert_mixed_with_regular_content() {
882        // Mixed document with GFM alerts and regular content
883        let rule = MD028NoBlanksBlockquote;
884        let content = r#"# Heading
885
886Some paragraph.
887
888> [!NOTE]
889> Important note
890
891More paragraph text.
892
893> [!WARNING]
894> Be careful!
895
896Final text."#;
897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898        let result = rule.check(&ctx).unwrap();
899        assert!(
900            result.is_empty(),
901            "GFM alerts in mixed document should not trigger warnings"
902        );
903    }
904
905    #[test]
906    fn test_gfm_alert_fix_not_applied() {
907        // When we have GFM alerts, fix should not modify the blank lines
908        let rule = MD028NoBlanksBlockquote;
909        let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
910        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911        let fixed = rule.fix(&ctx).unwrap();
912        assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
913    }
914
915    #[test]
916    fn test_gfm_alert_multiple_blank_lines_between() {
917        // Multiple blank lines between GFM alerts should not be flagged
918        let rule = MD028NoBlanksBlockquote;
919        let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
920        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921        let result = rule.check(&ctx).unwrap();
922        assert!(
923            result.is_empty(),
924            "Should not flag multiple blank lines between GFM alerts"
925        );
926    }
927
928    // ==================== Obsidian Callout Tests ====================
929    // Obsidian callouts use the same > [!TYPE] syntax as GFM alerts, but support
930    // any custom type (not just NOTE, TIP, IMPORTANT, WARNING, CAUTION).
931    // They also support foldable callouts with + or - suffix.
932    // Reference: https://help.obsidian.md/callouts
933
934    #[test]
935    fn test_obsidian_callout_detection() {
936        // Obsidian callouts should be detected
937        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
938        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
939        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
940        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
941        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
942        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
943        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
944        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
945        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
946        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
947        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
948    }
949
950    #[test]
951    fn test_obsidian_callout_custom_types() {
952        // Obsidian supports custom callout types
953        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
954        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
955        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
956        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
957        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
958    }
959
960    #[test]
961    fn test_obsidian_callout_foldable() {
962        // Obsidian supports foldable callouts with + or -
963        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
964        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
965            "> [!NOTE]- Collapsed"
966        ));
967        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
968        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
969    }
970
971    #[test]
972    fn test_obsidian_callout_with_title() {
973        // Obsidian callouts can have custom titles
974        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
975            "> [!NOTE] Custom Title"
976        ));
977        assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
978            "> [!WARNING]+ Be Careful!"
979        ));
980    }
981
982    #[test]
983    fn test_obsidian_callout_invalid() {
984        // Invalid callout patterns
985        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
986            "> Regular blockquote"
987        ));
988        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); // missing !
989        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); // empty type
990        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
991            "Regular text [!NOTE]"
992        )); // not blockquote
993        assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); // empty
994    }
995
996    #[test]
997    fn test_obsidian_callouts_separated_by_blank_line() {
998        // Obsidian callouts separated by blank line should NOT be flagged
999        let rule = MD028NoBlanksBlockquote;
1000        let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
1001        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1002        let result = rule.check(&ctx).unwrap();
1003        assert!(
1004            result.is_empty(),
1005            "Should not flag blank line between Obsidian callouts"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_obsidian_custom_callouts_separated() {
1011        // Custom Obsidian callouts should also be recognized
1012        let rule = MD028NoBlanksBlockquote;
1013        let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
1014        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1015        let result = rule.check(&ctx).unwrap();
1016        assert!(
1017            result.is_empty(),
1018            "Should not flag blank line between custom Obsidian callouts"
1019        );
1020    }
1021
1022    #[test]
1023    fn test_obsidian_foldable_callouts_separated() {
1024        // Foldable Obsidian callouts should also be recognized
1025        let rule = MD028NoBlanksBlockquote;
1026        let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1027        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1028        let result = rule.check(&ctx).unwrap();
1029        assert!(
1030            result.is_empty(),
1031            "Should not flag blank line between foldable Obsidian callouts"
1032        );
1033    }
1034
1035    #[test]
1036    fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1037        // Custom callout types should NOT be recognized in Standard flavor
1038        // (only GFM alert types are recognized)
1039        let rule = MD028NoBlanksBlockquote;
1040        let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1041        let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1042        let result = rule.check(&ctx).unwrap();
1043        // In Standard flavor, [!info] and [!todo] are NOT GFM alerts, so this is flagged
1044        assert_eq!(
1045            result.len(),
1046            1,
1047            "Custom callout types should be flagged in Standard flavor"
1048        );
1049    }
1050
1051    #[test]
1052    fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1053        // GFM alert types should work in both Standard and Obsidian flavors
1054        let rule = MD028NoBlanksBlockquote;
1055        let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1056
1057        // Standard flavor
1058        let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1059        let result_standard = rule.check(&ctx_standard).unwrap();
1060        assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1061
1062        // Obsidian flavor
1063        let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1064        let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1065        assert!(
1066            result_obsidian.is_empty(),
1067            "GFM alerts should also work in Obsidian flavor"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_obsidian_callout_all_builtin_types() {
1073        // Test all built-in Obsidian callout types
1074        let rule = MD028NoBlanksBlockquote;
1075        let content = r#"> [!note]
1076> Note
1077
1078> [!abstract]
1079> Abstract
1080
1081> [!summary]
1082> Summary
1083
1084> [!info]
1085> Info
1086
1087> [!todo]
1088> Todo
1089
1090> [!tip]
1091> Tip
1092
1093> [!success]
1094> Success
1095
1096> [!question]
1097> Question
1098
1099> [!warning]
1100> Warning
1101
1102> [!failure]
1103> Failure
1104
1105> [!danger]
1106> Danger
1107
1108> [!bug]
1109> Bug
1110
1111> [!example]
1112> Example
1113
1114> [!quote]
1115> Quote"#;
1116        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1117        let result = rule.check(&ctx).unwrap();
1118        assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1119    }
1120
1121    #[test]
1122    fn test_obsidian_fix_not_applied_to_callouts() {
1123        // Fix should not modify blank lines between Obsidian callouts
1124        let rule = MD028NoBlanksBlockquote;
1125        let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1126        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1127        let fixed = rule.fix(&ctx).unwrap();
1128        assert_eq!(
1129            fixed, content,
1130            "Fix should not modify blank lines between Obsidian callouts"
1131        );
1132    }
1133
1134    #[test]
1135    fn test_obsidian_regular_blockquotes_still_flagged() {
1136        // Regular blockquotes (not callouts) should still be flagged in Obsidian flavor
1137        let rule = MD028NoBlanksBlockquote;
1138        let content = "> First blockquote\n\n> Second blockquote";
1139        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1140        let result = rule.check(&ctx).unwrap();
1141        assert_eq!(
1142            result.len(),
1143            1,
1144            "Regular blockquotes should still be flagged in Obsidian flavor"
1145        );
1146    }
1147
1148    #[test]
1149    fn test_obsidian_callout_mixed_with_regular_blockquote() {
1150        // Callout followed by regular blockquote - should NOT flag (callout takes precedence)
1151        let rule = MD028NoBlanksBlockquote;
1152        let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1153        let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1154        let result = rule.check(&ctx).unwrap();
1155        assert!(
1156            result.is_empty(),
1157            "Should not flag blank after callout even if followed by regular blockquote"
1158        );
1159    }
1160}