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::new(
455 ctx.line_index
456 .line_col_to_byte_range_with_length(line_num, 1, line.len()),
457 fix_content,
458 )),
459 });
460 }
461 }
462
463 Ok(warnings)
464 }
465
466 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
467 if self.should_skip(ctx) {
468 return Ok(ctx.content.to_string());
469 }
470 let warnings = self.check(ctx)?;
471 if warnings.is_empty() {
472 return Ok(ctx.content.to_string());
473 }
474 let warnings =
475 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
476 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
477 .map_err(crate::rule::LintError::InvalidInput)
478 }
479
480 fn category(&self) -> RuleCategory {
482 RuleCategory::Blockquote
483 }
484
485 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
487 !ctx.likely_has_blockquotes()
488 }
489
490 fn as_any(&self) -> &dyn std::any::Any {
491 self
492 }
493
494 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
495 where
496 Self: Sized,
497 {
498 Box::new(MD028NoBlanksBlockquote)
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use crate::lint_context::LintContext;
506
507 #[test]
508 fn test_no_blockquotes() {
509 let rule = MD028NoBlanksBlockquote;
510 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512 let result = rule.check(&ctx).unwrap();
513 assert!(result.is_empty(), "Should not flag content without blockquotes");
514 }
515
516 #[test]
517 fn test_valid_blockquote_no_blanks() {
518 let rule = MD028NoBlanksBlockquote;
519 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
523 }
524
525 #[test]
526 fn test_blockquote_with_empty_line_marker() {
527 let rule = MD028NoBlanksBlockquote;
528 let content = "> First line\n>\n> Third line";
530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531 let result = rule.check(&ctx).unwrap();
532 assert!(result.is_empty(), "Should not flag lines with just > marker");
533 }
534
535 #[test]
536 fn test_blockquote_with_empty_line_marker_and_space() {
537 let rule = MD028NoBlanksBlockquote;
538 let content = "> First line\n> \n> Third line";
540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541 let result = rule.check(&ctx).unwrap();
542 assert!(result.is_empty(), "Should not flag lines with > and space");
543 }
544
545 #[test]
546 fn test_blank_line_in_blockquote() {
547 let rule = MD028NoBlanksBlockquote;
548 let content = "> First line\n\n> Third line";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551 let result = rule.check(&ctx).unwrap();
552 assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
553 assert_eq!(result[0].line, 2);
554 assert!(result[0].message.contains("Blank line inside blockquote"));
555 }
556
557 #[test]
558 fn test_multiple_blank_lines() {
559 let rule = MD028NoBlanksBlockquote;
560 let content = "> First\n\n\n> Fourth";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
565 assert_eq!(result[0].line, 2);
566 assert_eq!(result[1].line, 3);
567 }
568
569 #[test]
570 fn test_nested_blockquote_blank() {
571 let rule = MD028NoBlanksBlockquote;
572 let content = ">> Nested quote\n\n>> More nested";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let result = rule.check(&ctx).unwrap();
575 assert_eq!(result.len(), 1);
576 assert_eq!(result[0].line, 2);
577 }
578
579 #[test]
580 fn test_nested_blockquote_with_marker() {
581 let rule = MD028NoBlanksBlockquote;
582 let content = ">> Nested quote\n>>\n>> More nested";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 let result = rule.check(&ctx).unwrap();
586 assert!(result.is_empty(), "Should not flag lines with >> marker");
587 }
588
589 #[test]
590 fn test_fix_single_blank() {
591 let rule = MD028NoBlanksBlockquote;
592 let content = "> First\n\n> Third";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let fixed = rule.fix(&ctx).unwrap();
595 assert_eq!(fixed, "> First\n>\n> Third");
596 }
597
598 #[test]
599 fn test_fix_nested_blank() {
600 let rule = MD028NoBlanksBlockquote;
601 let content = ">> Nested\n\n>> More";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603 let fixed = rule.fix(&ctx).unwrap();
604 assert_eq!(fixed, ">> Nested\n>>\n>> More");
605 }
606
607 #[test]
608 fn test_fix_with_indentation() {
609 let rule = MD028NoBlanksBlockquote;
610 let content = " > Indented quote\n\n > More";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 let fixed = rule.fix(&ctx).unwrap();
613 assert_eq!(fixed, " > Indented quote\n >\n > More");
614 }
615
616 #[test]
617 fn test_mixed_levels() {
618 let rule = MD028NoBlanksBlockquote;
619 let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622 let result = rule.check(&ctx).unwrap();
623 assert_eq!(result.len(), 1);
626 assert_eq!(result[0].line, 2);
627 }
628
629 #[test]
630 fn test_blockquote_with_code_block() {
631 let rule = MD028NoBlanksBlockquote;
632 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634 let result = rule.check(&ctx).unwrap();
635 assert!(result.is_empty(), "Should not flag line with > marker");
637 }
638
639 #[test]
640 fn test_category() {
641 let rule = MD028NoBlanksBlockquote;
642 assert_eq!(rule.category(), RuleCategory::Blockquote);
643 }
644
645 #[test]
646 fn test_should_skip() {
647 let rule = MD028NoBlanksBlockquote;
648 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
649 assert!(rule.should_skip(&ctx1));
650
651 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
652 assert!(!rule.should_skip(&ctx2));
653 }
654
655 #[test]
656 fn test_empty_content() {
657 let rule = MD028NoBlanksBlockquote;
658 let content = "";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660 let result = rule.check(&ctx).unwrap();
661 assert!(result.is_empty());
662 }
663
664 #[test]
665 fn test_blank_after_blockquote() {
666 let rule = MD028NoBlanksBlockquote;
667 let content = "> Quote\n\nNot a quote";
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669 let result = rule.check(&ctx).unwrap();
670 assert!(result.is_empty(), "Blank line after blockquote ends is valid");
671 }
672
673 #[test]
674 fn test_blank_before_blockquote() {
675 let rule = MD028NoBlanksBlockquote;
676 let content = "Not a quote\n\n> Quote";
677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678 let result = rule.check(&ctx).unwrap();
679 assert!(result.is_empty(), "Blank line before blockquote starts is valid");
680 }
681
682 #[test]
683 fn test_preserve_trailing_newline() {
684 let rule = MD028NoBlanksBlockquote;
685 let content = "> Quote\n\n> More\n";
686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
687 let fixed = rule.fix(&ctx).unwrap();
688 assert!(fixed.ends_with('\n'));
689
690 let content_no_newline = "> Quote\n\n> More";
691 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
692 let fixed2 = rule.fix(&ctx2).unwrap();
693 assert!(!fixed2.ends_with('\n'));
694 }
695
696 #[test]
697 fn test_document_structure_extension() {
698 let rule = MD028NoBlanksBlockquote;
699 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
702 assert!(result.is_empty(), "Should not flag valid blockquote");
703
704 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
706 assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
707 }
708
709 #[test]
710 fn test_deeply_nested_blank() {
711 let rule = MD028NoBlanksBlockquote;
712 let content = ">>> Deep nest\n\n>>> More deep";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let result = rule.check(&ctx).unwrap();
715 assert_eq!(result.len(), 1);
716
717 let fixed = rule.fix(&ctx).unwrap();
718 assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
719 }
720
721 #[test]
722 fn test_deeply_nested_with_marker() {
723 let rule = MD028NoBlanksBlockquote;
724 let content = ">>> Deep nest\n>>>\n>>> More deep";
726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
727 let result = rule.check(&ctx).unwrap();
728 assert!(result.is_empty(), "Should not flag lines with >>> marker");
729 }
730
731 #[test]
732 fn test_complex_blockquote_structure() {
733 let rule = MD028NoBlanksBlockquote;
734 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737 let result = rule.check(&ctx).unwrap();
738 assert!(result.is_empty(), "Should not flag line with > marker");
739 }
740
741 #[test]
742 fn test_complex_with_blank() {
743 let rule = MD028NoBlanksBlockquote;
744 let content = "> Level 1\n> > Nested\n\n> Back to level 1";
747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
748 let result = rule.check(&ctx).unwrap();
749 assert_eq!(
750 result.len(),
751 0,
752 "Blank between different nesting levels is not inside blockquote"
753 );
754 }
755
756 #[test]
763 fn test_gfm_alert_detection_note() {
764 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
765 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
766 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
767 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); }
770
771 #[test]
772 fn test_gfm_alert_detection_all_types() {
773 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
775 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
776 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
777 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
778 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
779 }
780
781 #[test]
782 fn test_gfm_alert_detection_not_alert() {
783 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
785 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
786 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("> ")); }
792
793 #[test]
794 fn test_gfm_alerts_separated_by_blank_line() {
795 let rule = MD028NoBlanksBlockquote;
797 let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
798 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
799 let result = rule.check(&ctx).unwrap();
800 assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
801 }
802
803 #[test]
804 fn test_gfm_alerts_all_five_types_separated() {
805 let rule = MD028NoBlanksBlockquote;
807 let content = r#"> [!NOTE]
808> Note content
809
810> [!TIP]
811> Tip content
812
813> [!IMPORTANT]
814> Important content
815
816> [!WARNING]
817> Warning content
818
819> [!CAUTION]
820> Caution content"#;
821 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822 let result = rule.check(&ctx).unwrap();
823 assert!(
824 result.is_empty(),
825 "Should not flag blank lines between any GFM alert types"
826 );
827 }
828
829 #[test]
830 fn test_gfm_alert_with_multiple_lines() {
831 let rule = MD028NoBlanksBlockquote;
833 let content = r#"> [!WARNING]
834> This is a warning
835> with multiple lines
836> of content
837
838> [!NOTE]
839> This is a note"#;
840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841 let result = rule.check(&ctx).unwrap();
842 assert!(
843 result.is_empty(),
844 "Should not flag blank line between multi-line GFM alerts"
845 );
846 }
847
848 #[test]
849 fn test_gfm_alert_followed_by_regular_blockquote() {
850 let rule = MD028NoBlanksBlockquote;
852 let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854 let result = rule.check(&ctx).unwrap();
855 assert!(result.is_empty(), "Should not flag blank line after GFM alert");
856 }
857
858 #[test]
859 fn test_regular_blockquote_followed_by_gfm_alert() {
860 let rule = MD028NoBlanksBlockquote;
862 let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865 assert!(result.is_empty(), "Should not flag blank line before GFM alert");
866 }
867
868 #[test]
869 fn test_regular_blockquotes_still_flagged() {
870 let rule = MD028NoBlanksBlockquote;
872 let content = "> First blockquote\n\n> Second blockquote";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 let result = rule.check(&ctx).unwrap();
875 assert_eq!(
876 result.len(),
877 1,
878 "Should still flag blank line between regular blockquotes"
879 );
880 }
881
882 #[test]
883 fn test_gfm_alert_blank_line_within_same_alert() {
884 let rule = MD028NoBlanksBlockquote;
887 let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889 let result = rule.check(&ctx).unwrap();
890 assert!(
895 result.is_empty(),
896 "GFM alert status propagates to subsequent blockquote lines"
897 );
898 }
899
900 #[test]
901 fn test_gfm_alert_case_insensitive() {
902 let rule = MD028NoBlanksBlockquote;
903 let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905 let result = rule.check(&ctx).unwrap();
906 assert!(result.is_empty(), "GFM alert detection should be case insensitive");
907 }
908
909 #[test]
910 fn test_gfm_alert_with_nested_blockquote() {
911 let rule = MD028NoBlanksBlockquote;
913 let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let result = rule.check(&ctx).unwrap();
916 assert!(
917 result.is_empty(),
918 "Should not flag blank between alerts even with nested content"
919 );
920 }
921
922 #[test]
923 fn test_gfm_alert_indented() {
924 let rule = MD028NoBlanksBlockquote;
925 let content = " > [!NOTE]\n > Indented note\n\n > [!TIP]\n > Indented tip";
927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928 let result = rule.check(&ctx).unwrap();
929 assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
930 }
931
932 #[test]
933 fn test_gfm_alert_mixed_with_regular_content() {
934 let rule = MD028NoBlanksBlockquote;
936 let content = r#"# Heading
937
938Some paragraph.
939
940> [!NOTE]
941> Important note
942
943More paragraph text.
944
945> [!WARNING]
946> Be careful!
947
948Final text."#;
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
950 let result = rule.check(&ctx).unwrap();
951 assert!(
952 result.is_empty(),
953 "GFM alerts in mixed document should not trigger warnings"
954 );
955 }
956
957 #[test]
958 fn test_gfm_alert_fix_not_applied() {
959 let rule = MD028NoBlanksBlockquote;
961 let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963 let fixed = rule.fix(&ctx).unwrap();
964 assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
965 }
966
967 #[test]
968 fn test_gfm_alert_multiple_blank_lines_between() {
969 let rule = MD028NoBlanksBlockquote;
971 let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
973 let result = rule.check(&ctx).unwrap();
974 assert!(
975 result.is_empty(),
976 "Should not flag multiple blank lines between GFM alerts"
977 );
978 }
979
980 #[test]
987 fn test_obsidian_callout_detection() {
988 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
990 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
991 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
992 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
993 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
994 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
995 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
996 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
997 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
998 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
999 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
1000 }
1001
1002 #[test]
1003 fn test_obsidian_callout_custom_types() {
1004 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
1006 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
1007 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
1008 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
1009 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
1010 }
1011
1012 #[test]
1013 fn test_obsidian_callout_foldable() {
1014 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
1016 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1017 "> [!NOTE]- Collapsed"
1018 ));
1019 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
1020 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
1021 }
1022
1023 #[test]
1024 fn test_obsidian_callout_with_title() {
1025 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1027 "> [!NOTE] Custom Title"
1028 ));
1029 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1030 "> [!WARNING]+ Be Careful!"
1031 ));
1032 }
1033
1034 #[test]
1035 fn test_obsidian_callout_invalid() {
1036 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1038 "> Regular blockquote"
1039 ));
1040 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1043 "Regular text [!NOTE]"
1044 )); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); }
1047
1048 #[test]
1049 fn test_obsidian_callouts_separated_by_blank_line() {
1050 let rule = MD028NoBlanksBlockquote;
1052 let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
1053 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1054 let result = rule.check(&ctx).unwrap();
1055 assert!(
1056 result.is_empty(),
1057 "Should not flag blank line between Obsidian callouts"
1058 );
1059 }
1060
1061 #[test]
1062 fn test_obsidian_custom_callouts_separated() {
1063 let rule = MD028NoBlanksBlockquote;
1065 let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
1066 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1067 let result = rule.check(&ctx).unwrap();
1068 assert!(
1069 result.is_empty(),
1070 "Should not flag blank line between custom Obsidian callouts"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_obsidian_foldable_callouts_separated() {
1076 let rule = MD028NoBlanksBlockquote;
1078 let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1079 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1080 let result = rule.check(&ctx).unwrap();
1081 assert!(
1082 result.is_empty(),
1083 "Should not flag blank line between foldable Obsidian callouts"
1084 );
1085 }
1086
1087 #[test]
1088 fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1089 let rule = MD028NoBlanksBlockquote;
1092 let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1093 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1094 let result = rule.check(&ctx).unwrap();
1095 assert_eq!(
1097 result.len(),
1098 1,
1099 "Custom callout types should be flagged in Standard flavor"
1100 );
1101 }
1102
1103 #[test]
1104 fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1105 let rule = MD028NoBlanksBlockquote;
1107 let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1108
1109 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1111 let result_standard = rule.check(&ctx_standard).unwrap();
1112 assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1113
1114 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1116 let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1117 assert!(
1118 result_obsidian.is_empty(),
1119 "GFM alerts should also work in Obsidian flavor"
1120 );
1121 }
1122
1123 #[test]
1124 fn test_obsidian_callout_all_builtin_types() {
1125 let rule = MD028NoBlanksBlockquote;
1127 let content = r#"> [!note]
1128> Note
1129
1130> [!abstract]
1131> Abstract
1132
1133> [!summary]
1134> Summary
1135
1136> [!info]
1137> Info
1138
1139> [!todo]
1140> Todo
1141
1142> [!tip]
1143> Tip
1144
1145> [!success]
1146> Success
1147
1148> [!question]
1149> Question
1150
1151> [!warning]
1152> Warning
1153
1154> [!failure]
1155> Failure
1156
1157> [!danger]
1158> Danger
1159
1160> [!bug]
1161> Bug
1162
1163> [!example]
1164> Example
1165
1166> [!quote]
1167> Quote"#;
1168 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1169 let result = rule.check(&ctx).unwrap();
1170 assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1171 }
1172
1173 #[test]
1174 fn test_obsidian_fix_not_applied_to_callouts() {
1175 let rule = MD028NoBlanksBlockquote;
1177 let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1178 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1179 let fixed = rule.fix(&ctx).unwrap();
1180 assert_eq!(
1181 fixed, content,
1182 "Fix should not modify blank lines between Obsidian callouts"
1183 );
1184 }
1185
1186 #[test]
1187 fn test_obsidian_regular_blockquotes_still_flagged() {
1188 let rule = MD028NoBlanksBlockquote;
1190 let content = "> First blockquote\n\n> Second blockquote";
1191 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1192 let result = rule.check(&ctx).unwrap();
1193 assert_eq!(
1194 result.len(),
1195 1,
1196 "Regular blockquotes should still be flagged in Obsidian flavor"
1197 );
1198 }
1199
1200 #[test]
1201 fn test_obsidian_callout_mixed_with_regular_blockquote() {
1202 let rule = MD028NoBlanksBlockquote;
1204 let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1205 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1206 let result = rule.check(&ctx).unwrap();
1207 assert!(
1208 result.is_empty(),
1209 "Should not flag blank after callout even if followed by regular blockquote"
1210 );
1211 }
1212
1213 #[test]
1217 fn test_html_comment_blockquotes_not_flagged() {
1218 let rule = MD028NoBlanksBlockquote;
1219 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";
1220 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1221 let result = rule.check(&ctx).unwrap();
1222 assert!(
1223 result.is_empty(),
1224 "Should not flag blank lines inside HTML comments, got: {result:?}"
1225 );
1226 }
1227
1228 #[test]
1229 fn test_fix_preserves_html_comment_content() {
1230 let rule = MD028NoBlanksBlockquote;
1231 let content = "<!--\n> First quote\n\n> Second quote\n-->\n";
1232 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1233 let fixed = rule.fix(&ctx).unwrap();
1234 assert_eq!(fixed, content, "Fix should not modify content inside HTML comments");
1235 }
1236
1237 #[test]
1238 fn test_multiline_html_comment_with_blockquotes() {
1239 let rule = MD028NoBlanksBlockquote;
1240 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";
1241 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1242 let result = rule.check(&ctx).unwrap();
1243 assert!(
1244 result.is_empty(),
1245 "Should not flag any blank lines inside HTML comments, got: {result:?}"
1246 );
1247 }
1248
1249 #[test]
1250 fn test_blockquotes_outside_html_comment_still_flagged() {
1251 let rule = MD028NoBlanksBlockquote;
1252 let content = "> First quote\n\n> Second quote\n\n<!--\n> Commented quote A\n\n> Commented quote B\n-->\n";
1253 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1254 let result = rule.check(&ctx).unwrap();
1255 for w in &result {
1258 assert!(
1259 w.line < 5,
1260 "Warning at line {} should not be inside HTML comment",
1261 w.line
1262 );
1263 }
1264 assert!(
1265 !result.is_empty(),
1266 "Should still flag blank line between blockquotes outside HTML comment"
1267 );
1268 }
1269
1270 #[test]
1271 fn test_frontmatter_blockquote_like_content_not_flagged() {
1272 let rule = MD028NoBlanksBlockquote;
1273 let content = "---\n> not a real blockquote\n\n> also not real\n---\n\n# Title\n";
1274 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1275 let result = rule.check(&ctx).unwrap();
1276 assert!(
1277 result.is_empty(),
1278 "Should not flag content inside frontmatter, got: {result:?}"
1279 );
1280 }
1281
1282 #[test]
1283 fn test_comment_boundary_does_not_leak_into_adjacent_blockquotes() {
1284 let rule = MD028NoBlanksBlockquote;
1287 let content = "> real quote\n\n<!--\n> commented quote\n-->\n";
1288 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1289 let result = rule.check(&ctx).unwrap();
1290 assert!(
1291 result.is_empty(),
1292 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1293 );
1294 }
1295
1296 #[test]
1297 fn test_blockquote_after_comment_boundary_not_matched() {
1298 let rule = MD028NoBlanksBlockquote;
1301 let content = "<!--\n> commented quote\n-->\n\n> real quote\n";
1302 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1303 let result = rule.check(&ctx).unwrap();
1304 assert!(
1305 result.is_empty(),
1306 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1307 );
1308 }
1309
1310 #[test]
1311 fn test_fix_preserves_comment_boundary_content() {
1312 let rule = MD028NoBlanksBlockquote;
1314 let content = "> real quote\n\n<!--\n> commented quote A\n\n> commented quote B\n-->\n\n> another real quote\n";
1315 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1316 let fixed = rule.fix(&ctx).unwrap();
1317 assert_eq!(
1318 fixed, content,
1319 "Fix should not modify content when blockquotes are separated by comment boundaries"
1320 );
1321 }
1322
1323 #[test]
1324 fn test_inline_html_comment_does_not_suppress_warning() {
1325 let rule = MD028NoBlanksBlockquote;
1328 let content = "> quote with <!-- inline comment -->\n\n> continuation\n";
1329 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1330 let result = rule.check(&ctx).unwrap();
1331 assert!(
1333 !result.is_empty(),
1334 "Should still flag blank lines between blockquotes with inline HTML comments"
1335 );
1336 }
1337
1338 #[test]
1344 fn test_comment_with_blockquote_markers_on_delimiters() {
1345 let rule = MD028NoBlanksBlockquote;
1348 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1349 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1350 let result = rule.check(&ctx).unwrap();
1351 assert_eq!(
1353 result.len(),
1354 1,
1355 "Should only warn about blank between real quotes, got: {result:?}"
1356 );
1357 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1358 }
1359
1360 #[test]
1361 fn test_commented_blockquote_between_real_blockquotes() {
1362 let rule = MD028NoBlanksBlockquote;
1366 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1367 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1368 let result = rule.check(&ctx).unwrap();
1369 assert!(
1370 result.is_empty(),
1371 "Should NOT warn when non-blockquote content (HTML comment) separates blockquotes, got: {result:?}"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_code_block_with_blockquote_markers_between_real_blockquotes() {
1377 let rule = MD028NoBlanksBlockquote;
1379 let content = "> real A\n\n```\n> not a blockquote\n```\n\n> real B";
1380 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1381 let result = rule.check(&ctx).unwrap();
1382 assert!(
1383 result.is_empty(),
1384 "Should NOT warn when code block with > markers separates blockquotes, got: {result:?}"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_frontmatter_with_blockquote_markers_does_not_cause_false_positive() {
1390 let rule = MD028NoBlanksBlockquote;
1392 let content = "---\n> frontmatter value\n---\n\n> real quote A\n\n> real quote B";
1393 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1394 let result = rule.check(&ctx).unwrap();
1395 assert_eq!(
1397 result.len(),
1398 1,
1399 "Should only flag the blank between real quotes, got: {result:?}"
1400 );
1401 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1402 }
1403
1404 #[test]
1405 fn test_fix_does_not_modify_comment_separated_blockquotes() {
1406 let rule = MD028NoBlanksBlockquote;
1408 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1409 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1410 let fixed = rule.fix(&ctx).unwrap();
1411 assert_eq!(
1412 fixed, content,
1413 "Fix should not modify content when blockquotes are separated by HTML comment"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_fix_works_correctly_with_comment_before_real_blockquotes() {
1419 let rule = MD028NoBlanksBlockquote;
1422 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1423 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1424 let fixed = rule.fix(&ctx).unwrap();
1425 assert!(
1427 fixed.contains("> real quote A\n>\n> real quote B"),
1428 "Fix should add > marker between real quotes, got: {fixed}"
1429 );
1430 assert!(
1432 fixed.contains("<!-- > not a real blockquote"),
1433 "Fix should not modify comment content"
1434 );
1435 }
1436
1437 #[test]
1438 fn test_html_block_with_angle_brackets_not_flagged() {
1439 let rule = MD028NoBlanksBlockquote;
1442 let content = "<div>\n> not a real blockquote\n\n> also not real\n</div>";
1443 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1444 let result = rule.check(&ctx).unwrap();
1445
1446 assert!(
1447 result.is_empty(),
1448 "Lines inside HTML blocks should not trigger MD028. Got: {result:?}"
1449 );
1450 }
1451
1452 #[test]
1456 fn test_roundtrip_single_blank() {
1457 let rule = MD028NoBlanksBlockquote;
1458 let content = "> First\n\n> Third";
1459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1460 let fixed = rule.fix(&ctx).unwrap();
1461 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1462 let warnings = rule.check(&ctx2).unwrap();
1463 assert!(
1464 warnings.is_empty(),
1465 "Roundtrip should produce zero warnings, got: {warnings:?}"
1466 );
1467 }
1468
1469 #[test]
1470 fn test_roundtrip_multiple_blanks() {
1471 let rule = MD028NoBlanksBlockquote;
1472 let content = "> First\n\n\n> Fourth";
1473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474 let fixed = rule.fix(&ctx).unwrap();
1475 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1476 let warnings = rule.check(&ctx2).unwrap();
1477 assert!(
1478 warnings.is_empty(),
1479 "Roundtrip should produce zero warnings, got: {warnings:?}"
1480 );
1481 }
1482
1483 #[test]
1484 fn test_roundtrip_nested() {
1485 let rule = MD028NoBlanksBlockquote;
1486 let content = ">> Nested\n\n>> More";
1487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1488 let fixed = rule.fix(&ctx).unwrap();
1489 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1490 let warnings = rule.check(&ctx2).unwrap();
1491 assert!(
1492 warnings.is_empty(),
1493 "Roundtrip should produce zero warnings, got: {warnings:?}"
1494 );
1495 }
1496
1497 #[test]
1498 fn test_roundtrip_indented() {
1499 let rule = MD028NoBlanksBlockquote;
1500 let content = " > Indented\n\n > More";
1501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502 let fixed = rule.fix(&ctx).unwrap();
1503 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1504 let warnings = rule.check(&ctx2).unwrap();
1505 assert!(
1506 warnings.is_empty(),
1507 "Roundtrip should produce zero warnings, got: {warnings:?}"
1508 );
1509 }
1510
1511 #[test]
1512 fn test_roundtrip_deeply_nested() {
1513 let rule = MD028NoBlanksBlockquote;
1514 let content = ">>> Deep\n\n>>> More";
1515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1516 let fixed = rule.fix(&ctx).unwrap();
1517 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1518 let warnings = rule.check(&ctx2).unwrap();
1519 assert!(
1520 warnings.is_empty(),
1521 "Roundtrip should produce zero warnings, got: {warnings:?}"
1522 );
1523 }
1524
1525 #[test]
1526 fn test_roundtrip_multi_blockquotes() {
1527 let rule = MD028NoBlanksBlockquote;
1528 let content = "> First\n> Line\n\n> Second\n> Line\n\n> Third\n";
1529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1530 let fixed = rule.fix(&ctx).unwrap();
1531 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1532 let warnings = rule.check(&ctx2).unwrap();
1533 assert!(
1534 warnings.is_empty(),
1535 "Roundtrip should produce zero warnings, got: {warnings:?}"
1536 );
1537 }
1538
1539 #[test]
1540 fn test_roundtrip_idempotent() {
1541 let rule = MD028NoBlanksBlockquote;
1542 let content = "> First\n\n> Second\n\n> Third\n";
1543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544 let fixed1 = rule.fix(&ctx).unwrap();
1545 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
1546 let fixed2 = rule.fix(&ctx2).unwrap();
1547 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1548 }
1549
1550 #[test]
1551 fn test_html_block_does_not_leak_into_adjacent_blockquotes() {
1552 let rule = MD028NoBlanksBlockquote;
1554 let content =
1555 "<details>\n<summary>Click</summary>\n> inside html block\n</details>\n\n> real quote A\n\n> real quote B";
1556 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1557 let result = rule.check(&ctx).unwrap();
1558
1559 assert_eq!(
1561 result.len(),
1562 1,
1563 "Expected 1 warning for blank between real blockquotes after HTML block. Got: {result:?}"
1564 );
1565 }
1566}