1use crate::config::MarkdownFlavor;
17use crate::lint_context::LineInfo;
18use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
19use crate::rule_config_serde::{RuleConfig, load_rule_config};
20use crate::utils::range_utils::calculate_line_range;
21use serde::{Deserialize, Serialize};
22
23const GFM_ALERT_TYPES: &[&str] = &["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"];
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
29#[serde(rename_all = "kebab-case")]
30pub struct MD028Config {
31 #[serde(default)]
38 pub fix: bool,
39}
40
41impl RuleConfig for MD028Config {
42 const RULE_NAME: &'static str = "MD028";
43}
44
45#[derive(Clone, Default)]
46pub struct MD028NoBlanksBlockquote {
47 config: MD028Config,
48}
49
50impl MD028NoBlanksBlockquote {
51 pub fn new() -> Self {
52 Self::default()
53 }
54
55 pub fn with_config(config: MD028Config) -> Self {
56 Self { config }
57 }
58
59 pub fn with_fix(fix: bool) -> Self {
61 Self {
62 config: MD028Config { fix },
63 }
64 }
65
66 #[inline]
68 fn is_blockquote_line(line: &str) -> bool {
69 if !line.as_bytes().contains(&b'>') {
71 return false;
72 }
73 line.trim_start().starts_with('>')
74 }
75
76 fn get_blockquote_info(line: &str) -> (usize, usize) {
79 let bytes = line.as_bytes();
80 let mut i = 0;
81
82 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
84 i += 1;
85 }
86
87 let whitespace_end = i;
88 let mut level = 0;
89
90 while i < bytes.len() {
92 if bytes[i] == b'>' {
93 level += 1;
94 i += 1;
95 } else if bytes[i] == b' ' || bytes[i] == b'\t' {
96 i += 1;
97 } else {
98 break;
99 }
100 }
101
102 (level, whitespace_end)
103 }
104
105 #[inline]
107 fn is_in_skip_context(line_infos: &[LineInfo], idx: usize) -> bool {
108 if let Some(li) = line_infos.get(idx) {
109 li.in_html_comment || li.in_mdx_comment || li.in_code_block || li.in_html_block || li.in_front_matter
110 } else {
111 false
112 }
113 }
114
115 fn has_content_between(lines: &[&str], line_infos: &[LineInfo], start: usize, end: usize) -> bool {
120 for (offset, line) in lines[start..end].iter().enumerate() {
121 let idx = start + offset;
122 if Self::is_in_skip_context(line_infos, idx) {
125 if !line.trim().is_empty() {
126 return true;
127 }
128 continue;
129 }
130 let trimmed = line.trim();
131 if !trimmed.is_empty() && !trimmed.starts_with('>') {
133 return true;
134 }
135 }
136 false
137 }
138
139 #[inline]
143 fn is_gfm_alert_line(line: &str) -> bool {
144 if !line.contains("[!") {
146 return false;
147 }
148
149 let trimmed = line.trim_start();
151 if !trimmed.starts_with('>') {
152 return false;
153 }
154
155 let content = trimmed
157 .trim_start_matches('>')
158 .trim_start_matches([' ', '\t'])
159 .trim_start_matches('>')
160 .trim_start();
161
162 if !content.starts_with("[!") {
164 return false;
165 }
166
167 if let Some(end_bracket) = content.find(']') {
169 let alert_type = &content[2..end_bracket];
170 return GFM_ALERT_TYPES.iter().any(|&t| t.eq_ignore_ascii_case(alert_type));
171 }
172
173 false
174 }
175
176 #[inline]
181 fn is_obsidian_callout_line(line: &str) -> bool {
182 if !line.contains("[!") {
184 return false;
185 }
186
187 let trimmed = line.trim_start();
189 if !trimmed.starts_with('>') {
190 return false;
191 }
192
193 let content = trimmed
195 .trim_start_matches('>')
196 .trim_start_matches([' ', '\t'])
197 .trim_start_matches('>')
198 .trim_start();
199
200 if !content.starts_with("[!") {
202 return false;
203 }
204
205 if let Some(end_bracket) = content.find(']') {
207 if end_bracket > 2 {
209 let alert_type = &content[2..end_bracket];
211 return !alert_type.is_empty()
212 && alert_type.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_');
213 }
214 }
215
216 false
217 }
218
219 #[inline]
223 fn is_callout_line(line: &str, flavor: MarkdownFlavor) -> bool {
224 match flavor {
225 MarkdownFlavor::Obsidian => Self::is_obsidian_callout_line(line),
226 _ => Self::is_gfm_alert_line(line),
227 }
228 }
229
230 fn find_blockquote_start(lines: &[&str], line_infos: &[LineInfo], from_idx: usize) -> Option<usize> {
233 if from_idx >= lines.len() {
234 return None;
235 }
236
237 let mut start_idx = from_idx;
239
240 for i in (0..=from_idx).rev() {
241 if Self::is_in_skip_context(line_infos, i) {
243 continue;
244 }
245
246 let line = lines[i];
247
248 if Self::is_blockquote_line(line) {
250 start_idx = i;
251 } else if line.trim().is_empty() {
252 if start_idx == from_idx && !Self::is_blockquote_line(lines[from_idx]) {
255 continue;
256 }
257 break;
259 } else {
260 break;
262 }
263 }
264
265 if Self::is_blockquote_line(lines[start_idx]) && !Self::is_in_skip_context(line_infos, start_idx) {
267 Some(start_idx)
268 } else {
269 None
270 }
271 }
272
273 fn is_callout_block(
277 lines: &[&str],
278 line_infos: &[LineInfo],
279 blockquote_line_idx: usize,
280 flavor: MarkdownFlavor,
281 ) -> bool {
282 if let Some(start_idx) = Self::find_blockquote_start(lines, line_infos, blockquote_line_idx) {
284 return Self::is_callout_line(lines[start_idx], flavor);
286 }
287 false
288 }
289
290 fn are_likely_same_blockquote(
292 lines: &[&str],
293 line_infos: &[LineInfo],
294 prev_idx: usize,
295 next_idx: usize,
296 flavor: MarkdownFlavor,
297 ) -> bool {
298 let prev_is_callout = Self::is_callout_block(lines, line_infos, prev_idx, flavor);
314 let next_is_callout = Self::is_callout_block(lines, line_infos, next_idx, flavor);
315 if prev_is_callout || next_is_callout {
316 return false;
317 }
318
319 if Self::has_content_between(lines, line_infos, prev_idx + 1, next_idx) {
321 return false;
322 }
323
324 let (prev_level, prev_whitespace_end) = Self::get_blockquote_info(lines[prev_idx]);
326 let (next_level, next_whitespace_end) = Self::get_blockquote_info(lines[next_idx]);
327
328 if next_level < prev_level {
331 return false;
332 }
333
334 let prev_line = lines[prev_idx];
336 let next_line = lines[next_idx];
337 let prev_indent = &prev_line[..prev_whitespace_end];
338 let next_indent = &next_line[..next_whitespace_end];
339
340 prev_indent == next_indent
343 }
344
345 fn problematic_quote_pair(
347 lines: &[&str],
348 line_infos: &[LineInfo],
349 prev_idx: usize,
350 next_idx: usize,
351 flavor: MarkdownFlavor,
352 ) -> Option<(usize, String)> {
353 if !Self::are_likely_same_blockquote(lines, line_infos, prev_idx, next_idx, flavor) {
356 return None;
357 }
358
359 let line = lines[prev_idx];
360 let (level, whitespace_end) = Self::get_blockquote_info(line);
361 let indent = &line[..whitespace_end];
362 let mut fix = String::with_capacity(indent.len() + level);
363 fix.push_str(indent);
364 for _ in 0..level {
365 fix.push('>');
366 }
367
368 Some((level, fix))
369 }
370}
371
372impl Rule for MD028NoBlanksBlockquote {
373 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
374 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(28))
375 }
376
377 fn name(&self) -> &'static str {
378 "MD028"
379 }
380
381 fn description(&self) -> &'static str {
382 "Blank line inside blockquote"
383 }
384
385 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
386 if !ctx.has_char('>') {
388 return Ok(Vec::new());
389 }
390
391 let mut warnings = Vec::new();
392
393 let lines = ctx.raw_lines();
395
396 let mut blank_line_indices = Vec::new();
398 let mut quote_line_indices = Vec::new();
399
400 for (line_idx, line) in lines.iter().enumerate() {
401 if line_idx < ctx.lines.len() {
403 let li = &ctx.lines[line_idx];
404 if li.in_code_block || li.in_html_comment || li.in_mdx_comment || li.in_html_block || li.in_front_matter
405 {
406 continue;
407 }
408 }
409
410 if line.trim().is_empty() {
411 blank_line_indices.push(line_idx);
412 } else if Self::is_blockquote_line(line) {
413 quote_line_indices.push(line_idx);
414 }
415 }
416
417 if quote_line_indices.is_empty() {
419 return Ok(Vec::new());
420 }
421
422 let mut quote_cursor = 0;
423 let mut cached_pair = None;
424 let mut pair_fix = None;
425
426 for &line_idx in &blank_line_indices {
428 let line_num = line_idx + 1;
429
430 while quote_cursor < quote_line_indices.len() && quote_line_indices[quote_cursor] < line_idx {
432 quote_cursor += 1;
433 }
434 let (Some(&prev_idx), Some(&next_idx)) = (
435 quote_cursor.checked_sub(1).and_then(|i| quote_line_indices.get(i)),
436 quote_line_indices.get(quote_cursor),
437 ) else {
438 continue;
439 };
440
441 if cached_pair != Some((prev_idx, next_idx)) {
444 cached_pair = Some((prev_idx, next_idx));
445 pair_fix = Self::problematic_quote_pair(lines, &ctx.lines, prev_idx, next_idx, ctx.flavor);
446 }
447
448 if let Some((level, fix_content)) = &pair_fix {
450 let line = lines[line_idx];
451 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
452
453 warnings.push(LintWarning {
454 rule_name: Some(self.name().to_string()),
455 message: format!("Blank line inside blockquote (level {level})"),
456 line: start_line,
457 column: start_col,
458 end_line,
459 end_column: end_col,
460 severity: Severity::Warning,
461 fix: if self.config.fix {
464 Some(Fix::new(
465 ctx.line_column_byte_range_with_length(line_num, 1, line.len()),
466 fix_content.clone(),
467 ))
468 } else {
469 None
470 },
471 });
472 }
473 }
474
475 Ok(warnings)
476 }
477
478 fn fixes_from_diagnostics(&self) -> bool {
479 true
480 }
481
482 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
483 if !self.config.fix || self.should_skip(ctx) {
486 return Ok(ctx.content.to_string());
487 }
488 let warnings = self.check(ctx)?;
489 if warnings.is_empty() {
490 return Ok(ctx.content.to_string());
491 }
492 let warnings =
493 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
494 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
495 .map_err(crate::rule::LintError::InvalidInput)
496 }
497
498 fn category(&self) -> RuleCategory {
500 RuleCategory::Blockquote
501 }
502
503 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
505 !ctx.likely_has_blockquotes()
506 }
507
508 fn as_any(&self) -> &dyn std::any::Any {
509 self
510 }
511
512 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
513 where
514 Self: Sized,
515 {
516 let rule_config: MD028Config = load_rule_config(config);
517 Box::new(MD028NoBlanksBlockquote::with_config(rule_config))
518 }
519
520 crate::impl_rule_config_sections!(MD028Config);
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use crate::lint_context::LintContext;
527
528 #[test]
529 fn test_default_warns_but_does_not_merge_blockquotes() {
530 let rule = MD028NoBlanksBlockquote::from_config(&crate::config::Config::default());
536 let content = "> Quote by Alice.\n\n> Quote by Bob.\n";
537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538
539 let warnings = rule.check(&ctx).unwrap();
540 assert_eq!(warnings.len(), 1, "detection should still fire by default");
541 assert!(warnings[0].fix.is_none(), "default warnings must not carry a fix");
542
543 let fixed = rule.fix(&ctx).unwrap();
544 assert_eq!(fixed, content, "default fmt must not merge distinct blockquotes");
545 }
546
547 #[test]
548 fn test_fix_enabled_merges_blockquotes() {
549 let rule = MD028NoBlanksBlockquote::with_fix(true);
552 let content = "> A quote\n\n> its continuation\n";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let fixed = rule.fix(&ctx).unwrap();
555 assert_eq!(fixed, "> A quote\n>\n> its continuation\n");
556 }
557
558 #[test]
559 fn test_multiple_gaps_preserve_callout_boundaries() {
560 let content = "> plain first\n\n\n> plain second\n\n> [!NOTE]\n> alert\n\n\n> after alert\n\n> after normal\n";
561 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
562 for fix_enabled in [false, true] {
563 let rule = MD028NoBlanksBlockquote::with_fix(fix_enabled);
564 let warnings = rule.check(&ctx).unwrap();
565 assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), vec![2, 3, 11]);
566 assert!(warnings.iter().all(|w| w.fix.is_some() == fix_enabled));
567 let expected = if fix_enabled {
568 "> plain first\n>\n>\n> plain second\n\n> [!NOTE]\n> alert\n\n\n> after alert\n>\n> after normal\n"
569 } else {
570 content
571 };
572 assert_eq!(rule.fix(&ctx).unwrap(), expected);
573 }
574 }
575
576 #[test]
577 fn test_no_blockquotes() {
578 let rule = MD028NoBlanksBlockquote::with_fix(true);
579 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let result = rule.check(&ctx).unwrap();
582 assert!(result.is_empty(), "Should not flag content without blockquotes");
583 }
584
585 #[test]
586 fn test_valid_blockquote_no_blanks() {
587 let rule = MD028NoBlanksBlockquote::with_fix(true);
588 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590 let result = rule.check(&ctx).unwrap();
591 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
592 }
593
594 #[test]
595 fn test_blockquote_with_empty_line_marker() {
596 let rule = MD028NoBlanksBlockquote::with_fix(true);
597 let content = "> First line\n>\n> Third line";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let result = rule.check(&ctx).unwrap();
601 assert!(result.is_empty(), "Should not flag lines with just > marker");
602 }
603
604 #[test]
605 fn test_blockquote_with_empty_line_marker_and_space() {
606 let rule = MD028NoBlanksBlockquote::with_fix(true);
607 let content = "> First line\n> \n> Third line";
609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610 let result = rule.check(&ctx).unwrap();
611 assert!(result.is_empty(), "Should not flag lines with > and space");
612 }
613
614 #[test]
615 fn test_blank_line_in_blockquote() {
616 let rule = MD028NoBlanksBlockquote::with_fix(true);
617 let content = "> First line\n\n> Third line";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621 assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
622 assert_eq!(result[0].line, 2);
623 assert!(result[0].message.contains("Blank line inside blockquote"));
624 }
625
626 #[test]
627 fn test_multiple_blank_lines() {
628 let rule = MD028NoBlanksBlockquote::with_fix(true);
629 let content = "> First\n\n\n> Fourth";
630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631 let result = rule.check(&ctx).unwrap();
632 assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
634 assert_eq!(result[0].line, 2);
635 assert_eq!(result[1].line, 3);
636 }
637
638 #[test]
639 fn test_nested_blockquote_blank() {
640 let rule = MD028NoBlanksBlockquote::with_fix(true);
641 let content = ">> Nested quote\n\n>> More nested";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644 assert_eq!(result.len(), 1);
645 assert_eq!(result[0].line, 2);
646 }
647
648 #[test]
649 fn test_nested_blockquote_with_marker() {
650 let rule = MD028NoBlanksBlockquote::with_fix(true);
651 let content = ">> Nested quote\n>>\n>> More nested";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let result = rule.check(&ctx).unwrap();
655 assert!(result.is_empty(), "Should not flag lines with >> marker");
656 }
657
658 #[test]
659 fn test_fix_single_blank() {
660 let rule = MD028NoBlanksBlockquote::with_fix(true);
661 let content = "> First\n\n> Third";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663 let fixed = rule.fix(&ctx).unwrap();
664 assert_eq!(fixed, "> First\n>\n> Third");
665 }
666
667 #[test]
668 fn test_fix_nested_blank() {
669 let rule = MD028NoBlanksBlockquote::with_fix(true);
670 let content = ">> Nested\n\n>> More";
671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672 let fixed = rule.fix(&ctx).unwrap();
673 assert_eq!(fixed, ">> Nested\n>>\n>> More");
674 }
675
676 #[test]
677 fn test_fix_with_indentation() {
678 let rule = MD028NoBlanksBlockquote::with_fix(true);
679 let content = " > Indented quote\n\n > More";
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681 let fixed = rule.fix(&ctx).unwrap();
682 assert_eq!(fixed, " > Indented quote\n >\n > More");
683 }
684
685 #[test]
686 fn test_mixed_levels() {
687 let rule = MD028NoBlanksBlockquote::with_fix(true);
688 let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
691 let result = rule.check(&ctx).unwrap();
692 assert_eq!(result.len(), 1);
695 assert_eq!(result[0].line, 2);
696 }
697
698 #[test]
699 fn test_blockquote_with_code_block() {
700 let rule = MD028NoBlanksBlockquote::with_fix(true);
701 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let result = rule.check(&ctx).unwrap();
704 assert!(result.is_empty(), "Should not flag line with > marker");
706 }
707
708 #[test]
709 fn test_category() {
710 let rule = MD028NoBlanksBlockquote::with_fix(true);
711 assert_eq!(rule.category(), RuleCategory::Blockquote);
712 }
713
714 #[test]
715 fn test_should_skip() {
716 let rule = MD028NoBlanksBlockquote::with_fix(true);
717 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard, None);
718 assert!(rule.should_skip(&ctx1));
719
720 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard, None);
721 assert!(!rule.should_skip(&ctx2));
722 }
723
724 #[test]
725 fn test_empty_content() {
726 let rule = MD028NoBlanksBlockquote::with_fix(true);
727 let content = "";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let result = rule.check(&ctx).unwrap();
730 assert!(result.is_empty());
731 }
732
733 #[test]
734 fn test_blank_after_blockquote() {
735 let rule = MD028NoBlanksBlockquote::with_fix(true);
736 let content = "> Quote\n\nNot a quote";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739 assert!(result.is_empty(), "Blank line after blockquote ends is valid");
740 }
741
742 #[test]
743 fn test_blank_before_blockquote() {
744 let rule = MD028NoBlanksBlockquote::with_fix(true);
745 let content = "Not a quote\n\n> Quote";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747 let result = rule.check(&ctx).unwrap();
748 assert!(result.is_empty(), "Blank line before blockquote starts is valid");
749 }
750
751 #[test]
752 fn test_preserve_trailing_newline() {
753 let rule = MD028NoBlanksBlockquote::with_fix(true);
754 let content = "> Quote\n\n> More\n";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let fixed = rule.fix(&ctx).unwrap();
757 assert!(fixed.ends_with('\n'));
758
759 let content_no_newline = "> Quote\n\n> More";
760 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard, None);
761 let fixed2 = rule.fix(&ctx2).unwrap();
762 assert!(!fixed2.ends_with('\n'));
763 }
764
765 #[test]
766 fn test_document_structure_extension() {
767 let rule = MD028NoBlanksBlockquote::with_fix(true);
768 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
771 assert!(result.is_empty(), "Should not flag valid blockquote");
772
773 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard, None);
775 assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
776 }
777
778 #[test]
779 fn test_deeply_nested_blank() {
780 let rule = MD028NoBlanksBlockquote::with_fix(true);
781 let content = ">>> Deep nest\n\n>>> More deep";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let result = rule.check(&ctx).unwrap();
784 assert_eq!(result.len(), 1);
785
786 let fixed = rule.fix(&ctx).unwrap();
787 assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
788 }
789
790 #[test]
791 fn test_deeply_nested_with_marker() {
792 let rule = MD028NoBlanksBlockquote::with_fix(true);
793 let content = ">>> Deep nest\n>>>\n>>> More deep";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let result = rule.check(&ctx).unwrap();
797 assert!(result.is_empty(), "Should not flag lines with >>> marker");
798 }
799
800 #[test]
801 fn test_complex_blockquote_structure() {
802 let rule = MD028NoBlanksBlockquote::with_fix(true);
803 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806 let result = rule.check(&ctx).unwrap();
807 assert!(result.is_empty(), "Should not flag line with > marker");
808 }
809
810 #[test]
811 fn test_complex_with_blank() {
812 let rule = MD028NoBlanksBlockquote::with_fix(true);
813 let content = "> Level 1\n> > Nested\n\n> Back to level 1";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.check(&ctx).unwrap();
818 assert_eq!(
819 result.len(),
820 0,
821 "Blank between different nesting levels is not inside blockquote"
822 );
823 }
824
825 #[test]
832 fn test_gfm_alert_detection_note() {
833 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
834 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE] Additional text"));
835 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
836 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!note]")); assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!Note]")); }
839
840 #[test]
841 fn test_gfm_alert_detection_all_types() {
842 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!NOTE]"));
844 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!TIP]"));
845 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!IMPORTANT]"));
846 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!WARNING]"));
847 assert!(MD028NoBlanksBlockquote::is_gfm_alert_line("> [!CAUTION]"));
848 }
849
850 #[test]
851 fn test_gfm_alert_detection_not_alert() {
852 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> Regular blockquote"));
854 assert!(!MD028NoBlanksBlockquote::is_gfm_alert_line("> [!INVALID]"));
855 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("> ")); }
861
862 #[test]
863 fn test_gfm_alerts_separated_by_blank_line() {
864 let rule = MD028NoBlanksBlockquote::with_fix(true);
866 let content = "> [!TIP]\n> Here's a github tip\n\n> [!NOTE]\n> Here's a github note";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868 let result = rule.check(&ctx).unwrap();
869 assert!(result.is_empty(), "Should not flag blank line between GFM alerts");
870 }
871
872 #[test]
873 fn test_gfm_alerts_all_five_types_separated() {
874 let rule = MD028NoBlanksBlockquote::with_fix(true);
876 let content = r#"> [!NOTE]
877> Note content
878
879> [!TIP]
880> Tip content
881
882> [!IMPORTANT]
883> Important content
884
885> [!WARNING]
886> Warning content
887
888> [!CAUTION]
889> Caution content"#;
890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891 let result = rule.check(&ctx).unwrap();
892 assert!(
893 result.is_empty(),
894 "Should not flag blank lines between any GFM alert types"
895 );
896 }
897
898 #[test]
899 fn test_gfm_alert_with_multiple_lines() {
900 let rule = MD028NoBlanksBlockquote::with_fix(true);
902 let content = r#"> [!WARNING]
903> This is a warning
904> with multiple lines
905> of content
906
907> [!NOTE]
908> This is a note"#;
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 let result = rule.check(&ctx).unwrap();
911 assert!(
912 result.is_empty(),
913 "Should not flag blank line between multi-line GFM alerts"
914 );
915 }
916
917 #[test]
918 fn test_gfm_alert_followed_by_regular_blockquote() {
919 let rule = MD028NoBlanksBlockquote::with_fix(true);
921 let content = "> [!TIP]\n> A helpful tip\n\n> Regular blockquote";
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let result = rule.check(&ctx).unwrap();
924 assert!(result.is_empty(), "Should not flag blank line after GFM alert");
925 }
926
927 #[test]
928 fn test_regular_blockquote_followed_by_gfm_alert() {
929 let rule = MD028NoBlanksBlockquote::with_fix(true);
931 let content = "> Regular blockquote\n\n> [!NOTE]\n> Important note";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.check(&ctx).unwrap();
934 assert!(result.is_empty(), "Should not flag blank line before GFM alert");
935 }
936
937 #[test]
938 fn test_regular_blockquotes_still_flagged() {
939 let rule = MD028NoBlanksBlockquote::with_fix(true);
941 let content = "> First blockquote\n\n> Second blockquote";
942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 let result = rule.check(&ctx).unwrap();
944 assert_eq!(
945 result.len(),
946 1,
947 "Should still flag blank line between regular blockquotes"
948 );
949 }
950
951 #[test]
952 fn test_gfm_alert_blank_line_within_same_alert() {
953 let rule = MD028NoBlanksBlockquote::with_fix(true);
956 let content = "> [!NOTE]\n> First paragraph\n\n> Second paragraph of same note";
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958 let result = rule.check(&ctx).unwrap();
959 assert!(
964 result.is_empty(),
965 "GFM alert status propagates to subsequent blockquote lines"
966 );
967 }
968
969 #[test]
970 fn test_gfm_alert_case_insensitive() {
971 let rule = MD028NoBlanksBlockquote::with_fix(true);
972 let content = "> [!note]\n> lowercase\n\n> [!TIP]\n> uppercase\n\n> [!Warning]\n> mixed";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let result = rule.check(&ctx).unwrap();
975 assert!(result.is_empty(), "GFM alert detection should be case insensitive");
976 }
977
978 #[test]
979 fn test_gfm_alert_with_nested_blockquote() {
980 let rule = MD028NoBlanksBlockquote::with_fix(true);
982 let content = "> [!NOTE]\n> > Nested quote inside alert\n\n> [!TIP]\n> Tip";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985 assert!(
986 result.is_empty(),
987 "Should not flag blank between alerts even with nested content"
988 );
989 }
990
991 #[test]
992 fn test_gfm_alert_indented() {
993 let rule = MD028NoBlanksBlockquote::with_fix(true);
994 let content = " > [!NOTE]\n > Indented note\n\n > [!TIP]\n > Indented tip";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let result = rule.check(&ctx).unwrap();
998 assert!(result.is_empty(), "Should not flag blank between indented GFM alerts");
999 }
1000
1001 #[test]
1002 fn test_gfm_alert_mixed_with_regular_content() {
1003 let rule = MD028NoBlanksBlockquote::with_fix(true);
1005 let content = r#"# Heading
1006
1007Some paragraph.
1008
1009> [!NOTE]
1010> Important note
1011
1012More paragraph text.
1013
1014> [!WARNING]
1015> Be careful!
1016
1017Final text."#;
1018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1019 let result = rule.check(&ctx).unwrap();
1020 assert!(
1021 result.is_empty(),
1022 "GFM alerts in mixed document should not trigger warnings"
1023 );
1024 }
1025
1026 #[test]
1027 fn test_gfm_alert_fix_not_applied() {
1028 let rule = MD028NoBlanksBlockquote::with_fix(true);
1030 let content = "> [!TIP]\n> Tip\n\n> [!NOTE]\n> Note";
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032 let fixed = rule.fix(&ctx).unwrap();
1033 assert_eq!(fixed, content, "Fix should not modify blank lines between GFM alerts");
1034 }
1035
1036 #[test]
1037 fn test_gfm_alert_multiple_blank_lines_between() {
1038 let rule = MD028NoBlanksBlockquote::with_fix(true);
1040 let content = "> [!NOTE]\n> Note\n\n\n> [!TIP]\n> Tip";
1041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1042 let result = rule.check(&ctx).unwrap();
1043 assert!(
1044 result.is_empty(),
1045 "Should not flag multiple blank lines between GFM alerts"
1046 );
1047 }
1048
1049 #[test]
1056 fn test_obsidian_callout_detection() {
1057 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]"));
1059 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!info]"));
1060 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!todo]"));
1061 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!success]"));
1062 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!question]"));
1063 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!failure]"));
1064 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!danger]"));
1065 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!bug]"));
1066 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!example]"));
1067 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!quote]"));
1068 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!cite]"));
1069 }
1070
1071 #[test]
1072 fn test_obsidian_callout_custom_types() {
1073 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!custom]"));
1075 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my-callout]"));
1076 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!my_callout]"));
1077 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!MyCallout]"));
1078 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!callout123]"));
1079 }
1080
1081 #[test]
1082 fn test_obsidian_callout_foldable() {
1083 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!NOTE]+ Expanded"));
1085 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1086 "> [!NOTE]- Collapsed"
1087 ));
1088 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!WARNING]+"));
1089 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!TIP]-"));
1090 }
1091
1092 #[test]
1093 fn test_obsidian_callout_with_title() {
1094 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1096 "> [!NOTE] Custom Title"
1097 ));
1098 assert!(MD028NoBlanksBlockquote::is_obsidian_callout_line(
1099 "> [!WARNING]+ Be Careful!"
1100 ));
1101 }
1102
1103 #[test]
1104 fn test_obsidian_callout_invalid() {
1105 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1107 "> Regular blockquote"
1108 ));
1109 assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [NOTE]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("> [!]")); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line(
1112 "Regular text [!NOTE]"
1113 )); assert!(!MD028NoBlanksBlockquote::is_obsidian_callout_line("")); }
1116
1117 #[test]
1118 fn test_obsidian_callouts_separated_by_blank_line() {
1119 let rule = MD028NoBlanksBlockquote::with_fix(true);
1121 let content = "> [!info]\n> Some info\n\n> [!todo]\n> A todo item";
1122 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1123 let result = rule.check(&ctx).unwrap();
1124 assert!(
1125 result.is_empty(),
1126 "Should not flag blank line between Obsidian callouts"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_obsidian_custom_callouts_separated() {
1132 let rule = MD028NoBlanksBlockquote::with_fix(true);
1134 let content = "> [!my-custom]\n> Custom content\n\n> [!another_custom]\n> More content";
1135 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1136 let result = rule.check(&ctx).unwrap();
1137 assert!(
1138 result.is_empty(),
1139 "Should not flag blank line between custom Obsidian callouts"
1140 );
1141 }
1142
1143 #[test]
1144 fn test_obsidian_foldable_callouts_separated() {
1145 let rule = MD028NoBlanksBlockquote::with_fix(true);
1147 let content = "> [!NOTE]+ Expanded\n> Content\n\n> [!WARNING]- Collapsed\n> Warning content";
1148 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1149 let result = rule.check(&ctx).unwrap();
1150 assert!(
1151 result.is_empty(),
1152 "Should not flag blank line between foldable Obsidian callouts"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_obsidian_custom_not_recognized_in_standard_flavor() {
1158 let rule = MD028NoBlanksBlockquote::with_fix(true);
1161 let content = "> [!info]\n> Info content\n\n> [!todo]\n> Todo content";
1162 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1163 let result = rule.check(&ctx).unwrap();
1164 assert_eq!(
1166 result.len(),
1167 1,
1168 "Custom callout types should be flagged in Standard flavor"
1169 );
1170 }
1171
1172 #[test]
1173 fn test_obsidian_gfm_alerts_work_in_both_flavors() {
1174 let rule = MD028NoBlanksBlockquote::with_fix(true);
1176 let content = "> [!NOTE]\n> Note\n\n> [!WARNING]\n> Warning";
1177
1178 let ctx_standard = LintContext::new(content, MarkdownFlavor::Standard, None);
1180 let result_standard = rule.check(&ctx_standard).unwrap();
1181 assert!(result_standard.is_empty(), "GFM alerts should work in Standard flavor");
1182
1183 let ctx_obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1185 let result_obsidian = rule.check(&ctx_obsidian).unwrap();
1186 assert!(
1187 result_obsidian.is_empty(),
1188 "GFM alerts should also work in Obsidian flavor"
1189 );
1190 }
1191
1192 #[test]
1193 fn test_obsidian_callout_all_builtin_types() {
1194 let rule = MD028NoBlanksBlockquote::with_fix(true);
1196 let content = r#"> [!note]
1197> Note
1198
1199> [!abstract]
1200> Abstract
1201
1202> [!summary]
1203> Summary
1204
1205> [!info]
1206> Info
1207
1208> [!todo]
1209> Todo
1210
1211> [!tip]
1212> Tip
1213
1214> [!success]
1215> Success
1216
1217> [!question]
1218> Question
1219
1220> [!warning]
1221> Warning
1222
1223> [!failure]
1224> Failure
1225
1226> [!danger]
1227> Danger
1228
1229> [!bug]
1230> Bug
1231
1232> [!example]
1233> Example
1234
1235> [!quote]
1236> Quote"#;
1237 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1238 let result = rule.check(&ctx).unwrap();
1239 assert!(result.is_empty(), "All Obsidian callout types should be recognized");
1240 }
1241
1242 #[test]
1243 fn test_obsidian_fix_not_applied_to_callouts() {
1244 let rule = MD028NoBlanksBlockquote::with_fix(true);
1246 let content = "> [!info]\n> Info\n\n> [!todo]\n> Todo";
1247 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1248 let fixed = rule.fix(&ctx).unwrap();
1249 assert_eq!(
1250 fixed, content,
1251 "Fix should not modify blank lines between Obsidian callouts"
1252 );
1253 }
1254
1255 #[test]
1256 fn test_obsidian_regular_blockquotes_still_flagged() {
1257 let rule = MD028NoBlanksBlockquote::with_fix(true);
1259 let content = "> First blockquote\n\n> Second blockquote";
1260 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1261 let result = rule.check(&ctx).unwrap();
1262 assert_eq!(
1263 result.len(),
1264 1,
1265 "Regular blockquotes should still be flagged in Obsidian flavor"
1266 );
1267 }
1268
1269 #[test]
1270 fn test_obsidian_callout_mixed_with_regular_blockquote() {
1271 let rule = MD028NoBlanksBlockquote::with_fix(true);
1273 let content = "> [!note]\n> Note content\n\n> Regular blockquote";
1274 let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
1275 let result = rule.check(&ctx).unwrap();
1276 assert!(
1277 result.is_empty(),
1278 "Should not flag blank after callout even if followed by regular blockquote"
1279 );
1280 }
1281
1282 #[test]
1286 fn test_html_comment_blockquotes_not_flagged() {
1287 let rule = MD028NoBlanksBlockquote::with_fix(true);
1288 let content = "## Responses\n\n<!--\n> First response text here.\n> <br>— Person One\n\n> Second response text here.\n> <br>— Person Two\n-->\n\nThe above responses are currently disabled.\n";
1289 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1290 let result = rule.check(&ctx).unwrap();
1291 assert!(
1292 result.is_empty(),
1293 "Should not flag blank lines inside HTML comments, got: {result:?}"
1294 );
1295 }
1296
1297 #[test]
1298 fn test_fix_preserves_html_comment_content() {
1299 let rule = MD028NoBlanksBlockquote::with_fix(true);
1300 let content = "<!--\n> First quote\n\n> Second quote\n-->\n";
1301 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1302 let fixed = rule.fix(&ctx).unwrap();
1303 assert_eq!(fixed, content, "Fix should not modify content inside HTML comments");
1304 }
1305
1306 #[test]
1307 fn test_multiline_html_comment_with_blockquotes() {
1308 let rule = MD028NoBlanksBlockquote::with_fix(true);
1309 let content = "# Title\n\n<!--\n> Quote A\n> Line 2\n\n> Quote B\n> Line 2\n\n> Quote C\n-->\n\nSome text\n";
1310 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1311 let result = rule.check(&ctx).unwrap();
1312 assert!(
1313 result.is_empty(),
1314 "Should not flag any blank lines inside HTML comments, got: {result:?}"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_blockquotes_outside_html_comment_still_flagged() {
1320 let rule = MD028NoBlanksBlockquote::with_fix(true);
1321 let content = "> First quote\n\n> Second quote\n\n<!--\n> Commented quote A\n\n> Commented quote B\n-->\n";
1322 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1323 let result = rule.check(&ctx).unwrap();
1324 for w in &result {
1327 assert!(
1328 w.line < 5,
1329 "Warning at line {} should not be inside HTML comment",
1330 w.line
1331 );
1332 }
1333 assert!(
1334 !result.is_empty(),
1335 "Should still flag blank line between blockquotes outside HTML comment"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_frontmatter_blockquote_like_content_not_flagged() {
1341 let rule = MD028NoBlanksBlockquote::with_fix(true);
1342 let content = "---\n> not a real blockquote\n\n> also not real\n---\n\n# Title\n";
1343 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1344 let result = rule.check(&ctx).unwrap();
1345 assert!(
1346 result.is_empty(),
1347 "Should not flag content inside frontmatter, got: {result:?}"
1348 );
1349 }
1350
1351 #[test]
1352 fn test_comment_boundary_does_not_leak_into_adjacent_blockquotes() {
1353 let rule = MD028NoBlanksBlockquote::with_fix(true);
1356 let content = "> real quote\n\n<!--\n> commented quote\n-->\n";
1357 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1358 let result = rule.check(&ctx).unwrap();
1359 assert!(
1360 result.is_empty(),
1361 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1362 );
1363 }
1364
1365 #[test]
1366 fn test_blockquote_after_comment_boundary_not_matched() {
1367 let rule = MD028NoBlanksBlockquote::with_fix(true);
1370 let content = "<!--\n> commented quote\n-->\n\n> real quote\n";
1371 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1372 let result = rule.check(&ctx).unwrap();
1373 assert!(
1374 result.is_empty(),
1375 "Should not match blockquotes across HTML comment boundaries, got: {result:?}"
1376 );
1377 }
1378
1379 #[test]
1380 fn test_fix_preserves_comment_boundary_content() {
1381 let rule = MD028NoBlanksBlockquote::with_fix(true);
1383 let content = "> real quote\n\n<!--\n> commented quote A\n\n> commented quote B\n-->\n\n> another real quote\n";
1384 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1385 let fixed = rule.fix(&ctx).unwrap();
1386 assert_eq!(
1387 fixed, content,
1388 "Fix should not modify content when blockquotes are separated by comment boundaries"
1389 );
1390 }
1391
1392 #[test]
1393 fn test_inline_html_comment_does_not_suppress_warning() {
1394 let rule = MD028NoBlanksBlockquote::with_fix(true);
1397 let content = "> quote with <!-- inline comment -->\n\n> continuation\n";
1398 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1399 let result = rule.check(&ctx).unwrap();
1400 assert!(
1402 !result.is_empty(),
1403 "Should still flag blank lines between blockquotes with inline HTML comments"
1404 );
1405 }
1406
1407 #[test]
1412 fn test_comment_with_blockquote_markers_on_delimiters() {
1413 let rule = MD028NoBlanksBlockquote::with_fix(true);
1416 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1417 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1418 let result = rule.check(&ctx).unwrap();
1419 assert_eq!(
1421 result.len(),
1422 1,
1423 "Should only warn about blank between real quotes, got: {result:?}"
1424 );
1425 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1426 }
1427
1428 #[test]
1429 fn test_commented_blockquote_between_real_blockquotes() {
1430 let rule = MD028NoBlanksBlockquote::with_fix(true);
1434 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1435 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1436 let result = rule.check(&ctx).unwrap();
1437 assert!(
1438 result.is_empty(),
1439 "Should NOT warn when non-blockquote content (HTML comment) separates blockquotes, got: {result:?}"
1440 );
1441 }
1442
1443 #[test]
1444 fn test_code_block_with_blockquote_markers_between_real_blockquotes() {
1445 let rule = MD028NoBlanksBlockquote::with_fix(true);
1447 let content = "> real A\n\n```\n> not a blockquote\n```\n\n> real B";
1448 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1449 let result = rule.check(&ctx).unwrap();
1450 assert!(
1451 result.is_empty(),
1452 "Should NOT warn when code block with > markers separates blockquotes, got: {result:?}"
1453 );
1454 }
1455
1456 #[test]
1457 fn test_frontmatter_with_blockquote_markers_does_not_cause_false_positive() {
1458 let rule = MD028NoBlanksBlockquote::with_fix(true);
1460 let content = "---\n> frontmatter value\n---\n\n> real quote A\n\n> real quote B";
1461 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1462 let result = rule.check(&ctx).unwrap();
1463 assert_eq!(
1465 result.len(),
1466 1,
1467 "Should only flag the blank between real quotes, got: {result:?}"
1468 );
1469 assert_eq!(result[0].line, 6, "Warning should be on line 6 (between real quotes)");
1470 }
1471
1472 #[test]
1473 fn test_fix_does_not_modify_comment_separated_blockquotes() {
1474 let rule = MD028NoBlanksBlockquote::with_fix(true);
1476 let content = "> real A\n\n<!-- > commented -->\n\n> real B";
1477 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1478 let fixed = rule.fix(&ctx).unwrap();
1479 assert_eq!(
1480 fixed, content,
1481 "Fix should not modify content when blockquotes are separated by HTML comment"
1482 );
1483 }
1484
1485 #[test]
1486 fn test_fix_works_correctly_with_comment_before_real_blockquotes() {
1487 let rule = MD028NoBlanksBlockquote::with_fix(true);
1490 let content = "<!-- > not a real blockquote\n\n> also not real -->\n\n> real quote A\n\n> real quote B";
1491 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1492 let fixed = rule.fix(&ctx).unwrap();
1493 assert!(
1495 fixed.contains("> real quote A\n>\n> real quote B"),
1496 "Fix should add > marker between real quotes, got: {fixed}"
1497 );
1498 assert!(
1500 fixed.contains("<!-- > not a real blockquote"),
1501 "Fix should not modify comment content"
1502 );
1503 }
1504
1505 #[test]
1506 fn test_html_block_with_angle_brackets_not_flagged() {
1507 let rule = MD028NoBlanksBlockquote::with_fix(true);
1510 let content = "<div>\n> not a real blockquote\n\n> also not real\n</div>";
1511 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1512 let result = rule.check(&ctx).unwrap();
1513
1514 assert!(
1515 result.is_empty(),
1516 "Lines inside HTML blocks should not trigger MD028. Got: {result:?}"
1517 );
1518 }
1519
1520 #[test]
1524 fn test_roundtrip_single_blank() {
1525 let rule = MD028NoBlanksBlockquote::with_fix(true);
1526 let content = "> First\n\n> Third";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528 let fixed = rule.fix(&ctx).unwrap();
1529 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1530 let warnings = rule.check(&ctx2).unwrap();
1531 assert!(
1532 warnings.is_empty(),
1533 "Roundtrip should produce zero warnings, got: {warnings:?}"
1534 );
1535 }
1536
1537 #[test]
1538 fn test_roundtrip_multiple_blanks() {
1539 let rule = MD028NoBlanksBlockquote::with_fix(true);
1540 let content = "> First\n\n\n> Fourth";
1541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542 let fixed = rule.fix(&ctx).unwrap();
1543 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1544 let warnings = rule.check(&ctx2).unwrap();
1545 assert!(
1546 warnings.is_empty(),
1547 "Roundtrip should produce zero warnings, got: {warnings:?}"
1548 );
1549 }
1550
1551 #[test]
1552 fn test_roundtrip_nested() {
1553 let rule = MD028NoBlanksBlockquote::with_fix(true);
1554 let content = ">> Nested\n\n>> More";
1555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1556 let fixed = rule.fix(&ctx).unwrap();
1557 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1558 let warnings = rule.check(&ctx2).unwrap();
1559 assert!(
1560 warnings.is_empty(),
1561 "Roundtrip should produce zero warnings, got: {warnings:?}"
1562 );
1563 }
1564
1565 #[test]
1566 fn test_roundtrip_indented() {
1567 let rule = MD028NoBlanksBlockquote::with_fix(true);
1568 let content = " > Indented\n\n > More";
1569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1570 let fixed = rule.fix(&ctx).unwrap();
1571 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1572 let warnings = rule.check(&ctx2).unwrap();
1573 assert!(
1574 warnings.is_empty(),
1575 "Roundtrip should produce zero warnings, got: {warnings:?}"
1576 );
1577 }
1578
1579 #[test]
1580 fn test_roundtrip_deeply_nested() {
1581 let rule = MD028NoBlanksBlockquote::with_fix(true);
1582 let content = ">>> Deep\n\n>>> More";
1583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1584 let fixed = rule.fix(&ctx).unwrap();
1585 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1586 let warnings = rule.check(&ctx2).unwrap();
1587 assert!(
1588 warnings.is_empty(),
1589 "Roundtrip should produce zero warnings, got: {warnings:?}"
1590 );
1591 }
1592
1593 #[test]
1594 fn test_roundtrip_multi_blockquotes() {
1595 let rule = MD028NoBlanksBlockquote::with_fix(true);
1596 let content = "> First\n> Line\n\n> Second\n> Line\n\n> Third\n";
1597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1598 let fixed = rule.fix(&ctx).unwrap();
1599 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
1600 let warnings = rule.check(&ctx2).unwrap();
1601 assert!(
1602 warnings.is_empty(),
1603 "Roundtrip should produce zero warnings, got: {warnings:?}"
1604 );
1605 }
1606
1607 #[test]
1608 fn test_roundtrip_idempotent() {
1609 let rule = MD028NoBlanksBlockquote::with_fix(true);
1610 let content = "> First\n\n> Second\n\n> Third\n";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let fixed1 = rule.fix(&ctx).unwrap();
1613 let ctx2 = LintContext::new(&fixed1, crate::config::MarkdownFlavor::Standard, None);
1614 let fixed2 = rule.fix(&ctx2).unwrap();
1615 assert_eq!(fixed1, fixed2, "Fix should be idempotent");
1616 }
1617
1618 #[test]
1619 fn test_html_block_does_not_leak_into_adjacent_blockquotes() {
1620 let rule = MD028NoBlanksBlockquote::with_fix(true);
1622 let content =
1623 "<details>\n<summary>Click</summary>\n> inside html block\n</details>\n\n> real quote A\n\n> real quote B";
1624 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
1625 let result = rule.check(&ctx).unwrap();
1626
1627 assert_eq!(
1629 result.len(),
1630 1,
1631 "Expected 1 warning for blank between real blockquotes after HTML block. Got: {result:?}"
1632 );
1633 }
1634}