1use crate::config::MarkdownFlavor;
17use crate::lint_context::LineInfo;
18use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
19use crate::utils::range_utils::calculate_line_range;
20
21const GFM_ALERT_TYPES: &[&str] = &["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"];
24
25#[derive(Clone)]
26pub struct MD028NoBlanksBlockquote;
27
28impl MD028NoBlanksBlockquote {
29 #[inline]
31 fn is_blockquote_line(line: &str) -> bool {
32 if !line.as_bytes().contains(&b'>') {
34 return false;
35 }
36 line.trim_start().starts_with('>')
37 }
38
39 fn get_blockquote_info(line: &str) -> (usize, usize) {
42 let bytes = line.as_bytes();
43 let mut i = 0;
44
45 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
47 i += 1;
48 }
49
50 let whitespace_end = i;
51 let mut level = 0;
52
53 while i < bytes.len() {
55 if bytes[i] == b'>' {
56 level += 1;
57 i += 1;
58 } else if bytes[i] == b' ' || bytes[i] == b'\t' {
59 i += 1;
60 } else {
61 break;
62 }
63 }
64
65 (level, whitespace_end)
66 }
67
68 #[inline]
70 fn is_in_skip_context(line_infos: &[LineInfo], idx: usize) -> bool {
71 if let Some(li) = line_infos.get(idx) {
72 li.in_html_comment || li.in_mdx_comment || li.in_code_block || li.in_html_block || li.in_front_matter
73 } else {
74 false
75 }
76 }
77
78 fn has_content_between(lines: &[&str], line_infos: &[LineInfo], start: usize, end: usize) -> bool {
83 for (offset, line) in lines[start..end].iter().enumerate() {
84 let idx = start + offset;
85 if Self::is_in_skip_context(line_infos, idx) {
88 if !line.trim().is_empty() {
89 return true;
90 }
91 continue;
92 }
93 let trimmed = line.trim();
94 if !trimmed.is_empty() && !trimmed.starts_with('>') {
96 return true;
97 }
98 }
99 false
100 }
101
102 #[inline]
106 fn is_gfm_alert_line(line: &str) -> bool {
107 if !line.contains("[!") {
109 return false;
110 }
111
112 let trimmed = line.trim_start();
114 if !trimmed.starts_with('>') {
115 return false;
116 }
117
118 let content = trimmed
120 .trim_start_matches('>')
121 .trim_start_matches([' ', '\t'])
122 .trim_start_matches('>')
123 .trim_start();
124
125 if !content.starts_with("[!") {
127 return false;
128 }
129
130 if let Some(end_bracket) = content.find(']') {
132 let alert_type = &content[2..end_bracket];
133 return GFM_ALERT_TYPES.iter().any(|&t| t.eq_ignore_ascii_case(alert_type));
134 }
135
136 false
137 }
138
139 #[inline]
144 fn is_obsidian_callout_line(line: &str) -> bool {
145 if !line.contains("[!") {
147 return false;
148 }
149
150 let trimmed = line.trim_start();
152 if !trimmed.starts_with('>') {
153 return false;
154 }
155
156 let content = trimmed
158 .trim_start_matches('>')
159 .trim_start_matches([' ', '\t'])
160 .trim_start_matches('>')
161 .trim_start();
162
163 if !content.starts_with("[!") {
165 return false;
166 }
167
168 if let Some(end_bracket) = content.find(']') {
170 if end_bracket > 2 {
172 let alert_type = &content[2..end_bracket];
174 return !alert_type.is_empty()
175 && alert_type.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_');
176 }
177 }
178
179 false
180 }
181
182 #[inline]
186 fn is_callout_line(line: &str, flavor: MarkdownFlavor) -> bool {
187 match flavor {
188 MarkdownFlavor::Obsidian => Self::is_obsidian_callout_line(line),
189 _ => Self::is_gfm_alert_line(line),
190 }
191 }
192
193 fn find_blockquote_start(lines: &[&str], line_infos: &[LineInfo], from_idx: usize) -> Option<usize> {
196 if from_idx >= lines.len() {
197 return None;
198 }
199
200 let mut start_idx = from_idx;
202
203 for i in (0..=from_idx).rev() {
204 if Self::is_in_skip_context(line_infos, i) {
206 continue;
207 }
208
209 let line = lines[i];
210
211 if Self::is_blockquote_line(line) {
213 start_idx = i;
214 } else if line.trim().is_empty() {
215 if start_idx == from_idx && !Self::is_blockquote_line(lines[from_idx]) {
218 continue;
219 }
220 break;
222 } else {
223 break;
225 }
226 }
227
228 if Self::is_blockquote_line(lines[start_idx]) && !Self::is_in_skip_context(line_infos, start_idx) {
230 Some(start_idx)
231 } else {
232 None
233 }
234 }
235
236 fn is_callout_block(
240 lines: &[&str],
241 line_infos: &[LineInfo],
242 blockquote_line_idx: usize,
243 flavor: MarkdownFlavor,
244 ) -> bool {
245 if let Some(start_idx) = Self::find_blockquote_start(lines, line_infos, blockquote_line_idx) {
247 return Self::is_callout_line(lines[start_idx], flavor);
249 }
250 false
251 }
252
253 fn are_likely_same_blockquote(
255 lines: &[&str],
256 line_infos: &[LineInfo],
257 blank_idx: usize,
258 flavor: MarkdownFlavor,
259 ) -> bool {
260 let mut prev_quote_idx = None;
272 let mut next_quote_idx = None;
273
274 for i in (0..blank_idx).rev() {
276 if Self::is_in_skip_context(line_infos, i) {
277 continue;
278 }
279 let line = lines[i];
280 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
282 prev_quote_idx = Some(i);
283 break;
284 }
285 }
286
287 for (i, line) in lines.iter().enumerate().skip(blank_idx + 1) {
289 if Self::is_in_skip_context(line_infos, i) {
290 continue;
291 }
292 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
294 next_quote_idx = Some(i);
295 break;
296 }
297 }
298
299 let (Some(prev_idx), Some(next_idx)) = (prev_quote_idx, next_quote_idx) else {
300 return false;
301 };
302
303 let prev_is_callout = Self::is_callout_block(lines, line_infos, prev_idx, flavor);
309 let next_is_callout = Self::is_callout_block(lines, line_infos, next_idx, flavor);
310 if prev_is_callout || next_is_callout {
311 return false;
312 }
313
314 if Self::has_content_between(lines, line_infos, prev_idx + 1, next_idx) {
316 return false;
317 }
318
319 let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
321 let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
322
323 if next_level < prev_level {
326 return false;
327 }
328
329 let prev_line = lines[prev_idx];
331 let next_line = lines[next_idx];
332 let prev_indent = &prev_line[..prev_whitespace_end];
333 let next_indent = &next_line[..next_whitespace_end];
334
335 prev_indent == next_indent
338 }
339
340 fn is_problematic_blank_line(
342 lines: &[&str],
343 line_infos: &[LineInfo],
344 index: usize,
345 flavor: MarkdownFlavor,
346 ) -> Option<(usize, String)> {
347 let current_line = lines[index];
348
349 if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
351 return None;
352 }
353
354 if !Self::are_likely_same_blockquote(lines, line_infos, index, flavor) {
357 return None;
358 }
359
360 for i in (0..index).rev() {
363 if Self::is_in_skip_context(line_infos, i) {
364 continue;
365 }
366 let line = lines[i];
367 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
369 let (level, whitespace_end) = Self::get_blockquote_info(line);
370 let indent = &line[..whitespace_end];
371 let mut fix = String::with_capacity(indent.len() + level);
372 fix.push_str(indent);
373 for _ in 0..level {
374 fix.push('>');
375 }
376 return Some((level, fix));
377 }
378 }
379
380 None
381 }
382}
383
384impl Default for MD028NoBlanksBlockquote {
385 fn default() -> Self {
386 Self
387 }
388}
389
390impl Rule for MD028NoBlanksBlockquote {
391 fn name(&self) -> &'static str {
392 "MD028"
393 }
394
395 fn description(&self) -> &'static str {
396 "Blank line inside blockquote"
397 }
398
399 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
400 if !ctx.content.contains('>') {
402 return Ok(Vec::new());
403 }
404
405 let mut warnings = Vec::new();
406
407 let lines = ctx.raw_lines();
409
410 let mut blank_line_indices = Vec::new();
412 let mut has_blockquotes = false;
413
414 for (line_idx, line) in lines.iter().enumerate() {
415 if line_idx < ctx.lines.len() {
417 let li = &ctx.lines[line_idx];
418 if li.in_code_block || li.in_html_comment || li.in_mdx_comment || li.in_html_block || li.in_front_matter
419 {
420 continue;
421 }
422 }
423
424 if line.trim().is_empty() {
425 blank_line_indices.push(line_idx);
426 } else if Self::is_blockquote_line(line) {
427 has_blockquotes = true;
428 }
429 }
430
431 if !has_blockquotes {
433 return Ok(Vec::new());
434 }
435
436 for &line_idx in &blank_line_indices {
438 let line_num = line_idx + 1;
439
440 if let Some((level, fix_content)) = Self::is_problematic_blank_line(lines, &ctx.lines, line_idx, ctx.flavor)
442 {
443 let line = lines[line_idx];
444 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
445
446 warnings.push(LintWarning {
447 rule_name: Some(self.name().to_string()),
448 message: format!("Blank line inside blockquote (level {level})"),
449 line: start_line,
450 column: start_col,
451 end_line,
452 end_column: end_col,
453 severity: Severity::Warning,
454 fix: Some(Fix {
455 range: ctx
456 .line_index
457 .line_col_to_byte_range_with_length(line_num, 1, line.len()),
458 replacement: fix_content,
459 }),
460 });
461 }
462 }
463
464 Ok(warnings)
465 }
466
467 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
468 if self.should_skip(ctx) {
469 return Ok(ctx.content.to_string());
470 }
471 let warnings = self.check(ctx)?;
472 if warnings.is_empty() {
473 return Ok(ctx.content.to_string());
474 }
475 let warnings =
476 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
477 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
478 .map_err(crate::rule::LintError::InvalidInput)
479 }
480
481 fn category(&self) -> RuleCategory {
483 RuleCategory::Blockquote
484 }
485
486 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
488 !ctx.likely_has_blockquotes()
489 }
490
491 fn as_any(&self) -> &dyn std::any::Any {
492 self
493 }
494
495 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
496 where
497 Self: Sized,
498 {
499 Box::new(MD028NoBlanksBlockquote)
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506 use crate::lint_context::LintContext;
507
508 #[test]
509 fn test_no_blockquotes() {
510 let rule = MD028NoBlanksBlockquote;
511 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513 let result = rule.check(&ctx).unwrap();
514 assert!(result.is_empty(), "Should not flag content without blockquotes");
515 }
516
517 #[test]
518 fn test_valid_blockquote_no_blanks() {
519 let rule = MD028NoBlanksBlockquote;
520 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522 let result = rule.check(&ctx).unwrap();
523 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
524 }
525
526 #[test]
527 fn test_blockquote_with_empty_line_marker() {
528 let rule = MD028NoBlanksBlockquote;
529 let content = "> First line\n>\n> Third line";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.check(&ctx).unwrap();
533 assert!(result.is_empty(), "Should not flag lines with just > marker");
534 }
535
536 #[test]
537 fn test_blockquote_with_empty_line_marker_and_space() {
538 let rule = MD028NoBlanksBlockquote;
539 let content = "> First line\n> \n> Third line";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let result = rule.check(&ctx).unwrap();
543 assert!(result.is_empty(), "Should not flag lines with > and space");
544 }
545
546 #[test]
547 fn test_blank_line_in_blockquote() {
548 let rule = MD028NoBlanksBlockquote;
549 let content = "> First line\n\n> Third line";
551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552 let result = rule.check(&ctx).unwrap();
553 assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
554 assert_eq!(result[0].line, 2);
555 assert!(result[0].message.contains("Blank line inside blockquote"));
556 }
557
558 #[test]
559 fn test_multiple_blank_lines() {
560 let rule = MD028NoBlanksBlockquote;
561 let content = "> First\n\n\n> Fourth";
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563 let result = rule.check(&ctx).unwrap();
564 assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
566 assert_eq!(result[0].line, 2);
567 assert_eq!(result[1].line, 3);
568 }
569
570 #[test]
571 fn test_nested_blockquote_blank() {
572 let rule = MD028NoBlanksBlockquote;
573 let content = ">> Nested quote\n\n>> More nested";
574 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575 let result = rule.check(&ctx).unwrap();
576 assert_eq!(result.len(), 1);
577 assert_eq!(result[0].line, 2);
578 }
579
580 #[test]
581 fn test_nested_blockquote_with_marker() {
582 let rule = MD028NoBlanksBlockquote;
583 let content = ">> Nested quote\n>>\n>> More nested";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 let result = rule.check(&ctx).unwrap();
587 assert!(result.is_empty(), "Should not flag lines with >> marker");
588 }
589
590 #[test]
591 fn test_fix_single_blank() {
592 let rule = MD028NoBlanksBlockquote;
593 let content = "> First\n\n> Third";
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let fixed = rule.fix(&ctx).unwrap();
596 assert_eq!(fixed, "> First\n>\n> Third");
597 }
598
599 #[test]
600 fn test_fix_nested_blank() {
601 let rule = MD028NoBlanksBlockquote;
602 let content = ">> Nested\n\n>> More";
603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
604 let fixed = rule.fix(&ctx).unwrap();
605 assert_eq!(fixed, ">> Nested\n>>\n>> More");
606 }
607
608 #[test]
609 fn test_fix_with_indentation() {
610 let rule = MD028NoBlanksBlockquote;
611 let content = " > Indented quote\n\n > More";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let fixed = rule.fix(&ctx).unwrap();
614 assert_eq!(fixed, " > Indented quote\n >\n > More");
615 }
616
617 #[test]
618 fn test_mixed_levels() {
619 let rule = MD028NoBlanksBlockquote;
620 let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let result = rule.check(&ctx).unwrap();
624 assert_eq!(result.len(), 1);
627 assert_eq!(result[0].line, 2);
628 }
629
630 #[test]
631 fn test_blockquote_with_code_block() {
632 let rule = MD028NoBlanksBlockquote;
633 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635 let result = rule.check(&ctx).unwrap();
636 assert!(result.is_empty(), "Should not flag line with > marker");
638 }
639
640 #[test]
641 fn test_category() {
642 let rule = MD028NoBlanksBlockquote;
643 assert_eq!(rule.category(), RuleCategory::Blockquote);
644 }
645
646 #[test]
647 fn test_should_skip() {
648 let rule = MD028NoBlanksBlockquote;
649 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
650 assert!(rule.should_skip(&ctx1));
651
652 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
653 assert!(!rule.should_skip(&ctx2));
654 }
655
656 #[test]
657 fn test_empty_content() {
658 let rule = MD028NoBlanksBlockquote;
659 let content = "";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662 assert!(result.is_empty());
663 }
664
665 #[test]
666 fn test_blank_after_blockquote() {
667 let rule = MD028NoBlanksBlockquote;
668 let content = "> Quote\n\nNot a quote";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670 let result = rule.check(&ctx).unwrap();
671 assert!(result.is_empty(), "Blank line after blockquote ends is valid");
672 }
673
674 #[test]
675 fn test_blank_before_blockquote() {
676 let rule = MD028NoBlanksBlockquote;
677 let content = "Not a quote\n\n> Quote";
678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.check(&ctx).unwrap();
680 assert!(result.is_empty(), "Blank line before blockquote starts is valid");
681 }
682
683 #[test]
684 fn test_preserve_trailing_newline() {
685 let rule = MD028NoBlanksBlockquote;
686 let content = "> Quote\n\n> More\n";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688 let fixed = rule.fix(&ctx).unwrap();
689 assert!(fixed.ends_with('\n'));
690
691 let content_no_newline = "> Quote\n\n> More";
692 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
693 let fixed2 = rule.fix(&ctx2).unwrap();
694 assert!(!fixed2.ends_with('\n'));
695 }
696
697 #[test]
698 fn test_document_structure_extension() {
699 let rule = MD028NoBlanksBlockquote;
700 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.check(&ctx).unwrap();
703 assert!(result.is_empty(), "Should not flag valid blockquote");
704
705 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
707 assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
708 }
709
710 #[test]
711 fn test_deeply_nested_blank() {
712 let rule = MD028NoBlanksBlockquote;
713 let content = ">>> Deep nest\n\n>>> More deep";
714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
715 let result = rule.check(&ctx).unwrap();
716 assert_eq!(result.len(), 1);
717
718 let fixed = rule.fix(&ctx).unwrap();
719 assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
720 }
721
722 #[test]
723 fn test_deeply_nested_with_marker() {
724 let rule = MD028NoBlanksBlockquote;
725 let content = ">>> Deep nest\n>>>\n>>> More deep";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728 let result = rule.check(&ctx).unwrap();
729 assert!(result.is_empty(), "Should not flag lines with >>> marker");
730 }
731
732 #[test]
733 fn test_complex_blockquote_structure() {
734 let rule = MD028NoBlanksBlockquote;
735 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739 assert!(result.is_empty(), "Should not flag line with > marker");
740 }
741
742 #[test]
743 fn test_complex_with_blank() {
744 let rule = MD028NoBlanksBlockquote;
745 let content = "> Level 1\n> > Nested\n\n> Back to level 1";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert_eq!(
751 result.len(),
752 0,
753 "Blank between different nesting levels is not inside blockquote"
754 );
755 }
756
757 #[test]
764 fn test_gfm_alert_detection_note() {
765 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
766 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
767 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
768 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); }
771
772 #[test]
773 fn test_gfm_alert_detection_all_types() {
774 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
776 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
777 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
778 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
779 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
780 }
781
782 #[test]
783 fn test_gfm_alert_detection_not_alert() {
784 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
786 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
787 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("Regular text [!NOTE]")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("")); assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> ")); }
793
794 #[test]
795 fn test_gfm_alerts_separated_by_blank_line() {
796 let rule = MD028NoBlanksBlockquote;
798 let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800 let result = rule.check(&ctx).unwrap();
801 assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
802 }
803
804 #[test]
805 fn test_gfm_alerts_all_five_types_separated() {
806 let rule = MD028NoBlanksBlockquote;
808 let content = r#"> [!NOTE]
809> Note content
810
811> [!TIP]
812> Tip content
813
814> [!IMPORTANT]
815> Important content
816
817> [!WARNING]
818> Warning content
819
820> [!CAUTION]
821> Caution content"#;
822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823 let result = rule.check(&ctx).unwrap();
824 assert!(
825 result.is_empty(),
826 "Should not flag blank lines between any GFM alert types"
827 );
828 }
829
830 #[test]
831 fn test_gfm_alert_with_multiple_lines() {
832 let rule = MD028NoBlanksBlockquote;
834 let content = r#"> [!WARNING]
835> This is a warning
836> with multiple lines
837> of content
838
839> [!NOTE]
840> This is a note"#;
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843 assert!(
844 result.is_empty(),
845 "Should not flag blank line between multi-line GFM alerts"
846 );
847 }
848
849 #[test]
850 fn test_gfm_alert_followed_by_regular_blockquote() {
851 let rule = MD028NoBlanksBlockquote;
853 let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let result = rule.check(&ctx).unwrap();
856 assert!(result.is_empty(), "Should not flag blank line after GFM alert");
857 }
858
859 #[test]
860 fn test_regular_blockquote_followed_by_gfm_alert() {
861 let rule = MD028NoBlanksBlockquote;
863 let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx).unwrap();
866 assert!(result.is_empty(), "Should not flag blank line before GFM alert");
867 }
868
869 #[test]
870 fn test_regular_blockquotes_still_flagged() {
871 let rule = MD028NoBlanksBlockquote;
873 let content = "> First blockquote\n\n> Second blockquote";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 let result = rule.check(&ctx).unwrap();
876 assert_eq!(
877 result.len(),
878 1,
879 "Should still flag blank line between regular blockquotes"
880 );
881 }
882
883 #[test]
884 fn test_gfm_alert_blank_line_within_same_alert() {
885 let rule = MD028NoBlanksBlockquote;
888 let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let result = rule.check(&ctx).unwrap();
891 assert!(
896 result.is_empty(),
897 "GFM alert status propagates to subsequent blockquote lines"
898 );
899 }
900
901 #[test]
902 fn test_gfm_alert_case_insensitive() {
903 let rule = MD028NoBlanksBlockquote;
904 let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906 let result = rule.check(&ctx).unwrap();
907 assert!(result.is_empty(), "GFM alert detection should be case insensitive");
908 }
909
910 #[test]
911 fn test_gfm_alert_with_nested_blockquote() {
912 let rule = MD028NoBlanksBlockquote;
914 let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let result = rule.check(&ctx).unwrap();
917 assert!(
918 result.is_empty(),
919 "Should not flag blank between alerts even with nested content"
920 );
921 }
922
923 #[test]
924 fn test_gfm_alert_indented() {
925 let rule = MD028NoBlanksBlockquote;
926 let content = " > [!NOTE]\n > Indented note\n\n > [!TIP]\n > Indented tip";
928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
929 let result = rule.check(&ctx).unwrap();
930 assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
931 }
932
933 #[test]
934 fn test_gfm_alert_mixed_with_regular_content() {
935 let rule = MD028NoBlanksBlockquote;
937 let content = r#"# Heading
938
939Some paragraph.
940
941> [!NOTE]
942> Important note
943
944More paragraph text.
945
946> [!WARNING]
947> Be careful!
948
949Final text."#;
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951 let result = rule.check(&ctx).unwrap();
952 assert!(
953 result.is_empty(),
954 "GFM alerts in mixed document should not trigger warnings"
955 );
956 }
957
958 #[test]
959 fn test_gfm_alert_fix_not_applied() {
960 let rule = MD028NoBlanksBlockquote;
962 let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964 let fixed = rule.fix(&ctx).unwrap();
965 assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
966 }
967
968 #[test]
969 fn test_gfm_alert_multiple_blank_lines_between() {
970 let rule = MD028NoBlanksBlockquote;
972 let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let result = rule.check(&ctx).unwrap();
975 assert!(
976 result.is_empty(),
977 "Should not flag multiple blank lines between GFM alerts"
978 );
979 }
980
981 #[test]
988 fn test_obsidian_callout_detection() {
989 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
991 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
992 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
993 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
994 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
995 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
996 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
997 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
998 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
999 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
1000 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
1001 }
1002
1003 #[test]
1004 fn test_obsidian_callout_custom_types() {
1005 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
1007 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
1008 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
1009 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
1010 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
1011 }
1012
1013 #[test]
1014 fn test_obsidian_callout_foldable() {
1015 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
1017 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1018 "> [!NOTE]- Collapsed"
1019 ));
1020 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
1021 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
1022 }
1023
1024 #[test]
1025 fn test_obsidian_callout_with_title() {
1026 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1028 "> [!NOTE] Custom Title"
1029 ));
1030 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1031 "> [!WARNING]+ Be Careful!"
1032 ));
1033 }
1034
1035 #[test]
1036 fn test_obsidian_callout_invalid() {
1037 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1039 "> Regular blockquote"
1040 ));
1041 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1044 "Regular text [!NOTE]"
1045 )); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); }
1048
1049 #[test]
1050 fn test_obsidian_callouts_separated_by_blank_line() {
1051 let rule = MD028NoBlanksBlockquote;
1053 let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
1054 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1055 let result = rule.check(&ctx).unwrap();
1056 assert!(
1057 result.is_empty(),
1058 "Should not flag blank line between Obsidian callouts"
1059 );
1060 }
1061
1062 #[test]
1063 fn test_obsidian_custom_callouts_separated() {
1064 let rule = MD028NoBlanksBlockquote;
1066 let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
1067 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1068 let result = rule.check(&ctx).unwrap();
1069 assert!(
1070 result.is_empty(),
1071 "Should not flag blank line between custom Obsidian callouts"
1072 );
1073 }
1074
1075 #[test]
1076 fn test_obsidian_foldable_callouts_separated() {
1077 let rule = MD028NoBlanksBlockquote;
1079 let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1080 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1081 let result = rule.check(&ctx).unwrap();
1082 assert!(
1083 result.is_empty(),
1084 "Should not flag blank line between foldable Obsidian callouts"
1085 );
1086 }
1087
1088 #[test]
1089 fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1090 let rule = MD028NoBlanksBlockquote;
1093 let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1094 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1095 let result = rule.check(&ctx).unwrap();
1096 assert_eq!(
1098 result.len(),
1099 1,
1100 "Custom callout types should be flagged in Standard flavor"
1101 );
1102 }
1103
1104 #[test]
1105 fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1106 let rule = MD028NoBlanksBlockquote;
1108 let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1109
1110 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1112 let result_standard = rule.check(&ctx_standard).unwrap();
1113 assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1114
1115 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1117 let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1118 assert!(
1119 result_obsidian.is_empty(),
1120 "GFM alerts should also work in Obsidian flavor"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_obsidian_callout_all_builtin_types() {
1126 let rule = MD028NoBlanksBlockquote;
1128 let content = r#"> [!note]
1129> Note
1130
1131> [!abstract]
1132> Abstract
1133
1134> [!summary]
1135> Summary
1136
1137> [!info]
1138> Info
1139
1140> [!todo]
1141> Todo
1142
1143> [!tip]
1144> Tip
1145
1146> [!success]
1147> Success
1148
1149> [!question]
1150> Question
1151
1152> [!warning]
1153> Warning
1154
1155> [!failure]
1156> Failure
1157
1158> [!danger]
1159> Danger
1160
1161> [!bug]
1162> Bug
1163
1164> [!example]
1165> Example
1166
1167> [!quote]
1168> Quote"#;
1169 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1170 let result = rule.check(&ctx).unwrap();
1171 assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1172 }
1173
1174 #[test]
1175 fn test_obsidian_fix_not_applied_to_callouts() {
1176 let rule = MD028NoBlanksBlockquote;
1178 let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1179 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1180 let fixed = rule.fix(&ctx).unwrap();
1181 assert_eq!(
1182 fixed, content,
1183 "Fix should not modify blank lines between Obsidian callouts"
1184 );
1185 }
1186
1187 #[test]
1188 fn test_obsidian_regular_blockquotes_still_flagged() {
1189 let rule = MD028NoBlanksBlockquote;
1191 let content = "> First blockquote\n\n> Second blockquote";
1192 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1193 let result = rule.check(&ctx).unwrap();
1194 assert_eq!(
1195 result.len(),
1196 1,
1197 "Regular blockquotes should still be flagged in Obsidian flavor"
1198 );
1199 }
1200
1201 #[test]
1202 fn test_obsidian_callout_mixed_with_regular_blockquote() {
1203 let rule = MD028NoBlanksBlockquote;
1205 let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1206 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1207 let result = rule.check(&ctx).unwrap();
1208 assert!(
1209 result.is_empty(),
1210 "Should not flag blank after callout even if followed by regular blockquote"
1211 );
1212 }
1213
1214 #[test]
1218 fn test_html_comment_blockquotes_not_flagged() {
1219 let rule = MD028NoBlanksBlockquote;
1220 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";
1221 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1222 let result = rule.check(&ctx).unwrap();
1223 assert!(
1224 result.is_empty(),
1225 "Should not flag blank lines inside HTML comments, got: {result:?}"
1226 );
1227 }
1228
1229 #[test]
1230 fn test_fix_preserves_html_comment_content() {
1231 let rule = MD028NoBlanksBlockquote;
1232 let content = "<!--\n> First quote\n\n> Second quote\n-->\n";
1233 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1234 let fixed = rule.fix(&ctx).unwrap();
1235 assert_eq!(fixed, content, "Fix should not modify content inside HTML comments");
1236 }
1237
1238 #[test]
1239 fn test_multiline_html_comment_with_blockquotes() {
1240 let rule = MD028NoBlanksBlockquote;
1241 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";
1242 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1243 let result = rule.check(&ctx).unwrap();
1244 assert!(
1245 result.is_empty(),
1246 "Should not flag any blank lines inside HTML comments, got: {result:?}"
1247 );
1248 }
1249
1250 #[test]
1251 fn test_blockquotes_outside_html_comment_still_flagged() {
1252 let rule = MD028NoBlanksBlockquote;
1253 let content = "> First quote\n\n> Second quote\n\n<!--\n> Commented quote A\n\n> Commented quote B\n-->\n";
1254 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1255 let result = rule.check(&ctx).unwrap();
1256 for w in &result {
1259 assert!(
1260 w.line < 5,
1261 "Warning at line {} should not be inside HTML comment",
1262 w.line
1263 );
1264 }
1265 assert!(
1266 !result.is_empty(),
1267 "Should still flag blank line between blockquotes outside HTML comment"
1268 );
1269 }
1270
1271 #[test]
1272 fn test_frontmatter_blockquote_like_content_not_flagged() {
1273 let rule = MD028NoBlanksBlockquote;
1274 let content = "---\n> not a real blockquote\n\n> also not real\n---\n\n# Title\n";
1275 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1276 let result = rule.check(&ctx).unwrap();
1277 assert!(
1278 result.is_empty(),
1279 "Should not flag content inside frontmatter, got: {result:?}"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_comment_boundary_does_not_leak_into_adjacent_blockquotes() {
1285 let rule = MD028NoBlanksBlockquote;
1288 let content = "> real quote\n\n<!--\n> commented quote\n-->\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 match blockquotes across HTML comment boundaries, got: {result:?}"
1294 );
1295 }
1296
1297 #[test]
1298 fn test_blockquote_after_comment_boundary_not_matched() {
1299 let rule = MD028NoBlanksBlockquote;
1302 let content = "<!--\n> commented quote\n-->\n\n> real quote\n";
1303 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1304 let result = rule.check(&ctx).unwrap();
1305 assert!(
1306 result.is_empty(),
1307 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1308 );
1309 }
1310
1311 #[test]
1312 fn test_fix_preserves_comment_boundary_content() {
1313 let rule = MD028NoBlanksBlockquote;
1315 let content = "> real quote\n\n<!--\n> commented quote A\n\n> commented quote B\n-->\n\n> another real quote\n";
1316 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1317 let fixed = rule.fix(&ctx).unwrap();
1318 assert_eq!(
1319 fixed, content,
1320 "Fix should not modify content when blockquotes are separated by comment boundaries"
1321 );
1322 }
1323
1324 #[test]
1325 fn test_inline_html_comment_does_not_suppress_warning() {
1326 let rule = MD028NoBlanksBlockquote;
1329 let content = "> quote with <!-- inline comment -->\n\n> continuation\n";
1330 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1331 let result = rule.check(&ctx).unwrap();
1332 assert!(
1334 !result.is_empty(),
1335 "Should still flag blank lines between blockquotes with inline HTML comments"
1336 );
1337 }
1338
1339 #[test]
1345 fn test_comment_with_blockquote_markers_on_delimiters() {
1346 let rule = MD028NoBlanksBlockquote;
1349 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1350 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1351 let result = rule.check(&ctx).unwrap();
1352 assert_eq!(
1354 result.len(),
1355 1,
1356 "Should only warn about blank between real quotes, got: {result:?}"
1357 );
1358 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1359 }
1360
1361 #[test]
1362 fn test_commented_blockquote_between_real_blockquotes() {
1363 let rule = MD028NoBlanksBlockquote;
1367 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1368 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1369 let result = rule.check(&ctx).unwrap();
1370 assert!(
1371 result.is_empty(),
1372 "Should NOT warn when non-blockquote content (HTML comment) separates blockquotes, got: {result:?}"
1373 );
1374 }
1375
1376 #[test]
1377 fn test_code_block_with_blockquote_markers_between_real_blockquotes() {
1378 let rule = MD028NoBlanksBlockquote;
1380 let content = "> real A\n\n```\n> not a blockquote\n```\n\n> real B";
1381 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1382 let result = rule.check(&ctx).unwrap();
1383 assert!(
1384 result.is_empty(),
1385 "Should NOT warn when code block with > markers separates blockquotes, got: {result:?}"
1386 );
1387 }
1388
1389 #[test]
1390 fn test_frontmatter_with_blockquote_markers_does_not_cause_false_positive() {
1391 let rule = MD028NoBlanksBlockquote;
1393 let content = "---\n> frontmatter value\n---\n\n> real quote A\n\n> real quote B";
1394 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1395 let result = rule.check(&ctx).unwrap();
1396 assert_eq!(
1398 result.len(),
1399 1,
1400 "Should only flag the blank between real quotes, got: {result:?}"
1401 );
1402 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1403 }
1404
1405 #[test]
1406 fn test_fix_does_not_modify_comment_separated_blockquotes() {
1407 let rule = MD028NoBlanksBlockquote;
1409 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1410 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1411 let fixed = rule.fix(&ctx).unwrap();
1412 assert_eq!(
1413 fixed, content,
1414 "Fix should not modify content when blockquotes are separated by HTML comment"
1415 );
1416 }
1417
1418 #[test]
1419 fn test_fix_works_correctly_with_comment_before_real_blockquotes() {
1420 let rule = MD028NoBlanksBlockquote;
1423 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1424 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1425 let fixed = rule.fix(&ctx).unwrap();
1426 assert!(
1428 fixed.contains("> real quote A\n>\n> real quote B"),
1429 "Fix should add > marker between real quotes, got: {fixed}"
1430 );
1431 assert!(
1433 fixed.contains("<!-- > not a real blockquote"),
1434 "Fix should not modify comment content"
1435 );
1436 }
1437
1438 #[test]
1439 fn test_html_block_with_angle_brackets_not_flagged() {
1440 let rule = MD028NoBlanksBlockquote;
1443 let content = "<div>\n> not a real blockquote\n\n> also not real\n</div>";
1444 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1445 let result = rule.check(&ctx).unwrap();
1446
1447 assert!(
1448 result.is_empty(),
1449 "Lines inside HTML blocks should not trigger MD028. Got: {result:?}"
1450 );
1451 }
1452
1453 #[test]
1457 fn test_roundtrip_single_blank() {
1458 let rule = MD028NoBlanksBlockquote;
1459 let content = "> First\n\n> Third";
1460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1461 let fixed = rule.fix(&ctx).unwrap();
1462 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1463 let warnings = rule.check(&ctx2).unwrap();
1464 assert!(
1465 warnings.is_empty(),
1466 "Roundtrip should produce zero warnings, got: {warnings:?}"
1467 );
1468 }
1469
1470 #[test]
1471 fn test_roundtrip_multiple_blanks() {
1472 let rule = MD028NoBlanksBlockquote;
1473 let content = "> First\n\n\n> Fourth";
1474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1475 let fixed = rule.fix(&ctx).unwrap();
1476 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1477 let warnings = rule.check(&ctx2).unwrap();
1478 assert!(
1479 warnings.is_empty(),
1480 "Roundtrip should produce zero warnings, got: {warnings:?}"
1481 );
1482 }
1483
1484 #[test]
1485 fn test_roundtrip_nested() {
1486 let rule = MD028NoBlanksBlockquote;
1487 let content = ">> Nested\n\n>> More";
1488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1489 let fixed = rule.fix(&ctx).unwrap();
1490 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1491 let warnings = rule.check(&ctx2).unwrap();
1492 assert!(
1493 warnings.is_empty(),
1494 "Roundtrip should produce zero warnings, got: {warnings:?}"
1495 );
1496 }
1497
1498 #[test]
1499 fn test_roundtrip_indented() {
1500 let rule = MD028NoBlanksBlockquote;
1501 let content = " > Indented\n\n > More";
1502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1503 let fixed = rule.fix(&ctx).unwrap();
1504 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1505 let warnings = rule.check(&ctx2).unwrap();
1506 assert!(
1507 warnings.is_empty(),
1508 "Roundtrip should produce zero warnings, got: {warnings:?}"
1509 );
1510 }
1511
1512 #[test]
1513 fn test_roundtrip_deeply_nested() {
1514 let rule = MD028NoBlanksBlockquote;
1515 let content = ">>> Deep\n\n>>> More";
1516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1517 let fixed = rule.fix(&ctx).unwrap();
1518 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1519 let warnings = rule.check(&ctx2).unwrap();
1520 assert!(
1521 warnings.is_empty(),
1522 "Roundtrip should produce zero warnings, got: {warnings:?}"
1523 );
1524 }
1525
1526 #[test]
1527 fn test_roundtrip_multi_blockquotes() {
1528 let rule = MD028NoBlanksBlockquote;
1529 let content = "> First\n> Line\n\n> Second\n> Line\n\n> Third\n";
1530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531 let fixed = rule.fix(&ctx).unwrap();
1532 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1533 let warnings = rule.check(&ctx2).unwrap();
1534 assert!(
1535 warnings.is_empty(),
1536 "Roundtrip should produce zero warnings, got: {warnings:?}"
1537 );
1538 }
1539
1540 #[test]
1541 fn test_roundtrip_idempotent() {
1542 let rule = MD028NoBlanksBlockquote;
1543 let content = "> First\n\n> Second\n\n> Third\n";
1544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545 let fixed1 = rule.fix(&ctx).unwrap();
1546 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
1547 let fixed2 = rule.fix(&ctx2).unwrap();
1548 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1549 }
1550
1551 #[test]
1552 fn test_html_block_does_not_leak_into_adjacent_blockquotes() {
1553 let rule = MD028NoBlanksBlockquote;
1555 let content =
1556 "<details>\n<summary>Click</summary>\n> inside html block\n</details>\n\n> real quote A\n\n> real quote B";
1557 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1558 let result = rule.check(&ctx).unwrap();
1559
1560 assert_eq!(
1562 result.len(),
1563 1,
1564 "Expected 1 warning for blank between real blockquotes after HTML block. Got: {result:?}"
1565 );
1566 }
1567}