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 (prev_idx, next_idx) = match (prev_quote_idx, next_quote_idx) {
300 (Some(p), Some(n)) => (p, n),
301 _ => return false,
302 };
303
304 let prev_is_callout = Self::is_callout_block(lines, line_infos, prev_idx, flavor);
310 let next_is_callout = Self::is_callout_block(lines, line_infos, next_idx, flavor);
311 if prev_is_callout || next_is_callout {
312 return false;
313 }
314
315 if Self::has_content_between(lines, line_infos, prev_idx + 1, next_idx) {
317 return false;
318 }
319
320 let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
322 let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
323
324 if next_level < prev_level {
327 return false;
328 }
329
330 let prev_line = lines[prev_idx];
332 let next_line = lines[next_idx];
333 let prev_indent = &prev_line[..prev_whitespace_end];
334 let next_indent = &next_line[..next_whitespace_end];
335
336 prev_indent == next_indent
339 }
340
341 fn is_problematic_blank_line(
343 lines: &[&str],
344 line_infos: &[LineInfo],
345 index: usize,
346 flavor: MarkdownFlavor,
347 ) -> Option<(usize, String)> {
348 let current_line = lines[index];
349
350 if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
352 return None;
353 }
354
355 if !Self::are_likely_same_blockquote(lines, line_infos, index, flavor) {
358 return None;
359 }
360
361 for i in (0..index).rev() {
364 if Self::is_in_skip_context(line_infos, i) {
365 continue;
366 }
367 let line = lines[i];
368 if line.as_bytes().contains(&b'>') && Self::is_blockquote_line(line) {
370 let (level, whitespace_end) = Self::get_blockquote_info(line);
371 let indent = &line[..whitespace_end];
372 let mut fix = String::with_capacity(indent.len() + level);
373 fix.push_str(indent);
374 for _ in 0..level {
375 fix.push('>');
376 }
377 return Some((level, fix));
378 }
379 }
380
381 None
382 }
383}
384
385impl Default for MD028NoBlanksBlockquote {
386 fn default() -> Self {
387 Self
388 }
389}
390
391impl Rule for MD028NoBlanksBlockquote {
392 fn name(&self) -> &'static str {
393 "MD028"
394 }
395
396 fn description(&self) -> &'static str {
397 "Blank line inside blockquote"
398 }
399
400 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
401 if !ctx.content.contains('>') {
403 return Ok(Vec::new());
404 }
405
406 let mut warnings = Vec::new();
407
408 let lines = ctx.raw_lines();
410
411 let mut blank_line_indices = Vec::new();
413 let mut has_blockquotes = false;
414
415 for (line_idx, line) in lines.iter().enumerate() {
416 if line_idx < ctx.lines.len() {
418 let li = &ctx.lines[line_idx];
419 if li.in_code_block || li.in_html_comment || li.in_mdx_comment || li.in_html_block || li.in_front_matter
420 {
421 continue;
422 }
423 }
424
425 if line.trim().is_empty() {
426 blank_line_indices.push(line_idx);
427 } else if Self::is_blockquote_line(line) {
428 has_blockquotes = true;
429 }
430 }
431
432 if !has_blockquotes {
434 return Ok(Vec::new());
435 }
436
437 for &line_idx in &blank_line_indices {
439 let line_num = line_idx + 1;
440
441 if let Some((level, fix_content)) = Self::is_problematic_blank_line(lines, &ctx.lines, line_idx, ctx.flavor)
443 {
444 let line = lines[line_idx];
445 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
446
447 warnings.push(LintWarning {
448 rule_name: Some(self.name().to_string()),
449 message: format!("Blank line inside blockquote (level {level})"),
450 line: start_line,
451 column: start_col,
452 end_line,
453 end_column: end_col,
454 severity: Severity::Warning,
455 fix: Some(Fix {
456 range: ctx
457 .line_index
458 .line_col_to_byte_range_with_length(line_num, 1, line.len()),
459 replacement: fix_content,
460 }),
461 });
462 }
463 }
464
465 Ok(warnings)
466 }
467
468 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
469 if self.should_skip(ctx) {
470 return Ok(ctx.content.to_string());
471 }
472 let warnings = self.check(ctx)?;
473 if warnings.is_empty() {
474 return Ok(ctx.content.to_string());
475 }
476 let warnings =
477 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
478 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
479 .map_err(crate::rule::LintError::InvalidInput)
480 }
481
482 fn category(&self) -> RuleCategory {
484 RuleCategory::Blockquote
485 }
486
487 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
489 !ctx.likely_has_blockquotes()
490 }
491
492 fn as_any(&self) -> &dyn std::any::Any {
493 self
494 }
495
496 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
497 where
498 Self: Sized,
499 {
500 Box::new(MD028NoBlanksBlockquote)
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::lint_context::LintContext;
508
509 #[test]
510 fn test_no_blockquotes() {
511 let rule = MD028NoBlanksBlockquote;
512 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514 let result = rule.check(&ctx).unwrap();
515 assert!(result.is_empty(), "Should not flag content without blockquotes");
516 }
517
518 #[test]
519 fn test_valid_blockquote_no_blanks() {
520 let rule = MD028NoBlanksBlockquote;
521 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
522 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
523 let result = rule.check(&ctx).unwrap();
524 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
525 }
526
527 #[test]
528 fn test_blockquote_with_empty_line_marker() {
529 let rule = MD028NoBlanksBlockquote;
530 let content = "> First line\n>\n> Third line";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx).unwrap();
534 assert!(result.is_empty(), "Should not flag lines with just > marker");
535 }
536
537 #[test]
538 fn test_blockquote_with_empty_line_marker_and_space() {
539 let rule = MD028NoBlanksBlockquote;
540 let content = "> First line\n> \n> Third line";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let result = rule.check(&ctx).unwrap();
544 assert!(result.is_empty(), "Should not flag lines with > and space");
545 }
546
547 #[test]
548 fn test_blank_line_in_blockquote() {
549 let rule = MD028NoBlanksBlockquote;
550 let content = "> First line\n\n> Third line";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553 let result = rule.check(&ctx).unwrap();
554 assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
555 assert_eq!(result[0].line, 2);
556 assert!(result[0].message.contains("Blank line inside blockquote"));
557 }
558
559 #[test]
560 fn test_multiple_blank_lines() {
561 let rule = MD028NoBlanksBlockquote;
562 let content = "> First\n\n\n> Fourth";
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let result = rule.check(&ctx).unwrap();
565 assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
567 assert_eq!(result[0].line, 2);
568 assert_eq!(result[1].line, 3);
569 }
570
571 #[test]
572 fn test_nested_blockquote_blank() {
573 let rule = MD028NoBlanksBlockquote;
574 let content = ">> Nested quote\n\n>> More nested";
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576 let result = rule.check(&ctx).unwrap();
577 assert_eq!(result.len(), 1);
578 assert_eq!(result[0].line, 2);
579 }
580
581 #[test]
582 fn test_nested_blockquote_with_marker() {
583 let rule = MD028NoBlanksBlockquote;
584 let content = ">> Nested quote\n>>\n>> More nested";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert!(result.is_empty(), "Should not flag lines with >> marker");
589 }
590
591 #[test]
592 fn test_fix_single_blank() {
593 let rule = MD028NoBlanksBlockquote;
594 let content = "> First\n\n> Third";
595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596 let fixed = rule.fix(&ctx).unwrap();
597 assert_eq!(fixed, "> First\n>\n> Third");
598 }
599
600 #[test]
601 fn test_fix_nested_blank() {
602 let rule = MD028NoBlanksBlockquote;
603 let content = ">> Nested\n\n>> More";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let fixed = rule.fix(&ctx).unwrap();
606 assert_eq!(fixed, ">> Nested\n>>\n>> More");
607 }
608
609 #[test]
610 fn test_fix_with_indentation() {
611 let rule = MD028NoBlanksBlockquote;
612 let content = " > Indented quote\n\n > More";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let fixed = rule.fix(&ctx).unwrap();
615 assert_eq!(fixed, " > Indented quote\n >\n > More");
616 }
617
618 #[test]
619 fn test_mixed_levels() {
620 let rule = MD028NoBlanksBlockquote;
621 let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624 let result = rule.check(&ctx).unwrap();
625 assert_eq!(result.len(), 1);
628 assert_eq!(result[0].line, 2);
629 }
630
631 #[test]
632 fn test_blockquote_with_code_block() {
633 let rule = MD028NoBlanksBlockquote;
634 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637 assert!(result.is_empty(), "Should not flag line with > marker");
639 }
640
641 #[test]
642 fn test_category() {
643 let rule = MD028NoBlanksBlockquote;
644 assert_eq!(rule.category(), RuleCategory::Blockquote);
645 }
646
647 #[test]
648 fn test_should_skip() {
649 let rule = MD028NoBlanksBlockquote;
650 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
651 assert!(rule.should_skip(&ctx1));
652
653 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
654 assert!(!rule.should_skip(&ctx2));
655 }
656
657 #[test]
658 fn test_empty_content() {
659 let rule = MD028NoBlanksBlockquote;
660 let content = "";
661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662 let result = rule.check(&ctx).unwrap();
663 assert!(result.is_empty());
664 }
665
666 #[test]
667 fn test_blank_after_blockquote() {
668 let rule = MD028NoBlanksBlockquote;
669 let content = "> Quote\n\nNot a quote";
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671 let result = rule.check(&ctx).unwrap();
672 assert!(result.is_empty(), "Blank line after blockquote ends is valid");
673 }
674
675 #[test]
676 fn test_blank_before_blockquote() {
677 let rule = MD028NoBlanksBlockquote;
678 let content = "Not a quote\n\n> Quote";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let result = rule.check(&ctx).unwrap();
681 assert!(result.is_empty(), "Blank line before blockquote starts is valid");
682 }
683
684 #[test]
685 fn test_preserve_trailing_newline() {
686 let rule = MD028NoBlanksBlockquote;
687 let content = "> Quote\n\n> More\n";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let fixed = rule.fix(&ctx).unwrap();
690 assert!(fixed.ends_with('\n'));
691
692 let content_no_newline = "> Quote\n\n> More";
693 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
694 let fixed2 = rule.fix(&ctx2).unwrap();
695 assert!(!fixed2.ends_with('\n'));
696 }
697
698 #[test]
699 fn test_document_structure_extension() {
700 let rule = MD028NoBlanksBlockquote;
701 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
702 let result = rule.check(&ctx).unwrap();
704 assert!(result.is_empty(), "Should not flag valid blockquote");
705
706 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
708 assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
709 }
710
711 #[test]
712 fn test_deeply_nested_blank() {
713 let rule = MD028NoBlanksBlockquote;
714 let content = ">>> Deep nest\n\n>>> More deep";
715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716 let result = rule.check(&ctx).unwrap();
717 assert_eq!(result.len(), 1);
718
719 let fixed = rule.fix(&ctx).unwrap();
720 assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
721 }
722
723 #[test]
724 fn test_deeply_nested_with_marker() {
725 let rule = MD028NoBlanksBlockquote;
726 let content = ">>> Deep nest\n>>>\n>>> More deep";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.check(&ctx).unwrap();
730 assert!(result.is_empty(), "Should not flag lines with >>> marker");
731 }
732
733 #[test]
734 fn test_complex_blockquote_structure() {
735 let rule = MD028NoBlanksBlockquote;
736 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx).unwrap();
740 assert!(result.is_empty(), "Should not flag line with > marker");
741 }
742
743 #[test]
744 fn test_complex_with_blank() {
745 let rule = MD028NoBlanksBlockquote;
746 let content = "> Level 1\n> > Nested\n\n> Back to level 1";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let result = rule.check(&ctx).unwrap();
751 assert_eq!(
752 result.len(),
753 0,
754 "Blank between different nesting levels is not inside blockquote"
755 );
756 }
757
758 #[test]
765 fn test_gfm_alert_detection_note() {
766 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
767 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
768 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
769 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); }
772
773 #[test]
774 fn test_gfm_alert_detection_all_types() {
775 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
777 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
778 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
779 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
780 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
781 }
782
783 #[test]
784 fn test_gfm_alert_detection_not_alert() {
785 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
787 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
788 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("> ")); }
794
795 #[test]
796 fn test_gfm_alerts_separated_by_blank_line() {
797 let rule = MD028NoBlanksBlockquote;
799 let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let result = rule.check(&ctx).unwrap();
802 assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
803 }
804
805 #[test]
806 fn test_gfm_alerts_all_five_types_separated() {
807 let rule = MD028NoBlanksBlockquote;
809 let content = r#"> [!NOTE]
810> Note content
811
812> [!TIP]
813> Tip content
814
815> [!IMPORTANT]
816> Important content
817
818> [!WARNING]
819> Warning content
820
821> [!CAUTION]
822> Caution content"#;
823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824 let result = rule.check(&ctx).unwrap();
825 assert!(
826 result.is_empty(),
827 "Should not flag blank lines between any GFM alert types"
828 );
829 }
830
831 #[test]
832 fn test_gfm_alert_with_multiple_lines() {
833 let rule = MD028NoBlanksBlockquote;
835 let content = r#"> [!WARNING]
836> This is a warning
837> with multiple lines
838> of content
839
840> [!NOTE]
841> This is a note"#;
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843 let result = rule.check(&ctx).unwrap();
844 assert!(
845 result.is_empty(),
846 "Should not flag blank line between multi-line GFM alerts"
847 );
848 }
849
850 #[test]
851 fn test_gfm_alert_followed_by_regular_blockquote() {
852 let rule = MD028NoBlanksBlockquote;
854 let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let result = rule.check(&ctx).unwrap();
857 assert!(result.is_empty(), "Should not flag blank line after GFM alert");
858 }
859
860 #[test]
861 fn test_regular_blockquote_followed_by_gfm_alert() {
862 let rule = MD028NoBlanksBlockquote;
864 let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867 assert!(result.is_empty(), "Should not flag blank line before GFM alert");
868 }
869
870 #[test]
871 fn test_regular_blockquotes_still_flagged() {
872 let rule = MD028NoBlanksBlockquote;
874 let content = "> First blockquote\n\n> Second blockquote";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let result = rule.check(&ctx).unwrap();
877 assert_eq!(
878 result.len(),
879 1,
880 "Should still flag blank line between regular blockquotes"
881 );
882 }
883
884 #[test]
885 fn test_gfm_alert_blank_line_within_same_alert() {
886 let rule = MD028NoBlanksBlockquote;
889 let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891 let result = rule.check(&ctx).unwrap();
892 assert!(
897 result.is_empty(),
898 "GFM alert status propagates to subsequent blockquote lines"
899 );
900 }
901
902 #[test]
903 fn test_gfm_alert_case_insensitive() {
904 let rule = MD028NoBlanksBlockquote;
905 let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907 let result = rule.check(&ctx).unwrap();
908 assert!(result.is_empty(), "GFM alert detection should be case insensitive");
909 }
910
911 #[test]
912 fn test_gfm_alert_with_nested_blockquote() {
913 let rule = MD028NoBlanksBlockquote;
915 let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.check(&ctx).unwrap();
918 assert!(
919 result.is_empty(),
920 "Should not flag blank between alerts even with nested content"
921 );
922 }
923
924 #[test]
925 fn test_gfm_alert_indented() {
926 let rule = MD028NoBlanksBlockquote;
927 let content = " > [!NOTE]\n > Indented note\n\n > [!TIP]\n > Indented tip";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931 assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
932 }
933
934 #[test]
935 fn test_gfm_alert_mixed_with_regular_content() {
936 let rule = MD028NoBlanksBlockquote;
938 let content = r#"# Heading
939
940Some paragraph.
941
942> [!NOTE]
943> Important note
944
945More paragraph text.
946
947> [!WARNING]
948> Be careful!
949
950Final text."#;
951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952 let result = rule.check(&ctx).unwrap();
953 assert!(
954 result.is_empty(),
955 "GFM alerts in mixed document should not trigger warnings"
956 );
957 }
958
959 #[test]
960 fn test_gfm_alert_fix_not_applied() {
961 let rule = MD028NoBlanksBlockquote;
963 let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965 let fixed = rule.fix(&ctx).unwrap();
966 assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
967 }
968
969 #[test]
970 fn test_gfm_alert_multiple_blank_lines_between() {
971 let rule = MD028NoBlanksBlockquote;
973 let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let result = rule.check(&ctx).unwrap();
976 assert!(
977 result.is_empty(),
978 "Should not flag multiple blank lines between GFM alerts"
979 );
980 }
981
982 #[test]
989 fn test_obsidian_callout_detection() {
990 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
992 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
993 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
994 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
995 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
996 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
997 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
998 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
999 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
1000 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
1001 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
1002 }
1003
1004 #[test]
1005 fn test_obsidian_callout_custom_types() {
1006 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
1008 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
1009 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
1010 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
1011 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
1012 }
1013
1014 #[test]
1015 fn test_obsidian_callout_foldable() {
1016 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
1018 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1019 "> [!NOTE]- Collapsed"
1020 ));
1021 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
1022 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
1023 }
1024
1025 #[test]
1026 fn test_obsidian_callout_with_title() {
1027 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1029 "> [!NOTE] Custom Title"
1030 ));
1031 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1032 "> [!WARNING]+ Be Careful!"
1033 ));
1034 }
1035
1036 #[test]
1037 fn test_obsidian_callout_invalid() {
1038 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1040 "> Regular blockquote"
1041 ));
1042 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1045 "Regular text [!NOTE]"
1046 )); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); }
1049
1050 #[test]
1051 fn test_obsidian_callouts_separated_by_blank_line() {
1052 let rule = MD028NoBlanksBlockquote;
1054 let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
1055 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1056 let result = rule.check(&ctx).unwrap();
1057 assert!(
1058 result.is_empty(),
1059 "Should not flag blank line between Obsidian callouts"
1060 );
1061 }
1062
1063 #[test]
1064 fn test_obsidian_custom_callouts_separated() {
1065 let rule = MD028NoBlanksBlockquote;
1067 let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
1068 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1069 let result = rule.check(&ctx).unwrap();
1070 assert!(
1071 result.is_empty(),
1072 "Should not flag blank line between custom Obsidian callouts"
1073 );
1074 }
1075
1076 #[test]
1077 fn test_obsidian_foldable_callouts_separated() {
1078 let rule = MD028NoBlanksBlockquote;
1080 let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1081 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1082 let result = rule.check(&ctx).unwrap();
1083 assert!(
1084 result.is_empty(),
1085 "Should not flag blank line between foldable Obsidian callouts"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1091 let rule = MD028NoBlanksBlockquote;
1094 let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1095 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1096 let result = rule.check(&ctx).unwrap();
1097 assert_eq!(
1099 result.len(),
1100 1,
1101 "Custom callout types should be flagged in Standard flavor"
1102 );
1103 }
1104
1105 #[test]
1106 fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1107 let rule = MD028NoBlanksBlockquote;
1109 let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1110
1111 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1113 let result_standard = rule.check(&ctx_standard).unwrap();
1114 assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1115
1116 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1118 let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1119 assert!(
1120 result_obsidian.is_empty(),
1121 "GFM alerts should also work in Obsidian flavor"
1122 );
1123 }
1124
1125 #[test]
1126 fn test_obsidian_callout_all_builtin_types() {
1127 let rule = MD028NoBlanksBlockquote;
1129 let content = r#"> [!note]
1130> Note
1131
1132> [!abstract]
1133> Abstract
1134
1135> [!summary]
1136> Summary
1137
1138> [!info]
1139> Info
1140
1141> [!todo]
1142> Todo
1143
1144> [!tip]
1145> Tip
1146
1147> [!success]
1148> Success
1149
1150> [!question]
1151> Question
1152
1153> [!warning]
1154> Warning
1155
1156> [!failure]
1157> Failure
1158
1159> [!danger]
1160> Danger
1161
1162> [!bug]
1163> Bug
1164
1165> [!example]
1166> Example
1167
1168> [!quote]
1169> Quote"#;
1170 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1171 let result = rule.check(&ctx).unwrap();
1172 assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1173 }
1174
1175 #[test]
1176 fn test_obsidian_fix_not_applied_to_callouts() {
1177 let rule = MD028NoBlanksBlockquote;
1179 let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1180 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1181 let fixed = rule.fix(&ctx).unwrap();
1182 assert_eq!(
1183 fixed, content,
1184 "Fix should not modify blank lines between Obsidian callouts"
1185 );
1186 }
1187
1188 #[test]
1189 fn test_obsidian_regular_blockquotes_still_flagged() {
1190 let rule = MD028NoBlanksBlockquote;
1192 let content = "> First blockquote\n\n> Second blockquote";
1193 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1194 let result = rule.check(&ctx).unwrap();
1195 assert_eq!(
1196 result.len(),
1197 1,
1198 "Regular blockquotes should still be flagged in Obsidian flavor"
1199 );
1200 }
1201
1202 #[test]
1203 fn test_obsidian_callout_mixed_with_regular_blockquote() {
1204 let rule = MD028NoBlanksBlockquote;
1206 let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1207 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1208 let result = rule.check(&ctx).unwrap();
1209 assert!(
1210 result.is_empty(),
1211 "Should not flag blank after callout even if followed by regular blockquote"
1212 );
1213 }
1214
1215 #[test]
1219 fn test_html_comment_blockquotes_not_flagged() {
1220 let rule = MD028NoBlanksBlockquote;
1221 let content = "## Responses\n\n<!--\n> First response text here.\n> <br>— Person One\n\n> Second response text here.\n> <br>— Person Two\n-->\n\nThe above responses are currently disabled.\n";
1222 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1223 let result = rule.check(&ctx).unwrap();
1224 assert!(
1225 result.is_empty(),
1226 "Should not flag blank lines inside HTML comments, got: {result:?}"
1227 );
1228 }
1229
1230 #[test]
1231 fn test_fix_preserves_html_comment_content() {
1232 let rule = MD028NoBlanksBlockquote;
1233 let content = "<!--\n> First quote\n\n> Second quote\n-->\n";
1234 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1235 let fixed = rule.fix(&ctx).unwrap();
1236 assert_eq!(fixed, content, "Fix should not modify content inside HTML comments");
1237 }
1238
1239 #[test]
1240 fn test_multiline_html_comment_with_blockquotes() {
1241 let rule = MD028NoBlanksBlockquote;
1242 let content = "# Title\n\n<!--\n> Quote A\n> Line 2\n\n> Quote B\n> Line 2\n\n> Quote C\n-->\n\nSome text\n";
1243 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1244 let result = rule.check(&ctx).unwrap();
1245 assert!(
1246 result.is_empty(),
1247 "Should not flag any blank lines inside HTML comments, got: {result:?}"
1248 );
1249 }
1250
1251 #[test]
1252 fn test_blockquotes_outside_html_comment_still_flagged() {
1253 let rule = MD028NoBlanksBlockquote;
1254 let content = "> First quote\n\n> Second quote\n\n<!--\n> Commented quote A\n\n> Commented quote B\n-->\n";
1255 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1256 let result = rule.check(&ctx).unwrap();
1257 for w in &result {
1260 assert!(
1261 w.line < 5,
1262 "Warning at line {} should not be inside HTML comment",
1263 w.line
1264 );
1265 }
1266 assert!(
1267 !result.is_empty(),
1268 "Should still flag blank line between blockquotes outside HTML comment"
1269 );
1270 }
1271
1272 #[test]
1273 fn test_frontmatter_blockquote_like_content_not_flagged() {
1274 let rule = MD028NoBlanksBlockquote;
1275 let content = "---\n> not a real blockquote\n\n> also not real\n---\n\n# Title\n";
1276 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1277 let result = rule.check(&ctx).unwrap();
1278 assert!(
1279 result.is_empty(),
1280 "Should not flag content inside frontmatter, got: {result:?}"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_comment_boundary_does_not_leak_into_adjacent_blockquotes() {
1286 let rule = MD028NoBlanksBlockquote;
1289 let content = "> real quote\n\n<!--\n> commented quote\n-->\n";
1290 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1291 let result = rule.check(&ctx).unwrap();
1292 assert!(
1293 result.is_empty(),
1294 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1295 );
1296 }
1297
1298 #[test]
1299 fn test_blockquote_after_comment_boundary_not_matched() {
1300 let rule = MD028NoBlanksBlockquote;
1303 let content = "<!--\n> commented quote\n-->\n\n> real quote\n";
1304 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1305 let result = rule.check(&ctx).unwrap();
1306 assert!(
1307 result.is_empty(),
1308 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1309 );
1310 }
1311
1312 #[test]
1313 fn test_fix_preserves_comment_boundary_content() {
1314 let rule = MD028NoBlanksBlockquote;
1316 let content = "> real quote\n\n<!--\n> commented quote A\n\n> commented quote B\n-->\n\n> another real quote\n";
1317 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1318 let fixed = rule.fix(&ctx).unwrap();
1319 assert_eq!(
1320 fixed, content,
1321 "Fix should not modify content when blockquotes are separated by comment boundaries"
1322 );
1323 }
1324
1325 #[test]
1326 fn test_inline_html_comment_does_not_suppress_warning() {
1327 let rule = MD028NoBlanksBlockquote;
1330 let content = "> quote with <!-- inline comment -->\n\n> continuation\n";
1331 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1332 let result = rule.check(&ctx).unwrap();
1333 assert!(
1335 !result.is_empty(),
1336 "Should still flag blank lines between blockquotes with inline HTML comments"
1337 );
1338 }
1339
1340 #[test]
1346 fn test_comment_with_blockquote_markers_on_delimiters() {
1347 let rule = MD028NoBlanksBlockquote;
1350 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1351 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1352 let result = rule.check(&ctx).unwrap();
1353 assert_eq!(
1355 result.len(),
1356 1,
1357 "Should only warn about blank between real quotes, got: {result:?}"
1358 );
1359 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1360 }
1361
1362 #[test]
1363 fn test_commented_blockquote_between_real_blockquotes() {
1364 let rule = MD028NoBlanksBlockquote;
1368 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1369 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1370 let result = rule.check(&ctx).unwrap();
1371 assert!(
1372 result.is_empty(),
1373 "Should NOT warn when non-blockquote content (HTML comment) separates blockquotes, got: {result:?}"
1374 );
1375 }
1376
1377 #[test]
1378 fn test_code_block_with_blockquote_markers_between_real_blockquotes() {
1379 let rule = MD028NoBlanksBlockquote;
1381 let content = "> real A\n\n```\n> not a blockquote\n```\n\n> real B";
1382 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1383 let result = rule.check(&ctx).unwrap();
1384 assert!(
1385 result.is_empty(),
1386 "Should NOT warn when code block with > markers separates blockquotes, got: {result:?}"
1387 );
1388 }
1389
1390 #[test]
1391 fn test_frontmatter_with_blockquote_markers_does_not_cause_false_positive() {
1392 let rule = MD028NoBlanksBlockquote;
1394 let content = "---\n> frontmatter value\n---\n\n> real quote A\n\n> real quote B";
1395 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1396 let result = rule.check(&ctx).unwrap();
1397 assert_eq!(
1399 result.len(),
1400 1,
1401 "Should only flag the blank between real quotes, got: {result:?}"
1402 );
1403 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1404 }
1405
1406 #[test]
1407 fn test_fix_does_not_modify_comment_separated_blockquotes() {
1408 let rule = MD028NoBlanksBlockquote;
1410 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1411 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1412 let fixed = rule.fix(&ctx).unwrap();
1413 assert_eq!(
1414 fixed, content,
1415 "Fix should not modify content when blockquotes are separated by HTML comment"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_fix_works_correctly_with_comment_before_real_blockquotes() {
1421 let rule = MD028NoBlanksBlockquote;
1424 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1425 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1426 let fixed = rule.fix(&ctx).unwrap();
1427 assert!(
1429 fixed.contains("> real quote A\n>\n> real quote B"),
1430 "Fix should add > marker between real quotes, got: {fixed}"
1431 );
1432 assert!(
1434 fixed.contains("<!-- > not a real blockquote"),
1435 "Fix should not modify comment content"
1436 );
1437 }
1438
1439 #[test]
1440 fn test_html_block_with_angle_brackets_not_flagged() {
1441 let rule = MD028NoBlanksBlockquote;
1444 let content = "<div>\n> not a real blockquote\n\n> also not real\n</div>";
1445 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1446 let result = rule.check(&ctx).unwrap();
1447
1448 assert!(
1449 result.is_empty(),
1450 "Lines inside HTML blocks should not trigger MD028. Got: {result:?}"
1451 );
1452 }
1453
1454 #[test]
1458 fn test_roundtrip_single_blank() {
1459 let rule = MD028NoBlanksBlockquote;
1460 let content = "> First\n\n> Third";
1461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462 let fixed = rule.fix(&ctx).unwrap();
1463 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1464 let warnings = rule.check(&ctx2).unwrap();
1465 assert!(
1466 warnings.is_empty(),
1467 "Roundtrip should produce zero warnings, got: {warnings:?}"
1468 );
1469 }
1470
1471 #[test]
1472 fn test_roundtrip_multiple_blanks() {
1473 let rule = MD028NoBlanksBlockquote;
1474 let content = "> First\n\n\n> Fourth";
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let fixed = rule.fix(&ctx).unwrap();
1477 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1478 let warnings = rule.check(&ctx2).unwrap();
1479 assert!(
1480 warnings.is_empty(),
1481 "Roundtrip should produce zero warnings, got: {warnings:?}"
1482 );
1483 }
1484
1485 #[test]
1486 fn test_roundtrip_nested() {
1487 let rule = MD028NoBlanksBlockquote;
1488 let content = ">> Nested\n\n>> More";
1489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490 let fixed = rule.fix(&ctx).unwrap();
1491 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1492 let warnings = rule.check(&ctx2).unwrap();
1493 assert!(
1494 warnings.is_empty(),
1495 "Roundtrip should produce zero warnings, got: {warnings:?}"
1496 );
1497 }
1498
1499 #[test]
1500 fn test_roundtrip_indented() {
1501 let rule = MD028NoBlanksBlockquote;
1502 let content = " > Indented\n\n > More";
1503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1504 let fixed = rule.fix(&ctx).unwrap();
1505 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1506 let warnings = rule.check(&ctx2).unwrap();
1507 assert!(
1508 warnings.is_empty(),
1509 "Roundtrip should produce zero warnings, got: {warnings:?}"
1510 );
1511 }
1512
1513 #[test]
1514 fn test_roundtrip_deeply_nested() {
1515 let rule = MD028NoBlanksBlockquote;
1516 let content = ">>> Deep\n\n>>> More";
1517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1518 let fixed = rule.fix(&ctx).unwrap();
1519 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1520 let warnings = rule.check(&ctx2).unwrap();
1521 assert!(
1522 warnings.is_empty(),
1523 "Roundtrip should produce zero warnings, got: {warnings:?}"
1524 );
1525 }
1526
1527 #[test]
1528 fn test_roundtrip_multi_blockquotes() {
1529 let rule = MD028NoBlanksBlockquote;
1530 let content = "> First\n> Line\n\n> Second\n> Line\n\n> Third\n";
1531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1532 let fixed = rule.fix(&ctx).unwrap();
1533 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1534 let warnings = rule.check(&ctx2).unwrap();
1535 assert!(
1536 warnings.is_empty(),
1537 "Roundtrip should produce zero warnings, got: {warnings:?}"
1538 );
1539 }
1540
1541 #[test]
1542 fn test_roundtrip_idempotent() {
1543 let rule = MD028NoBlanksBlockquote;
1544 let content = "> First\n\n> Second\n\n> Third\n";
1545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1546 let fixed1 = rule.fix(&ctx).unwrap();
1547 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
1548 let fixed2 = rule.fix(&ctx2).unwrap();
1549 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1550 }
1551
1552 #[test]
1553 fn test_html_block_does_not_leak_into_adjacent_blockquotes() {
1554 let rule = MD028NoBlanksBlockquote;
1556 let content =
1557 "<details>\n<summary>Click</summary>\n> inside html block\n</details>\n\n> real quote A\n\n> real quote B";
1558 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1559 let result = rule.check(&ctx).unwrap();
1560
1561 assert_eq!(
1563 result.len(),
1564 1,
1565 "Expected 1 warning for blank between real blockquotes after HTML block. Got: {result:?}"
1566 );
1567 }
1568}